roowho2_lib/server/walld/
tty_utils.rs1use std::{
4 collections::HashSet,
5 io::Error,
6 path::{Path, PathBuf},
7 time::{Duration, SystemTime},
8};
9
10use chrono::Timelike;
11use nix::{
12 errno::Errno,
13 fcntl::{self, OFlag},
14 sys::stat::{Mode, SFlag, stat},
15 unistd,
16};
17use tokio::{io::unix::AsyncFd, time};
18use uucore::utmpx::Utmpx;
19
20const TERM_WIDTH: usize = 79;
21
22#[derive(Debug, thiserror::Error)]
23pub enum TtyError {
24 #[error("invalid tty name: {0:?}")]
25 InvalidName(String),
26 #[error("{0}: no such tty")]
27 NotFound(String),
28 #[error("{0}: not a character device")]
29 NotCharacterDevice(String),
30 #[error("{0}: messages are disabled on this tty")]
31 MessagesDisabled(String),
32 #[error("{0}: device is busy or not accessible")]
33 Unavailable(String),
34 #[error("timed out writing to {0}")]
35 Timeout(String),
36 #[error("{0}: not owned by the caller")]
37 NotOwnedByCaller(String),
38 #[error("{path}: {source}")]
39 Io {
40 path: String,
41 #[source]
42 source: Error,
43 },
44}
45
46fn validate_tty_name(name: &str) -> Result<(), TtyError> {
47 if name.is_empty()
48 || name.starts_with(':')
49 || name.starts_with('/')
50 || name.split('/').any(|part| part == ".." || part.is_empty())
51 {
52 return Err(TtyError::InvalidName(name.to_string()));
53 }
54 Ok(())
55}
56
57pub fn tty_device_path(name: &str) -> Result<PathBuf, TtyError> {
58 validate_tty_name(name)?;
59 Ok(Path::new("/dev").join(name))
60}
61
62pub struct TtyInfo {
63 pub writable: bool,
66 pub atime: SystemTime,
67}
68
69pub fn stat_tty(name: &str) -> Result<TtyInfo, TtyError> {
70 let path = tty_device_path(name)?;
71
72 let st = stat(&path).map_err(|e| match e {
73 Errno::ENOENT => TtyError::NotFound(name.to_string()),
74 other => TtyError::Io {
75 path: path.display().to_string(),
76 source: other.into(),
77 },
78 })?;
79
80 let file_type = SFlag::from_bits_truncate(st.st_mode) & SFlag::S_IFMT;
81 if file_type != SFlag::S_IFCHR {
82 return Err(TtyError::NotCharacterDevice(name.to_string()));
83 }
84
85 let mode = Mode::from_bits_truncate(st.st_mode);
86 let atime = SystemTime::UNIX_EPOCH + Duration::from_secs(st.st_atime.max(0) as u64);
87
88 Ok(TtyInfo {
89 writable: mode.contains(Mode::S_IWGRP),
90 atime,
91 })
92}
93
94pub fn verify_tty_owner(path: &str, uid: u32) -> Result<(), TtyError> {
96 if !path.starts_with("/dev/") || path.split('/').any(|part| part == "..") {
97 return Err(TtyError::InvalidName(path.to_string()));
98 }
99
100 let st = stat(Path::new(path)).map_err(|e| match e {
101 Errno::ENOENT => TtyError::NotFound(path.to_string()),
102 other => TtyError::Io {
103 path: path.to_string(),
104 source: other.into(),
105 },
106 })?;
107
108 let file_type = SFlag::from_bits_truncate(st.st_mode) & SFlag::S_IFMT;
109 if file_type != SFlag::S_IFCHR {
110 return Err(TtyError::NotCharacterDevice(path.to_string()));
111 }
112
113 if st.st_uid != uid {
114 return Err(TtyError::NotOwnedByCaller(path.to_string()));
115 }
116
117 Ok(())
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Session {
123 pub user: String,
124 pub tty: String,
125}
126
127fn utmpx_sessions() -> impl Iterator<Item = Session> {
128 Utmpx::iter_all_records().filter_map(|record| {
129 if !record.is_user_process() {
130 return None;
131 }
132 let tty = record.tty_device();
133 if tty.is_empty() || tty.starts_with(':') {
135 return None;
136 }
137 Some(Session {
138 user: record.user(),
139 tty,
140 })
141 })
142}
143
144pub fn all_sessions(group: Option<&str>) -> Vec<Session> {
147 let mut seen = HashSet::new();
148 utmpx_sessions()
149 .filter(|s| group.is_none_or(|g| is_group_member(&s.user, g)))
150 .filter(|s| seen.insert(s.tty.clone()))
151 .collect()
152}
153
154pub fn sessions_for_user(user: &str) -> Vec<Session> {
156 let mut seen = HashSet::new();
157 utmpx_sessions()
158 .filter(|s| s.user == user)
159 .filter(|s| seen.insert(s.tty.clone()))
160 .collect()
161}
162
163pub fn is_group_member(username: &str, group: &str) -> bool {
165 let Some(target_group) = users::get_group_by_name(group) else {
166 return false;
167 };
168 let Some(user) = users::get_user_by_name(username) else {
169 return false;
170 };
171 if user.primary_group_id() == target_group.gid() {
172 return true;
173 }
174 users::get_user_groups(username, user.primary_group_id())
175 .is_some_and(|groups| groups.iter().any(|g| g.gid() == target_group.gid()))
176}
177
178pub fn escape_and_wrap_content(input: &str, wrap_width: Option<usize>) -> String {
182 let mut out = String::with_capacity(input.len());
183 let mut col = 0usize;
184
185 for ch in input.chars() {
186 if ch == '\n' {
187 out.push_str("\r\n");
188 col = 0;
189 continue;
190 }
191
192 let rendered = if ch.is_control() {
193 format!("^{}", (ch as u8 ^ 0x40) as char)
194 } else {
195 ch.to_string()
196 };
197
198 if let Some(width) = wrap_width
199 && col > 0
200 && col + rendered.len() > width
201 {
202 out.push_str("\r\n");
203 col = 0;
204 }
205
206 out.push_str(&rendered);
207 col += rendered.len();
208 }
209
210 out
211}
212
213fn pad_or_truncate(s: &str, width: usize) -> String {
214 let truncated: String = s.chars().take(width).collect();
215 format!("{truncated:<width$}")
216}
217
218pub fn format_wall_message(
219 from_user: &str,
220 from_host: &str,
221 from_tty: Option<&str>,
222 message: &str,
223 nobanner: bool,
224) -> Vec<u8> {
225 let mut out = String::new();
226
227 if !nobanner {
228 let location = from_tty.unwrap_or("somewhere");
229 let now = chrono::Local::now().format("%a %b %e %H:%M:%S %Y");
230 out.push('\r');
231 out.push_str(&" ".repeat(TERM_WIDTH));
232 out.push_str("\r\n");
233
234 let banner =
235 format!("Broadcast message from {from_user}@{from_host} ({location}) ({now}):");
236 out.push_str(&pad_or_truncate(&banner, TERM_WIDTH));
237 out.push_str("\x07\x07\r\n");
238 }
239
240 out.push_str(&" ".repeat(TERM_WIDTH));
241 out.push_str("\r\n");
242
243 out.push_str(&escape_and_wrap_content(message, Some(TERM_WIDTH)));
244 if !out.ends_with("\r\n") {
245 out.push_str("\r\n");
246 }
247
248 out.push_str(&" ".repeat(TERM_WIDTH));
249 out.push_str("\r\n");
250
251 out.into_bytes()
252}
253
254pub fn format_write_message(
255 from_user: &str,
256 from_host: &str,
257 from_tty: Option<&str>,
258 message: &str,
259) -> Vec<u8> {
260 let mut out = String::new();
261 out.push_str("\r\n\x07\x07\x07");
262
263 let now = chrono::Local::now();
264 let tty = from_tty.unwrap_or("<no tty>");
265 out.push_str(&format!(
266 "Message from {from_user}@{from_host} on {tty} at {:02}:{:02} ...\r\n",
267 now.hour(),
268 now.minute(),
269 ));
270
271 out.push_str(&escape_and_wrap_content(message, None));
272 if !out.ends_with("\r\n") {
273 out.push_str("\r\n");
274 }
275 out.push_str("EOF\r\n");
276
277 out.into_bytes()
278}
279
280pub async fn deliver_message(
282 tty_name: &str,
283 message: &[u8],
284 timeout: Duration,
285) -> Result<(), TtyError> {
286 let path = tty_device_path(tty_name)?;
287
288 let fd = match fcntl::open(
289 &path,
290 OFlag::O_WRONLY | OFlag::O_NONBLOCK | OFlag::O_NOCTTY,
291 Mode::empty(),
292 ) {
293 Ok(fd) => fd,
294 Err(Errno::ENOENT | Errno::EACCES | Errno::EBUSY) => {
295 return Err(TtyError::Unavailable(tty_name.to_string()));
296 }
297 Err(e) => {
298 return Err(TtyError::Io {
299 path: path.display().to_string(),
300 source: e.into(),
301 });
302 }
303 };
304
305 let async_fd = AsyncFd::new(fd).map_err(|source| TtyError::Io {
306 path: path.display().to_string(),
307 source,
308 })?;
309
310 let write_all = async {
311 let mut written = 0usize;
312 while written < message.len() {
313 let mut guard = async_fd.writable().await?;
314 match guard.try_io(|inner| {
315 unistd::write(inner.get_ref(), &message[written..]).map_err(Error::from)
316 }) {
317 Ok(Ok(n)) => written += n,
318 Ok(Err(e)) => return Err(e),
319 Err(_would_block) => continue,
320 }
321 }
322 Ok::<(), Error>(())
323 };
324
325 time::timeout(timeout, write_all)
326 .await
327 .map_err(|_| TtyError::Timeout(tty_name.to_string()))?
328 .map_err(|source| TtyError::Io {
329 path: path.display().to_string(),
330 source,
331 })
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use nix::{
338 pty::openpty,
339 sys::termios::{self, SetArg},
340 };
341
342 #[test]
343 fn validate_tty_name_rejects_traversal() {
344 assert!(validate_tty_name("pts/3").is_ok());
345 assert!(validate_tty_name("").is_err());
346 assert!(validate_tty_name(":0").is_err());
347 assert!(validate_tty_name("/etc/passwd").is_err());
348 assert!(validate_tty_name("../etc/passwd").is_err());
349 assert!(validate_tty_name("foo/../../bar").is_err());
350 }
351
352 #[test]
353 fn careful_escape_handles_control_chars_and_wrapping() {
354 assert_eq!(escape_and_wrap_content("hi\nthere", None), "hi\r\nthere");
355 assert_eq!(escape_and_wrap_content("a\x01b", None), "a^Ab");
356
357 let wrapped = escape_and_wrap_content("aaaa bbbb", Some(4));
358 assert!(wrapped.contains("\r\n"));
359 }
360
361 #[test]
362 fn wall_message_contains_banner_and_body() {
363 let msg = format_wall_message("alice", "host", Some("pts/0"), "hello there", false);
364 let msg = String::from_utf8(msg).unwrap();
365 assert!(msg.contains("Broadcast message from alice@host (pts/0)"));
366 assert!(msg.contains("hello there"));
367 }
368
369 #[test]
370 fn wall_message_without_banner_omits_it() {
371 let msg = format_wall_message("alice", "host", Some("pts/0"), "hello there", true);
372 let msg = String::from_utf8(msg).unwrap();
373 assert!(!msg.contains("Broadcast message"));
374 assert!(msg.contains("hello there"));
375 }
376
377 #[test]
378 fn write_message_has_eof_marker() {
379 let msg = format_write_message("bob", "host", Some("pts/1"), "yo");
380 let msg = String::from_utf8(msg).unwrap();
381 assert!(msg.contains("Message from bob@host on pts/1"));
382 assert!(msg.ends_with("EOF\r\n"));
383 }
384
385 fn open_fake_tty() -> (nix::pty::OpenptyResult, String) {
386 let pty = openpty(None, None).expect("openpty");
387 let name = nix::unistd::ttyname(&pty.slave)
388 .expect("ttyname")
389 .to_str()
390 .unwrap()
391 .trim_start_matches("/dev/")
392 .to_string();
393
394 let mut termios = termios::tcgetattr(&pty.slave).expect("tcgetattr");
397 termios::cfmakeraw(&mut termios);
398 termios::tcsetattr(&pty.slave, SetArg::TCSANOW, &termios).expect("tcsetattr");
399
400 (pty, name)
401 }
402
403 #[test]
404 fn stat_tty_reports_character_device() {
405 let (pty, name) = open_fake_tty();
406 let info = stat_tty(&name).expect("stat_tty");
407 let _ = info.writable;
408 drop(pty);
409 }
410
411 #[tokio::test]
412 async fn deliver_message_writes_to_pty() {
413 let (pty, name) = open_fake_tty();
414
415 deliver_message(&name, b"hello pty\r\n", Duration::from_secs(2))
416 .await
417 .expect("deliver_message");
418
419 let mut buf = [0u8; 64];
420 let n = nix::unistd::read(&pty.master, &mut buf).expect("read");
421 assert_eq!(&buf[..n], b"hello pty\r\n");
422 }
423
424 #[tokio::test]
425 async fn deliver_message_to_missing_tty_is_unavailable() {
426 let err = deliver_message("this-tty-does-not-exist", b"x", Duration::from_millis(200))
427 .await
428 .unwrap_err();
429 assert!(matches!(err, TtyError::Unavailable(_)));
430 }
431}