Rust · 5180 bytes Raw Blame History
1 //! Session and user enumeration
2 //!
3 //! Discovers available X11/Wayland sessions and system users.
4
5 use gardm_ipc::{SessionInfo, UserInfo};
6 use std::fs;
7 use std::path::{Path, PathBuf};
8
9 /// Directories to search for X11 session .desktop files
10 const XSESSION_DIRS: &[&str] = &[
11 "/usr/share/xsessions",
12 "/usr/local/share/xsessions",
13 ];
14
15 /// Directories to search for Wayland session .desktop files
16 const WAYLAND_SESSION_DIRS: &[&str] = &[
17 "/usr/share/wayland-sessions",
18 "/usr/local/share/wayland-sessions",
19 ];
20
21 /// Minimum UID for regular users (typically 1000 on most systems)
22 const MIN_UID: u32 = 1000;
23
24 /// Maximum UID for regular users
25 const MAX_UID: u32 = 60000;
26
27 /// Enumerate available sessions from .desktop files
28 /// Returns both X11 and Wayland sessions. The daemon handles launching
29 /// each session type appropriately (X11 on existing server, Wayland directly on VT).
30 pub fn list_sessions() -> Vec<SessionInfo> {
31 let mut sessions = Vec::new();
32
33 // X11 sessions
34 for dir in XSESSION_DIRS {
35 if let Ok(entries) = fs::read_dir(dir) {
36 for entry in entries.flatten() {
37 if let Some(session) = parse_desktop_file(&entry.path(), "x11") {
38 sessions.push(session);
39 }
40 }
41 }
42 }
43
44 // Wayland sessions
45 for dir in WAYLAND_SESSION_DIRS {
46 if let Ok(entries) = fs::read_dir(dir) {
47 for entry in entries.flatten() {
48 if let Some(session) = parse_desktop_file(&entry.path(), "wayland") {
49 sessions.push(session);
50 }
51 }
52 }
53 }
54
55 // Sort by name for consistent ordering
56 sessions.sort_by(|a, b| a.name.cmp(&b.name));
57
58 sessions
59 }
60
61 /// Parse a .desktop file into SessionInfo
62 fn parse_desktop_file(path: &Path, session_type: &str) -> Option<SessionInfo> {
63 let filename = path.file_name()?.to_str()?;
64 if !filename.ends_with(".desktop") {
65 return None;
66 }
67
68 let content = fs::read_to_string(path).ok()?;
69 let mut name = None;
70 let mut comment = None;
71 let mut exec = None;
72
73 for line in content.lines() {
74 let line = line.trim();
75 if line.starts_with("Name=") {
76 name = Some(line.strip_prefix("Name=")?.to_string());
77 } else if line.starts_with("Comment=") {
78 comment = Some(line.strip_prefix("Comment=")?.to_string());
79 } else if line.starts_with("Exec=") {
80 exec = Some(line.strip_prefix("Exec=")?.to_string());
81 }
82 }
83
84 let id = filename.strip_suffix(".desktop")?.to_string();
85 let name = name.unwrap_or_else(|| id.clone());
86 let exec = exec?;
87
88 Some(SessionInfo {
89 id,
90 name,
91 comment,
92 exec,
93 session_type: session_type.to_string(),
94 })
95 }
96
97 /// Enumerate users suitable for login
98 pub fn list_users() -> Vec<UserInfo> {
99 let mut users = Vec::new();
100
101 // Read /etc/passwd
102 if let Ok(content) = fs::read_to_string("/etc/passwd") {
103 for line in content.lines() {
104 if let Some(user) = parse_passwd_line(line) {
105 users.push(user);
106 }
107 }
108 }
109
110 // Sort by username
111 users.sort_by(|a, b| a.name.cmp(&b.name));
112
113 users
114 }
115
116 /// Parse a line from /etc/passwd
117 fn parse_passwd_line(line: &str) -> Option<UserInfo> {
118 let fields: Vec<&str> = line.split(':').collect();
119 if fields.len() < 7 {
120 return None;
121 }
122
123 let name = fields[0].to_string();
124 let uid: u32 = fields[2].parse().ok()?;
125 let gecos = fields[4];
126 let home = PathBuf::from(fields[5]);
127 let shell = fields[6];
128
129 // Filter: only regular users (UID in range, valid shell)
130 if uid < MIN_UID || uid > MAX_UID {
131 return None;
132 }
133
134 // Skip users with nologin shell
135 if shell.contains("nologin") || shell.contains("false") {
136 return None;
137 }
138
139 // Parse GECOS field (full name is first comma-separated field)
140 let full_name = if gecos.is_empty() {
141 None
142 } else {
143 Some(gecos.split(',').next()?.to_string())
144 };
145
146 // Check for user avatar
147 let avatar = find_user_avatar(&name, &home);
148
149 Some(UserInfo {
150 name,
151 full_name,
152 home,
153 avatar,
154 })
155 }
156
157 /// Find user avatar image if available
158 fn find_user_avatar(username: &str, home: &Path) -> Option<PathBuf> {
159 // Check common avatar locations
160 let candidates = [
161 home.join(".face"),
162 home.join(".face.icon"),
163 PathBuf::from(format!("/var/lib/AccountsService/icons/{}", username)),
164 ];
165
166 for path in &candidates {
167 if path.exists() {
168 return Some(path.clone());
169 }
170 }
171
172 None
173 }
174
175 #[cfg(test)]
176 mod tests {
177 use super::*;
178
179 #[test]
180 fn test_list_sessions() {
181 // This test may return empty on systems without xsessions
182 let sessions = list_sessions();
183 // Just verify it doesn't panic
184 println!("Found {} sessions", sessions.len());
185 }
186
187 #[test]
188 fn test_list_users() {
189 let users = list_users();
190 // Should find at least the current user on most systems
191 println!("Found {} users", users.len());
192 }
193 }
194