1
//! Low-level tty discovery, permission checks and message delivery.
2

            
3
use std::{
4
    collections::HashSet,
5
    io::Error,
6
    path::{Path, PathBuf},
7
    time::{Duration, SystemTime},
8
};
9

            
10
use chrono::Timelike;
11
use nix::{
12
    errno::Errno,
13
    fcntl::{self, OFlag},
14
    sys::stat::{Mode, SFlag, stat},
15
    unistd,
16
};
17
use tokio::{io::unix::AsyncFd, time};
18
use uucore::utmpx::Utmpx;
19

            
20
const TERM_WIDTH: usize = 79;
21

            
22
#[derive(Debug, thiserror::Error)]
23
pub 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

            
46
9
fn validate_tty_name(name: &str) -> Result<(), TtyError> {
47
9
    if name.is_empty()
48
8
        || name.starts_with(':')
49
7
        || name.starts_with('/')
50
10
        || name.split('/').any(|part| part == ".." || part.is_empty())
51
    {
52
5
        return Err(TtyError::InvalidName(name.to_string()));
53
4
    }
54
4
    Ok(())
55
9
}
56

            
57
3
pub fn tty_device_path(name: &str) -> Result<PathBuf, TtyError> {
58
3
    validate_tty_name(name)?;
59
3
    Ok(Path::new("/dev").join(name))
60
3
}
61

            
62
pub 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

            
69
1
pub fn stat_tty(name: &str) -> Result<TtyInfo, TtyError> {
70
1
    let path = tty_device_path(name)?;
71

            
72
1
    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
1
    let file_type = SFlag::from_bits_truncate(st.st_mode) & SFlag::S_IFMT;
81
1
    if file_type != SFlag::S_IFCHR {
82
        return Err(TtyError::NotCharacterDevice(name.to_string()));
83
1
    }
84

            
85
1
    let mode = Mode::from_bits_truncate(st.st_mode);
86
1
    let atime = SystemTime::UNIX_EPOCH + Duration::from_secs(st.st_atime.max(0) as u64);
87

            
88
1
    Ok(TtyInfo {
89
1
        writable: mode.contains(Mode::S_IWGRP),
90
1
        atime,
91
1
    })
92
1
}
93

            
94
/// Verifies that `path` is a character device in `/dev/` owned by `uid`.
95
pub 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)]
122
pub struct Session {
123
    pub user: String,
124
    pub tty: String,
125
}
126

            
127
fn 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.
146
pub 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.
155
pub 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.
164
pub 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.
181
6
pub fn escape_and_wrap_content(input: &str, wrap_width: Option<usize>) -> String {
182
6
    let mut out = String::with_capacity(input.len());
183
6
    let mut col = 0usize;
184

            
185
44
    for ch in input.chars() {
186
44
        if ch == '\n' {
187
1
            out.push_str("\r\n");
188
1
            col = 0;
189
1
            continue;
190
43
        }
191

            
192
43
        let rendered = if ch.is_control() {
193
1
            format!("^{}", (ch as u8 ^ 0x40) as char)
194
        } else {
195
42
            ch.to_string()
196
        };
197

            
198
43
        if let Some(width) = wrap_width
199
31
            && col > 0
200
28
            && col + rendered.len() > width
201
2
        {
202
2
            out.push_str("\r\n");
203
2
            col = 0;
204
41
        }
205

            
206
43
        out.push_str(&rendered);
207
43
        col += rendered.len();
208
    }
209

            
210
6
    out
211
6
}
212

            
213
1
fn pad_or_truncate(s: &str, width: usize) -> String {
214
1
    let truncated: String = s.chars().take(width).collect();
215
1
    format!("{truncated:<width$}")
216
1
}
217

            
218
2
pub fn format_wall_message(
219
2
    from_user: &str,
220
2
    from_host: &str,
221
2
    from_tty: Option<&str>,
222
2
    message: &str,
223
2
    nobanner: bool,
224
2
) -> Vec<u8> {
225
2
    let mut out = String::new();
226

            
227
2
    if !nobanner {
228
1
        let location = from_tty.unwrap_or("somewhere");
229
1
        let now = chrono::Local::now().format("%a %b %e %H:%M:%S %Y");
230
1
        out.push('\r');
231
1
        out.push_str(&" ".repeat(TERM_WIDTH));
232
1
        out.push_str("\r\n");
233
1

            
234
1
        let banner =
235
1
            format!("Broadcast message from {from_user}@{from_host} ({location}) ({now}):");
236
1
        out.push_str(&pad_or_truncate(&banner, TERM_WIDTH));
237
1
        out.push_str("\x07\x07\r\n");
238
1
    }
239

            
240
2
    out.push_str(&" ".repeat(TERM_WIDTH));
241
2
    out.push_str("\r\n");
242

            
243
2
    out.push_str(&escape_and_wrap_content(message, Some(TERM_WIDTH)));
244
2
    if !out.ends_with("\r\n") {
245
2
        out.push_str("\r\n");
246
2
    }
247

            
248
2
    out.push_str(&" ".repeat(TERM_WIDTH));
249
2
    out.push_str("\r\n");
250

            
251
2
    out.into_bytes()
252
2
}
253

            
254
1
pub fn format_write_message(
255
1
    from_user: &str,
256
1
    from_host: &str,
257
1
    from_tty: Option<&str>,
258
1
    message: &str,
259
1
) -> Vec<u8> {
260
1
    let mut out = String::new();
261
1
    out.push_str("\r\n\x07\x07\x07");
262

            
263
1
    let now = chrono::Local::now();
264
1
    let tty = from_tty.unwrap_or("<no tty>");
265
1
    out.push_str(&format!(
266
1
        "Message from {from_user}@{from_host} on {tty} at {:02}:{:02} ...\r\n",
267
1
        now.hour(),
268
1
        now.minute(),
269
1
    ));
270

            
271
1
    out.push_str(&escape_and_wrap_content(message, None));
272
1
    if !out.ends_with("\r\n") {
273
1
        out.push_str("\r\n");
274
1
    }
275
1
    out.push_str("EOF\r\n");
276

            
277
1
    out.into_bytes()
278
1
}
279

            
280
/// Open `tty` and write `message` to it, giving up after `timeout`.
281
2
pub async fn deliver_message(
282
2
    tty_name: &str,
283
2
    message: &[u8],
284
2
    timeout: Duration,
285
2
) -> Result<(), TtyError> {
286
2
    let path = tty_device_path(tty_name)?;
287

            
288
2
    let fd = match fcntl::open(
289
2
        &path,
290
2
        OFlag::O_WRONLY | OFlag::O_NONBLOCK | OFlag::O_NOCTTY,
291
2
        Mode::empty(),
292
    ) {
293
1
        Ok(fd) => fd,
294
        Err(Errno::ENOENT | Errno::EACCES | Errno::EBUSY) => {
295
1
            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
1
    let async_fd = AsyncFd::new(fd).map_err(|source| TtyError::Io {
306
        path: path.display().to_string(),
307
        source,
308
    })?;
309

            
310
1
    let write_all = async {
311
1
        let mut written = 0usize;
312
2
        while written < message.len() {
313
1
            let mut guard = async_fd.writable().await?;
314
1
            match guard.try_io(|inner| {
315
1
                unistd::write(inner.get_ref(), &message[written..]).map_err(Error::from)
316
1
            }) {
317
1
                Ok(Ok(n)) => written += n,
318
                Ok(Err(e)) => return Err(e),
319
                Err(_would_block) => continue,
320
            }
321
        }
322
1
        Ok::<(), Error>(())
323
1
    };
324

            
325
1
    time::timeout(timeout, write_all)
326
1
        .await
327
1
        .map_err(|_| TtyError::Timeout(tty_name.to_string()))?
328
1
        .map_err(|source| TtyError::Io {
329
            path: path.display().to_string(),
330
            source,
331
        })
332
2
}
333

            
334
#[cfg(test)]
335
mod tests {
336
    use super::*;
337
    use nix::{
338
        pty::openpty,
339
        sys::termios::{self, SetArg},
340
    };
341

            
342
    #[test]
343
1
    fn validate_tty_name_rejects_traversal() {
344
1
        assert!(validate_tty_name("pts/3").is_ok());
345
1
        assert!(validate_tty_name("").is_err());
346
1
        assert!(validate_tty_name(":0").is_err());
347
1
        assert!(validate_tty_name("/etc/passwd").is_err());
348
1
        assert!(validate_tty_name("../etc/passwd").is_err());
349
1
        assert!(validate_tty_name("foo/../../bar").is_err());
350
1
    }
351

            
352
    #[test]
353
1
    fn careful_escape_handles_control_chars_and_wrapping() {
354
1
        assert_eq!(escape_and_wrap_content("hi\nthere", None), "hi\r\nthere");
355
1
        assert_eq!(escape_and_wrap_content("a\x01b", None), "a^Ab");
356

            
357
1
        let wrapped = escape_and_wrap_content("aaaa bbbb", Some(4));
358
1
        assert!(wrapped.contains("\r\n"));
359
1
    }
360

            
361
    #[test]
362
1
    fn wall_message_contains_banner_and_body() {
363
1
        let msg = format_wall_message("alice", "host", Some("pts/0"), "hello there", false);
364
1
        let msg = String::from_utf8(msg).unwrap();
365
1
        assert!(msg.contains("Broadcast message from alice@host (pts/0)"));
366
1
        assert!(msg.contains("hello there"));
367
1
    }
368

            
369
    #[test]
370
1
    fn wall_message_without_banner_omits_it() {
371
1
        let msg = format_wall_message("alice", "host", Some("pts/0"), "hello there", true);
372
1
        let msg = String::from_utf8(msg).unwrap();
373
1
        assert!(!msg.contains("Broadcast message"));
374
1
        assert!(msg.contains("hello there"));
375
1
    }
376

            
377
    #[test]
378
1
    fn write_message_has_eof_marker() {
379
1
        let msg = format_write_message("bob", "host", Some("pts/1"), "yo");
380
1
        let msg = String::from_utf8(msg).unwrap();
381
1
        assert!(msg.contains("Message from bob@host on pts/1"));
382
1
        assert!(msg.ends_with("EOF\r\n"));
383
1
    }
384

            
385
2
    fn open_fake_tty() -> (nix::pty::OpenptyResult, String) {
386
2
        let pty = openpty(None, None).expect("openpty");
387
2
        let name = nix::unistd::ttyname(&pty.slave)
388
2
            .expect("ttyname")
389
2
            .to_str()
390
2
            .unwrap()
391
2
            .trim_start_matches("/dev/")
392
2
            .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
2
        let mut termios = termios::tcgetattr(&pty.slave).expect("tcgetattr");
397
2
        termios::cfmakeraw(&mut termios);
398
2
        termios::tcsetattr(&pty.slave, SetArg::TCSANOW, &termios).expect("tcsetattr");
399

            
400
2
        (pty, name)
401
2
    }
402

            
403
    #[test]
404
1
    fn stat_tty_reports_character_device() {
405
1
        let (pty, name) = open_fake_tty();
406
1
        let info = stat_tty(&name).expect("stat_tty");
407
1
        let _ = info.writable;
408
1
        drop(pty);
409
1
    }
410

            
411
    #[tokio::test]
412
1
    async fn deliver_message_writes_to_pty() {
413
1
        let (pty, name) = open_fake_tty();
414

            
415
1
        deliver_message(&name, b"hello pty\r\n", Duration::from_secs(2))
416
1
            .await
417
1
            .expect("deliver_message");
418

            
419
1
        let mut buf = [0u8; 64];
420
1
        let n = nix::unistd::read(&pty.master, &mut buf).expect("read");
421
1
        assert_eq!(&buf[..n], b"hello pty\r\n");
422
1
    }
423

            
424
    #[tokio::test]
425
1
    async fn deliver_message_to_missing_tty_is_unavailable() {
426
1
        let err = deliver_message("this-tty-does-not-exist", b"x", Duration::from_millis(200))
427
1
            .await
428
1
            .unwrap_err();
429
1
        assert!(matches!(err, TtyError::Unavailable(_)));
430
1
    }
431
}