Repository: trifectatechfoundation/sudo-rs
Pull request #1065: Allow '@' signs in user names
Author: login: squell
Labels: name: C-parser
description: Parser/AST
Created: 2025-04-03T09:41:50Z | Merged: 2025-04-29T16:12:02Z

Closing #1064 


Diff:
diff --git a/src/sudoers/ast.rs b/src/sudoers/ast.rs
index 313e0b2c2..2a7194173 100644
--- a/src/sudoers/ast.rs
+++ b/src/sudoers/ast.rs
@@ -1,5 +1,6 @@
 use super::ast_names::UserFriendly;
 use super::basic_parser::*;
+use super::char_stream::advance;
 use super::tokens::*;
 use crate::common::SudoString;
 use crate::common::{
@@ -309,16 +310,13 @@ impl Parse for UserSpecifier {
 
         // if we see a quote, first parse the quoted text as a token and then
         // re-parse whatever we found inside; this is a lazy solution but it works
-        let begin_pos = stream.get_pos();
         if stream.eat_char('"') {
+            let begin_pos = stream.get_pos();
             let Unquoted(text, _): Unquoted<Username> = expect_nonterminal(stream)?;
-            let result = parse_user(&mut CharStream::new(text.chars()));
-            if result.is_err() {
-                unrecoverable!(pos = begin_pos, stream, "invalid user")
-            }
+            let result = parse_user(&mut CharStream::new_with_pos(&text, begin_pos))?;
             expect_syntax('"', stream)?;
 
-            result
+            Ok(result)
         } else {
             parse_user(stream)
         }
@@ -576,7 +574,13 @@ impl Parse for Sudo {
         }
 
         let start_pos = stream.get_pos();
-        if let Some(users) = maybe(try_nonterminal::<SpecList<_>>(stream))? {
+        if stream.peek() == Some('"') {
+            // a quoted userlist follows; this forces us to read a userlist
+            let users = expect_nonterminal(stream)?;
+            let permissions = expect_nonterminal(stream)?;
+            make(Sudo::Spec(PermissionSpec { users, permissions }))
+        } else if let Some(users) = maybe(try_nonterminal::<SpecList<_>>(stream))? {
+            // this could be the start of a Defaults or Alias definition, so distinguish.
             // element 1 always exists (parse_list fails on an empty list)
             let key = &users[0];
             if let Some(directive) = maybe(get_directive(key, stream, start_pos))? {
@@ -692,15 +696,26 @@ fn get_directive(
         "Host_Alias" => make(HostAlias(expect_nonterminal(stream)?)),
         "Cmnd_Alias" | "Cmd_Alias" => make(CmndAlias(expect_nonterminal(stream)?)),
         "Runas_Alias" => make(RunasAlias(expect_nonterminal(stream)?)),
-        "Defaults" => {
-            //HACK: this avoids having to add "Defaults@" etc as separate tokens; but relying
-            //on positional information during parsing is of course, cheating.
+        _ if keyword.starts_with("Defaults") => {
+            //HACK #1: no space is allowed between "Defaults" and '!>@:'. The below avoids having to
+            //add "Defaults!" etc as separate tokens; but relying on positional information during
+            //parsing is of course, cheating.
+            //HACK #2: '@' can be part of a username, so it will already have been parsed;
+            //an acceptable hostname is subset of an acceptable username, so that's actually OK.
+            //This resolves an ambiguity in the grammar similarly to how MetaOrTag does that.
+            const DEFAULTS_LEN: usize = "Defaults".len();
             let allow_scope_modifier = stream.get_pos().0 == begin_pos.0
-                && stream.get_pos().1 - begin_pos.1 == "Defaults".len();
+                && (stream.get_pos().1 - begin_pos.1 == DEFAULTS_LEN
+                    || keyword.len() > DEFAULTS_LEN);
 
             let scope = if allow_scope_modifier {
-                if is_syntax('@', stream)? {
-                    ConfigScope::Host(expect_nonterminal(stream)?)
+                if keyword[DEFAULTS_LEN..].starts_with('@') {
+                    let inner_stream = &mut CharStream::new_with_pos(
+                        &keyword[DEFAULTS_LEN + 1..],
+                        advance(begin_pos, DEFAULTS_LEN + 1),
+                    );
+
+                    ConfigScope::Host(expect_nonterminal(inner_stream)?)
                 } else if is_syntax(':', stream)? {
                     ConfigScope::User(expect_nonterminal(stream)?)
                 } else if is_syntax('!', stream)? {
diff --git a/src/sudoers/basic_parser.rs b/src/sudoers/basic_parser.rs
index 6437b803a..73b8ccc9f 100644
--- a/src/sudoers/basic_parser.rs
+++ b/src/sudoers/basic_parser.rs
@@ -348,7 +348,7 @@ fn expect_complete<T: Parse>(stream: &mut CharStream) -> Parsed<T> {
 /// AST constructors by hand.
 #[cfg(test)]
 pub fn parse_string<T: Parse>(text: &str) -> Parsed<T> {
-    expect_complete(&mut CharStream::new(text.chars()))
+    expect_complete(&mut CharStream::new(text))
 }
 
 #[cfg(test)]
@@ -384,7 +384,7 @@ mod test {
 
     #[test]
     fn lines_test() {
-        let input = |text: &str| parse_lines(&mut CharStream::new(text.chars()));
+        let input = |text: &str| parse_lines(&mut CharStream::new(text));
 
         let s = |text: &str| Ok(text.to_string());
         assert_eq!(input("hello\nworld\n"), vec![s("hello"), s("world")]);
diff --git a/src/sudoers/char_stream.rs b/src/sudoers/char_stream.rs
index 53836f9e6..2ed9eed4e 100644
--- a/src/sudoers/char_stream.rs
+++ b/src/sudoers/char_stream.rs
@@ -4,14 +4,23 @@ pub struct CharStream<'a> {
     col: usize,
 }
 
+/// Advance the given position by `n` horizontal steps
+pub fn advance(pos: (usize, usize), n: usize) -> (usize, usize) {
+    (pos.0, pos.1 + n)
+}
+
 impl<'a> CharStream<'a> {
-    pub fn new(src: std::str::Chars<'a>) -> Self {
+    pub fn new_with_pos(src: &'a str, (line, col): (usize, usize)) -> Self {
         CharStream {
-            iter: src.peekable(),
-            line: 1,
-            col: 1,
+            iter: src.chars().peekable(),
+            line,
+            col,
         }
     }
+
+    pub fn new(src: &'a str) -> Self {
+        Self::new_with_pos(src, (1, 1))
+    }
 }
 
 impl CharStream<'_> {
@@ -51,7 +60,7 @@ mod test {
 
     #[test]
     fn test_iter() {
-        let mut stream = CharStream::new("12\n3\n".chars());
+        let mut stream = CharStream::new("12\n3\n");
         assert_eq!(stream.peek(), Some('1'));
         assert!(stream.eat_char('1'));
         assert_eq!(stream.peek(), Some('2'));
diff --git a/src/sudoers/mod.rs b/src/sudoers/mod.rs
index 1e9a0c98d..787551311 100644
--- a/src/sudoers/mod.rs
+++ b/src/sudoers/mod.rs
@@ -336,7 +336,7 @@ fn read_sudoers<R: io::Read>(mut reader: R) -> io::Result<Vec<basic_parser::Pars
 
     use basic_parser::parse_lines;
     use char_stream::*;
-    Ok(parse_lines(&mut CharStream::new(buffer.chars())))
+    Ok(parse_lines(&mut CharStream::new(&buffer)))
 }
 
 fn open_sudoers(path: &Path) -> io::Result<Vec<basic_parser::Parsed<Sudo>>> {
diff --git a/src/sudoers/test/mod.rs b/src/sudoers/test/mod.rs
index 59fb41fb1..88df95272 100644
--- a/src/sudoers/test/mod.rs
+++ b/src/sudoers/test/mod.rs
@@ -58,7 +58,7 @@ macro_rules! request {
 
 macro_rules! sudoer {
     ($($e:expr),*) => {
-        parse_lines(&mut CharStream::new([$($e),*, ""].join("\n").chars()))
+        parse_lines(&mut CharStream::new(&[$($e),*, ""].join("\n")))
             .into_iter()
             .map(|x| Ok::<_,basic_parser::Status>(x.unwrap()))
     }
@@ -72,7 +72,7 @@ fn parse_line(s: &str) -> Sudo {
 
 /// Returns `None` if a syntax error is encountered
 fn try_parse_line(s: &str) -> Option<Sudo> {
-    parse_lines(&mut CharStream::new([s, ""].join("").chars()))
+    parse_lines(&mut CharStream::new(&[s, ""].join("")))
         .into_iter()
         .next()?
         .ok()
@@ -251,6 +251,10 @@ fn permission_test() {
     pass!(["\"a,b\" ALL=ALL"], "a,b" => request! { root, root }, "server"; "/bin/foo");
     pass!(["\"a\\b\" ALL=ALL"], "a\\b" => request! { root, root }, "server"; "/bin/foo");
 
+    // special chacters
+    pass!(["foo@machine.name ALL=ALL"], "foo@machine.name" => request! { root, root }, "server"; "/bin/foo");
+    pass!(["fnord$ ALL=ALL"], "fnord$" => request! { root, root }, "server"; "/bin/foo");
+
     // apparmor
     #[cfg(feature = "apparmor")]
     pass!(["ALL ALL=(ALL:ALL) APPARMOR_PROFILE=unconfined ALL"], "user" => root(), "server"; "/bin/bar" => [apparmor_profile: Some("unconfined".to_string())]);
@@ -350,6 +354,12 @@ fn invalid_directive() {
     parse_eval::<ast::Sudo>("User_Alias, user Alias = user1, user2");
 }
 
+#[test]
+#[should_panic = "embedded $ in username"]
+fn invalid_username() {
+    parse_eval::<ast::Sudo>("User_Alias FOO = $dollar");
+}
+
 #[test]
 fn directive_test() {
     let y = parse_eval::<Spec<UserSpecifier>>;
@@ -471,6 +481,12 @@ fn specific_defaults() {
     assert!(try_parse_line("Defaults >user !use_pty").is_none());
 }
 
+#[test]
+fn at_sign_ambiguity() {
+    assert!(parse_line("Defaults@host env_keep=ALL").is_decl());
+    assert!(parse_line("defaults@host env_keep=ALL").is_spec());
+}
+
 #[test]
 fn default_specific_test() {
     let sudoers = || {
diff --git a/src/sudoers/tokens.rs b/src/sudoers/tokens.rs
index 4c54731f2..abcbb379d 100644
--- a/src/sudoers/tokens.rs
+++ b/src/sudoers/tokens.rs
@@ -9,19 +9,25 @@ use crate::common::{HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_
 pub struct Username(pub SudoString);
 
 /// A username consists of alphanumeric characters as well as "." and "-", but does not start with an underscore.
+// See: https://systemd.io/USER_NAMES/
 impl Token for Username {
     fn construct(text: String) -> Result<Self, String> {
+        // if a '$' occurs in a username, it has to be the final character
+        if text.strip_suffix('$').unwrap_or(&text).contains('$') {
+            return Err("embedded $ in username".to_string());
+        }
+
         SudoString::new(text)
             .map_err(|e| e.to_string())
             .map(Username)
     }
 
     fn accept(c: char) -> bool {
-        c.is_ascii_alphanumeric() || ".-_".contains(c)
+        c.is_ascii_alphanumeric() || ".-_@$".contains(c)
     }
 
     fn accept_1st(c: char) -> bool {
-        c != '_' && Self::accept(c)
+        c != '_' && c != '@' && Self::accept(c)
     }
 
     const ALLOW_ESCAPE: bool = true;


Reviews:
user: login: 3v1n0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T17:11:07Z
user: login: squell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T16:21:32Z
user: login: bjorn3
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T14:39:28Z
user: login: bjorn3
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T15:10:57Z
user: login: squell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T15:12:10Z
user: login: squell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T15:15:10Z
user: login: squell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T15:16:12Z
user: login: bjorn3
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-29T15:21:40Z
user: login: squell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T15:57:05Z

Review comments:
diff_hunk: @@ -9,19 +9,24 @@ use crate::common::{HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_
 pub struct Username(pub SudoString);
 
 /// A username consists of alphanumeric characters as well as "." and "-", but does not start with an underscore.
+// See: https://systemd.io/USER_NAMES/
 impl Token for Username {
     fn construct(text: String) -> Result<Self, String> {
-        SudoString::new(text)
-            .map_err(|e| e.to_string())
-            .map(Username)
+        if let Some((_, "")) | None = text.split_once('$') {
+            SudoString::new(text)
+                .map_err(|e| e.to_string())
+                .map(Username)
+        } else {
+            Err("embedded $ in username".to_string())
+        }
     }
 
     fn accept(c: char) -> bool {
-        c.is_ascii_alphanumeric() || ".-_".contains(c)
+        c.is_ascii_alphanumeric() || ".-_@$".contains(c)
path: src/sudoers/tokens.rs
user: login: 3v1n0
body: `$` can be only used as last char, no? 
created_at: 2025-04-05T17:11:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,19 +9,24 @@ use crate::common::{HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_
 pub struct Username(pub SudoString);
 
 /// A username consists of alphanumeric characters as well as "." and "-", but does not start with an underscore.
+// See: https://systemd.io/USER_NAMES/
 impl Token for Username {
     fn construct(text: String) -> Result<Self, String> {
-        SudoString::new(text)
-            .map_err(|e| e.to_string())
-            .map(Username)
+        if let Some((_, "")) | None = text.split_once('$') {
+            SudoString::new(text)
+                .map_err(|e| e.to_string())
+                .map(Username)
+        } else {
+            Err("embedded $ in username".to_string())
+        }
     }
 
     fn accept(c: char) -> bool {
-        c.is_ascii_alphanumeric() || ".-_".contains(c)
+        c.is_ascii_alphanumeric() || ".-_@$".contains(c)
path: src/sudoers/tokens.rs
user: login: squell
body: You can see in ecf5d95904cd42732e1099bb786ec5f2a971f17 that `fn construct()` changes as well.
created_at: 2025-04-06T16:21:32Z
author_association: MEMBER
diff_hunk: @@ -316,13 +317,10 @@ impl Parse for UserSpecifier {
         let begin_pos = stream.get_pos();
         if stream.eat_char('"') {
             let Unquoted(text, _): Unquoted<Username> = expect_nonterminal(stream)?;
-            let result = parse_user(&mut CharStream::new(text.chars()));
-            if result.is_err() {
-                unrecoverable!(pos = begin_pos, stream, "invalid user")
-            }
+            let result = parse_user(&mut CharStream::new_with_pos(&text, advance(begin_pos, 1)))?;
path: src/sudoers/ast.rs
user: login: bjorn3
body: Calling `get_pos()` after `stream.eat_char()` would work too, right?
created_at: 2025-04-29T14:39:27Z
author_association: COLLABORATOR
diff_hunk: @@ -350,6 +354,12 @@ fn invalid_directive() {
     parse_eval::<ast::Sudo>("User_Alias, user Alias = user1, user2");
 }
 
+#[test]
+#[should_panic]
path: src/sudoers/test/mod.rs
user: login: bjorn3
body: ```suggestion
#[should_panic = "embedded $ in username"]
```

to ensure it doesn't accidentally pass for the wrong reason.
created_at: 2025-04-29T14:51:41Z
author_association: COLLABORATOR
diff_hunk: @@ -12,13 +12,17 @@ pub struct Username(pub SudoString);
 // See: https://systemd.io/USER_NAMES/
 impl Token for Username {
     fn construct(text: String) -> Result<Self, String> {
-        SudoString::new(text)
-            .map_err(|e| e.to_string())
-            .map(Username)
+        if let Some((_, "")) | None = text.split_once('$') {
+            SudoString::new(text)
+                .map_err(|e| e.to_string())
+                .map(Username)
+        } else {
+            Err("embedded $ in username".to_string())
+        }
path: src/sudoers/tokens.rs
user: login: bjorn3
body: ```suggestion
        if text.strip_suffix('$').unwrap_or(text).contains('$') {
            return Err("embedded $ in username".to_string());
        }

        SudoString::new(text)
            .map_err(|e| e.to_string())
            .map(Username)
```
created_at: 2025-04-29T15:10:53Z
author_association: COLLABORATOR
diff_hunk: @@ -316,13 +317,10 @@ impl Parse for UserSpecifier {
         let begin_pos = stream.get_pos();
         if stream.eat_char('"') {
             let Unquoted(text, _): Unquoted<Username> = expect_nonterminal(stream)?;
-            let result = parse_user(&mut CharStream::new(text.chars()));
-            if result.is_err() {
-                unrecoverable!(pos = begin_pos, stream, "invalid user")
-            }
+            let result = parse_user(&mut CharStream::new_with_pos(&text, advance(begin_pos, 1)))?;
path: src/sudoers/ast.rs
user: login: squell
body: That simplifies this part a bit, yes.
created_at: 2025-04-29T15:12:09Z
author_association: MEMBER
diff_hunk: @@ -350,6 +354,12 @@ fn invalid_directive() {
     parse_eval::<ast::Sudo>("User_Alias, user Alias = user1, user2");
 }
 
+#[test]
+#[should_panic]
path: src/sudoers/test/mod.rs
user: login: squell
body: Maybe we should use this for other`should_panic` tests as well.
created_at: 2025-04-29T15:15:10Z
author_association: MEMBER
diff_hunk: @@ -12,13 +12,17 @@ pub struct Username(pub SudoString);
 // See: https://systemd.io/USER_NAMES/
 impl Token for Username {
     fn construct(text: String) -> Result<Self, String> {
-        SudoString::new(text)
-            .map_err(|e| e.to_string())
-            .map(Username)
+        if let Some((_, "")) | None = text.split_once('$') {
+            SudoString::new(text)
+                .map_err(|e| e.to_string())
+                .map(Username)
+        } else {
+            Err("embedded $ in username".to_string())
+        }
path: src/sudoers/tokens.rs
user: login: squell
body: Cool!
created_at: 2025-04-29T15:16:12Z
author_association: MEMBER
diff_hunk: @@ -696,15 +700,26 @@ fn get_directive(
         "Host_Alias" => make(HostAlias(expect_nonterminal(stream)?)),
         "Cmnd_Alias" | "Cmd_Alias" => make(CmndAlias(expect_nonterminal(stream)?)),
         "Runas_Alias" => make(RunasAlias(expect_nonterminal(stream)?)),
-        "Defaults" => {
-            //HACK: this avoids having to add "Defaults@" etc as separate tokens; but relying
-            //on positional information during parsing is of course, cheating.
+        _ if keyword.starts_with("Defaults") => {
+            //HACK #1: no space is allowed between "Defaults" and '!>@:'. The below avoids having to
+            //add "Defaults!" etc as separate tokens; but relying on positional information during
+            //parsing is of course, cheating.
+            //HACK #2: '@' can be part of a username, so it will already have been parsed;
+            //an acceptable hostname is subset of an acceptable username, so that's actually OK.
+            //This resolves an ambiguity in the grammar similarly to how MetaOrTag does that.
+            const DEFAULTS_LEN: usize = "Defaults".len();
             let allow_scope_modifier = stream.get_pos().0 == begin_pos.0
-                && stream.get_pos().1 - begin_pos.1 == "Defaults".len();
+                && (stream.get_pos().1 - begin_pos.1 == DEFAULTS_LEN
+                    || keyword.len() > DEFAULTS_LEN);
 
             let scope = if allow_scope_modifier {
-                if is_syntax('@', stream)? {
-                    ConfigScope::Host(expect_nonterminal(stream)?)
+                if keyword[DEFAULTS_LEN..].starts_with('@') {
+                    let inner_stream = &mut CharStream::new_with_pos(
+                        &keyword[DEFAULTS_LEN + 1..],
+                        advance(begin_pos, DEFAULTS_LEN + 1),
+                    );
path: src/sudoers/ast.rs
user: login: bjorn3
body: Maybe we could have a method on `CharStream` to advance a given amount of positions and then use `CharStream::new_with_pos(&keyword, begin_pos)` followed by calling this method with `DEFAULTS_LEN + 1` as argument? The code you have right now is fine too though.
created_at: 2025-04-29T15:20:55Z
author_association: COLLABORATOR
diff_hunk: @@ -696,15 +700,26 @@ fn get_directive(
         "Host_Alias" => make(HostAlias(expect_nonterminal(stream)?)),
         "Cmnd_Alias" | "Cmd_Alias" => make(CmndAlias(expect_nonterminal(stream)?)),
         "Runas_Alias" => make(RunasAlias(expect_nonterminal(stream)?)),
-        "Defaults" => {
-            //HACK: this avoids having to add "Defaults@" etc as separate tokens; but relying
-            //on positional information during parsing is of course, cheating.
+        _ if keyword.starts_with("Defaults") => {
+            //HACK #1: no space is allowed between "Defaults" and '!>@:'. The below avoids having to
+            //add "Defaults!" etc as separate tokens; but relying on positional information during
+            //parsing is of course, cheating.
+            //HACK #2: '@' can be part of a username, so it will already have been parsed;
+            //an acceptable hostname is subset of an acceptable username, so that's actually OK.
+            //This resolves an ambiguity in the grammar similarly to how MetaOrTag does that.
+            const DEFAULTS_LEN: usize = "Defaults".len();
             let allow_scope_modifier = stream.get_pos().0 == begin_pos.0
-                && stream.get_pos().1 - begin_pos.1 == "Defaults".len();
+                && (stream.get_pos().1 - begin_pos.1 == DEFAULTS_LEN
+                    || keyword.len() > DEFAULTS_LEN);
 
             let scope = if allow_scope_modifier {
-                if is_syntax('@', stream)? {
-                    ConfigScope::Host(expect_nonterminal(stream)?)
+                if keyword[DEFAULTS_LEN..].starts_with('@') {
+                    let inner_stream = &mut CharStream::new_with_pos(
+                        &keyword[DEFAULTS_LEN + 1..],
+                        advance(begin_pos, DEFAULTS_LEN + 1),
+                    );
path: src/sudoers/ast.rs
user: login: squell
body: I like this idea. I'd prefer to handle that in a separate PR!
created_at: 2025-04-29T15:57:04Z
author_association: MEMBER

PR comments:
user: login: 3v1n0
created_at: 2025-04-03T15:32:31Z
body: I think we should be a bit more conform to what `adduser` (see [`man adduser.conf`](https://manpages.ubuntu.com/manpages/plucky/en/man5/adduser.conf.5.html)) allows here:

```
VALID NAMES
       Historically, adduser(8) and addgroup(8) enforced conformity to IEEE Std 1003.1-2001, which allows only the following characters to ap‐
       pear in group- and usernames: letters, digits, underscores, periods, at signs (@) and dashes.  The name may not start with a dash or @.
       The "$" sign is allowed at the end of usernames to allow typical Samba machine accounts.

       The  default settings for NAME_REGEX and SYS_NAME_REGEX allow usernames to contain lowercase letters and numbers, plus dash (-) and un‐
       derscore (_); the name must begin with a letter (or an underscore for system users).

       The least restrictive policy, available by using the --allow-all-names option, simply makes the same checks as useradd(8): cannot start
       with a dash, plus sign, or tilde; and cannot contain a colon, comma, slash, or whitespace.

       This option can be used to create confusing or misleading names; use it with caution.

       Please note that regardless of the regular expressions used to evaluate the username, it may be a maximum of 32 bytes; this may be less
       than 32 visual characters when using Unicode glyphs in the username.
```
author_association: CONTRIBUTOR
user: login: squell
created_at: 2025-04-08T09:18:57Z
body: Todo: add unit tests, add comment about `$` being last.
author_association: MEMBER