Skip to main content

roowho2_lib/server/walld/
tty_utils.rs

1//! Low-level tty discovery, permission checks and message delivery.
2
3use 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    /// Whether the tty currently accepts unsolicited writes (`mesg y`), signalled by the
64    /// group-write bit on the device node.
65    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
94/// Verifies that `path` is a character device in `/dev/` owned by `uid`.
95pub 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/// A single logged-in session, as reported by utmpx (systemd-logind)
121#[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        // Skip empty ttys and X11/Wayland sessions (":0", ":1", etc.).
134        if tty.is_empty() || tty.starts_with(':') {
135            return None;
136        }
137        Some(Session {
138            user: record.user(),
139            tty,
140        })
141    })
142}
143
144/// All distinct (user, tty) sessions, optionally restricted to members of `group`,
145/// deduplicated by tty.
146pub 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
154/// Distinct ttys that `user` is currently logged in on.
155pub 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
163/// Whether `username` is a member of `group`, by primary or supplementary group.
164pub 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
178/// - Escape non-printable characters as `^X`
179/// - Rewrite LF TO CRLF
180/// - Wrap at `wrap_width` columns if given.
181pub 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
280/// Open `tty` and write `message` to it, giving up after `timeout`.
281pub 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        // Put the slave side in raw mode so the line discipline doesn't rewrite the
395        // `\r\n` we send before we get to assert on it.
396        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}