diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3b08768..ece578c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,19 +17,17 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install GTK4 run: sudo apt update && sudo apt install libgtk-4-dev build-essential - - name: Install Rust - id: toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - components: rustfmt, clippy + - run: rustup toolchain install stable --profile minimal - name: Restore build cache - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2.7.5 + + - run: cargo build --verbose + - run: cargo test --verbose + - name: pre-commit - uses: pre-commit/action@v3.0.0 - - uses: pre-commit-ci/lite-action@v1.0.0 + uses: pre-commit/action@v3.0.1 + - uses: pre-commit-ci/lite-action@v1.1.0 if: always() diff --git a/Cargo.lock b/Cargo.lock index 001d467..74b257c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,9 +104,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" [[package]] name = "async-trait" @@ -265,18 +265,19 @@ checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "const_format" -version = "0.2.32" +version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a214c7af3d04997541b18d432afaff4c455e79e2029079647e72fc2bd27673" +checksum = "50c655d81ff1114fb0dcdea9225ea9f0cc712a6f8d189378e82bdf62a473a64b" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] name = "const_format_proc_macros" -version = "0.2.32" +version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f6ff08fd20f4f299298a28e2dfa8a8ba1036e6cd2460ac1de7b425d76f2500" +checksum = "eff1a44b93f47b1bac19a27932f5c591e43d1ba357ee4f61526c8a25603f0eb1" dependencies = [ "proc-macro2", "quote", @@ -879,11 +880,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330f0e13e6483b8c34885f7e6c9f19b1a7bd449c673fbb948a51c99d66ef74f4" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" @@ -1207,12 +1223,14 @@ dependencies = [ "glob", "greetd_ipc", "gtk4", + "lazy_static", "lru", "pwd", "regex", "relm4", "serde", "shlex", + "test-case", "thiserror", "tokio", "toml 0.6.0", @@ -1419,6 +1437,39 @@ version = "0.12.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f" +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", + "test-case-core", +] + [[package]] name = "thiserror" version = "1.0.61" diff --git a/Cargo.toml b/Cargo.toml index 217c02a..d0fac97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,12 +16,13 @@ license = "GPL-3.0-or-later" [dependencies] chrono = { version = "0.4.22", default-features = false } clap = { version = "4.1.4", features = ["derive"] } -const_format = "0.2.26" +const_format = { version = "0.2.33", features = ["rust_1_64"] } derivative = "2.2.0" file-rotate = "0.7.2" glob = "0.3.0" greetd_ipc = { version = "0.9.0", features = ["tokio-codec"] } gtk4 = "0.5" +lazy_static = "1.5.0" lru = "0.9.0" pwd = "1.4.0" regex = "1.7.1" @@ -38,3 +39,6 @@ tracker = "0.2.0" [features] gtk4_8 = ["gtk4/v4_8"] + +[dev-dependencies] +test-case = "3.3.1" diff --git a/README.md b/README.md index 068ce23..7290ec9 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,9 @@ LOG\_DIR | `/var/log/regreet` | The directory used to store logs SESSION\_DIRS | `/usr/share/xsessions:/usr/share/wayland-sessions` | A colon (:) separated list of directories where the greeter looks for session files REBOOT\_CMD | `reboot` | The default command used to reboot the system POWEROFF\_CMD | `poweroff` | The default command used to shut down the system +LOGIN\_DEFS\_PATHS | `/etc/login.defs:/usr/etc/login.defs` | A colon (:) separated list of `login.defs` file paths. First found is loaded. +LOGIN\_DEFS\_UID\_MIN | 1000 | Override the assumed default if `login.defs` doesnt specify `UID_MIN`. +LOGIN\_DEFS\_UID\_MAX | 60000 | Override the assumed default if `login.defs` doesnt specify `UID_MAX`. The greeter can be installed by copying the file `target/release/regreet` to `/usr/bin` (or similar directories like `/bin`). diff --git a/src/cache/lru.rs b/src/cache/lru.rs index 152d5df..a9ec5ba 100644 --- a/src/cache/lru.rs +++ b/src/cache/lru.rs @@ -19,7 +19,6 @@ use serde::{ ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer, }; -use tracing::warn; /// Wrapper to enable (de)serialization pub(super) struct LruCache(OrigLruCache); diff --git a/src/cache/mod.rs b/src/cache/mod.rs index d85571f..8062825 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -11,7 +11,6 @@ use std::num::NonZeroUsize; use std::path::Path; use serde::{Deserialize, Serialize}; -use tracing::info; use self::lru::LruCache; use crate::constants::CACHE_PATH; diff --git a/src/client.rs b/src/client.rs index 2ee5af0..da11e18 100644 --- a/src/client.rs +++ b/src/client.rs @@ -12,8 +12,6 @@ use greetd_ipc::{ AuthMessageType, ErrorType, Request, Response, }; use tokio::net::UnixStream; -use tracing::info; -use tracing::warn; /// Environment variable containing the path to the greetd socket const GREETD_SOCK_ENV_VAR: &str = "GREETD_SOCK"; diff --git a/src/constants.rs b/src/constants.rs index ef442d6..08afd68 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -4,8 +4,6 @@ //! Stores constants that can be configured at compile time -use const_format::concatcp; - /// Get an environment variable during compile time, else return a default. macro_rules! env_or { ($name:expr, $default:expr) => { @@ -49,6 +47,45 @@ pub const POWEROFF_CMD: &str = env_or!("POWEROFF_CMD", "poweroff"); /// Default greeting message pub const GREETING_MSG: &str = "Welcome back!"; +/// `:`-separated search path for `login.defs` file. +/// +/// By default this file is at `/etc/login.defs`, however some distros (e.g. Tumbleweed) move it to other locations. +/// +/// See: +pub const LOGIN_DEFS_PATHS: &[&str] = { + const ENV: &str = env_or!("LOGIN_DEFS_PATHS", "/etc/login.defs:/usr/etc/login.defs"); + &str_split!(ENV, ':') +}; + +lazy_static! { + /// Override the default `UID_MIN` in `login.defs`. If the string cannot be parsed at runtime, the value is `1_000`. + /// + /// This is not meant as a configuration facility. Only override this value if it's a different default in the + /// `passwd` suite. + pub static ref LOGIN_DEFS_UID_MIN: u64 = { + const DEFAULT: u64 = 1_000; + const ENV: &str = env_or!("LOGIN_DEFS_UID_MIN", formatcp!("{DEFAULT}")); + + ENV.parse() + .map_err(|e| error!("Failed to parse LOGIN_DEFS_UID_MIN='{ENV}': {e}. This is a compile time mistake!")) + .unwrap_or(DEFAULT) + }; + + /// Override the default `UID_MAX` in `login.defs`. If the string cannot be parsed at runtime, the value is + /// `60_000`. + /// + /// This is not meant as a configuration facility. Only override this value if it's a different default in the + /// `passwd` suite. + pub static ref LOGIN_DEFS_UID_MAX: u64 = { + const DEFAULT: u64 = 60_000; + const ENV: &str = env_or!("LOGIN_DEFS_UID_MAX", formatcp!("{DEFAULT}")); + + ENV.parse() + .map_err(|e| error!("Failed to parse LOGIN_DEFS_UID_MAX='{ENV}': {e}. This is a compile time mistake!")) + .unwrap_or(DEFAULT) + }; +} + /// Directories separated by `:`, containing desktop files for X11/Wayland sessions pub const SESSION_DIRS: &str = env_or!( "SESSION_DIRS", diff --git a/src/gui/component.rs b/src/gui/component.rs index 288dd0a..0c81630 100644 --- a/src/gui/component.rs +++ b/src/gui/component.rs @@ -8,7 +8,6 @@ use std::path::PathBuf; use std::time::Duration; use chrono::Local; -use tracing::{debug, info, warn}; use gtk::prelude::*; use relm4::{ diff --git a/src/gui/model.rs b/src/gui/model.rs index 136993b..f7aacb7 100644 --- a/src/gui/model.rs +++ b/src/gui/model.rs @@ -22,7 +22,6 @@ use relm4::{ AsyncComponentSender, }; use tokio::{sync::Mutex, time::sleep}; -use tracing::{debug, error, info, instrument, warn}; use crate::cache::Cache; use crate::client::{AuthStatus, GreetdClient}; diff --git a/src/main.rs b/src/main.rs index 08fb06d..945bb1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,17 @@ use tracing_subscriber::{ use crate::constants::{APP_ID, CONFIG_PATH, CSS_PATH, LOG_PATH}; use crate::gui::{Greeter, GreeterInit}; +#[macro_use] +extern crate tracing; +#[macro_use] +extern crate lazy_static; +#[macro_use] +extern crate const_format; + +#[cfg(test)] +#[macro_use] +extern crate test_case; + const MAX_LOG_FILES: usize = 3; const MAX_LOG_SIZE: usize = 1024 * 1024; diff --git a/src/sysutil.rs b/src/sysutil.rs index d308ba4..8697296 100644 --- a/src/sysutil.rs +++ b/src/sysutil.rs @@ -4,27 +4,20 @@ //! Helper for system utilities like users and sessions -use std::collections::HashMap; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::env; -use std::fs::read; -use std::io::Result as IOResult; +use std::fs::{read, read_to_string}; +use std::io; +use std::ops::ControlFlow; use std::path::Path; use std::str::from_utf8; use glob::glob; use pwd::Passwd; use regex::Regex; -use tracing::{debug, info, warn}; -use crate::constants::SESSION_DIRS; +use crate::constants::{LOGIN_DEFS_PATHS, LOGIN_DEFS_UID_MAX, LOGIN_DEFS_UID_MIN, SESSION_DIRS}; -/// Path to the file that contains min/max UID of a regular user -pub const LOGIN_FILE: &str = "/etc/login.defs"; -/// Default minimum UID for `useradd` (a/c to my system) -const DEFAULT_UID_MIN: u32 = 1000; -/// Default maximum UID for `useradd` (a/c to my system) -const DEFAULT_UID_MAX: u32 = 60000; /// XDG data directory variable name (parent directory for X11/Wayland sessions) const XDG_DIR_ENV_VAR: &str = "XDG_DATA_DIRS"; @@ -44,8 +37,32 @@ pub struct SysUtil { } impl SysUtil { - pub fn new() -> IOResult { - let (users, shells) = Self::init_users()?; + pub fn new() -> io::Result { + let path = (*LOGIN_DEFS_PATHS).iter().try_for_each(|path| { + if let Ok(true) = AsRef::::as_ref(&path).try_exists() { + ControlFlow::Break(path) + } else { + ControlFlow::Continue(()) + } + }); + + let normal_user = match path { + ControlFlow::Break(path) => read_to_string(path) + .map_err(|err| { + warn!("Failed to read login.defs from '{path}', using default values: {err}") + }) + .map(|text| NormalUser::parse_login_defs(&text)) + .unwrap_or_default(), + ControlFlow::Continue(()) => { + warn!("`login.defs` file not found in these paths: {LOGIN_DEFS_PATHS:?}",); + + NormalUser::default() + } + }; + + debug!("{normal_user:?}"); + + let (users, shells) = Self::init_users(normal_user)?; Ok(Self { users, shells, @@ -53,63 +70,14 @@ impl SysUtil { }) } - /// Get the min and max UID for the current system. - fn get_uid_limits() -> IOResult<(u32, u32)> { - let contents = read(LOGIN_FILE)?; - let text = from_utf8(contents.as_slice()) - .unwrap_or_else(|err| panic!("Login file '{LOGIN_FILE}' is not UTF-8: {err}")); - - // UID_MIN/MAX are limits to a UID for a regular user i.e. a user created with `useradd`. - // Thus, to find regular users, we filter the list of users with these UID limits. - let min_uid_regex = Regex::new(r"\nUID_MIN\s+([0-9]+)").expect("Invalid regex for UID_MIN"); - let max_uid_regex = Regex::new(r"\nUID_MAX\s+([0-9]+)").expect("Invalid regex for UID_MAX"); - - // Get UID_MIN. - let min_uid = if let Some(num) = min_uid_regex - .captures(text) - .and_then(|capture| capture.get(1)) - { - num.as_str() - .parse() - .expect("UID_MIN regex didn't capture an integer") - } else { - warn!("Failed to find UID_MIN in login file: {LOGIN_FILE}"); - DEFAULT_UID_MIN - }; - - // Get UID_MAX. - let max_uid = if let Some(num) = max_uid_regex - .captures(text) - .and_then(|capture| capture.get(1)) - { - num.as_str() - .parse() - .expect("UID_MAX regex didn't capture an integer") - } else { - warn!("Failed to find UID_MAX in login file: {LOGIN_FILE}"); - DEFAULT_UID_MAX - }; - - Ok((min_uid, max_uid)) - } - /// Get the list of regular users. /// /// These are defined as a list of users with UID between `UID_MIN` and `UID_MAX`. - fn init_users() -> IOResult<(UserMap, ShellMap)> { - let (min_uid, max_uid) = Self::get_uid_limits()?; - debug!("UID_MIN: {min_uid}, UID_MAX: {max_uid}"); - + fn init_users(normal_user: NormalUser) -> io::Result<(UserMap, ShellMap)> { let mut users = HashMap::new(); let mut shells = HashMap::new(); - // Iterate over all users in /etc/passwd. - for entry in Passwd::iter() { - if entry.uid > max_uid || entry.uid < min_uid { - // Non-standard user, eg. git or root - continue; - }; - + for entry in Passwd::iter().filter(|entry| normal_user.is_normal_user(entry.uid)) { // Use the actual system username if the "full name" is not available. let full_name = if let Some(gecos) = entry.gecos { if gecos.is_empty() { @@ -154,7 +122,7 @@ impl SysUtil { /// /// These are defined as either X11 or Wayland session desktop files stored in specific /// directories. - fn init_sessions() -> IOResult { + fn init_sessions() -> io::Result { let mut found_session_names = HashSet::new(); let mut sessions = HashMap::new(); @@ -331,3 +299,159 @@ impl SysUtil { &self.sessions } } + +/// A named tuple of min and max that stores UID limits for normal users. +/// +/// Use [`Self::parse_login_defs`] to obtain the system configuration. If the file is missing or there are +/// parsing errors a fallback of [`Self::default`] should be used. +#[derive(Debug, PartialEq, Eq)] +struct NormalUser { + uid_min: u64, + uid_max: u64, +} + +impl Default for NormalUser { + fn default() -> Self { + Self { + uid_min: *LOGIN_DEFS_UID_MIN, + uid_max: *LOGIN_DEFS_UID_MAX, + } + } +} + +impl NormalUser { + /// Parses the `login.defs` file content and looks for `UID_MIN` and `UID_MAX` definitions. If a definition is + /// missing or causes parsing errors, the default values [`struct@LOGIN_DEFS_UID_MIN`] and + /// [`struct@LOGIN_DEFS_UID_MAX`] are used. + /// + /// This parser is highly specific to parsing the 2 required values, thus it focuses on doing the least amout of + /// compute required to extracting them. + /// + /// Errors are dropped because they are unlikely and their handling would result in the use of default values + /// anyway. + pub fn parse_login_defs(text: &str) -> Self { + let mut min = None; + let mut max = None; + + for line in text.lines().map(str::trim) { + const KEY_LENGTH: usize = "UID_XXX".len(); + + // At MSRV 1.80 you could use `split_at_checked`, this is just a way to not raise it. + // This checks if the string is of sufficient length too. + if !line.is_char_boundary(KEY_LENGTH) { + continue; + } + let (key, val) = line.split_at(KEY_LENGTH); + + if !val.starts_with(char::is_whitespace) { + continue; + } + + match (key, min, max) { + ("UID_MIN", None, _) => min = Self::parse_number(val), + ("UID_MAX", _, None) => max = Self::parse_number(val), + _ => continue, + } + + if min.is_some() && max.is_some() { + break; + } + } + + Self { + uid_min: min.unwrap_or(*LOGIN_DEFS_UID_MIN), + uid_max: max.unwrap_or(*LOGIN_DEFS_UID_MAX), + } + } + + /// Parses a number value in a `/etc/login.defs` entry. As per the manpage: + /// + /// - `0x` prefix: hex number + /// - `0` prefix: octal number + /// - starts with `1..9`: decimal number + /// + /// In case the string value is not parsable as a number the entry value is considered invalid and `None` is + /// returned. + fn parse_number(num: &str) -> Option { + let num = num.trim(); + if num == "0" { + return Some(0); + } + + if let Some(octal) = num.strip_prefix('0') { + if let Some(hex) = octal.strip_prefix('x') { + return u64::from_str_radix(hex, 16).ok(); + } + + return u64::from_str_radix(octal, 8).ok(); + } + + num.parse().ok() + } + + // Returns true for regular users, false for those outside the UID limit, eg. git or root. + pub fn is_normal_user(&self, uid: T) -> bool + where + T: Into, + { + (self.uid_min..=self.uid_max).contains(&uid.into()) + } +} + +#[cfg(test)] +mod tests { + #[allow(non_snake_case)] + mod UidLimit { + use super::super::*; + + #[test_case( + &["UID_MIN 1", "UID_MAX 10"].join("\n") + => NormalUser { uid_min: 1, uid_max: 10 }; + "both configured" + )] + #[test_case( + &["UID_MAX 10", "UID_MIN 1"].join("\n") + => NormalUser { uid_min: 1, uid_max: 10 }; + "reverse order" + )] + #[test_case( + &["OTHER 20", + "# Comment", + "", + "UID_MAX 10", + "UID_MIN 1", + "MORE_TEXT 40"].join("\n") + => NormalUser { uid_min: 1, uid_max: 10 }; + "complex file" + )] + #[test_case( + "UID_MAX10" + => NormalUser::default(); + "no space" + )] + #[test_case( + "SUB_UID_MAX 10" + => NormalUser::default(); + "invalid field (with prefix)" + )] + #[test_case( + "UID_MAX_BLAH 10" + => NormalUser::default(); + "invalid field (with suffix)" + )] + fn parse_login_defs(text: &str) -> NormalUser { + NormalUser::parse_login_defs(text) + } + + #[test_case("" => None; "empty")] + #[test_case("no" => None; "string")] + #[test_case("0" => Some(0); "zero")] + #[test_case("0x" => None; "0x isn't a hex number")] + #[test_case("10" => Some(10); "decimal")] + #[test_case("0777" => Some(0o777); "octal")] + #[test_case("0xDeadBeef" => Some(0xdead_beef); "hex")] + fn parse_number(num: &str) -> Option { + NormalUser::parse_number(num) + } + } +} diff --git a/src/tomlutils.rs b/src/tomlutils.rs index da793aa..e8f1e06 100644 --- a/src/tomlutils.rs +++ b/src/tomlutils.rs @@ -9,7 +9,6 @@ use std::fs::read; use std::path::Path; use serde::de::DeserializeOwned; -use tracing::{info, warn}; /// Contains possible errors when loading/saving TOML from/to disk #[derive(thiserror::Error, Debug)]