Skip to main content

lonca_client/
session.rs

1//! The human session file (`docs/protocol.md`, "The human session file is
2//! an interface"): `lonca human login` keeps a human's session token at
3//! `<humans>/<name>.session`, mode 0600, as `{"token", "expires_ms"}`.
4//!
5//! The token opens reads and prepares only; every write still needs the
6//! human's signature. It is read only from a file that is what `lonca`
7//! left: a regular file of this user that nobody else may read or write.
8
9use std::io::{Error, ErrorKind, Read, Result};
10use std::os::unix::fs::MetadataExt;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15use crate::sys::current_uid;
16
17/// A session file is a token and a time; anything near this size is not one.
18const MAX_BYTES: u64 = 64 * 1024;
19
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21pub struct HumanSession {
22    pub token: String,
23    pub expires_ms: i64,
24}
25
26impl HumanSession {
27    /// Past its time at `now_ms`; the daemon answers `unauthenticated` then.
28    pub fn expired(&self, now_ms: i64) -> bool {
29        now_ms >= self.expires_ms
30    }
31}
32
33/// Where the session files are: `$LONCA_HUMANS`, else
34/// `~/.config/lonca/humans`. `None` with neither set.
35pub fn humans_dir() -> Option<PathBuf> {
36    humans_dir_from(std::env::var_os("LONCA_HUMANS"), std::env::var_os("HOME"))
37}
38
39fn humans_dir_from(
40    humans: Option<std::ffi::OsString>,
41    home: Option<std::ffi::OsString>,
42) -> Option<PathBuf> {
43    let set = |v: Option<std::ffi::OsString>| v.filter(|v| !v.is_empty());
44    match set(humans) {
45        Some(dir) => Some(PathBuf::from(dir)),
46        None => set(home).map(|h| PathBuf::from(h).join(".config/lonca/humans")),
47    }
48}
49
50/// A human name as `lonca` takes it: letters, digits, `.`, `_`, `-`, not
51/// starting with `.`.
52pub fn valid_name(name: &str) -> bool {
53    !name.is_empty()
54        && !name.starts_with('.')
55        && name
56            .bytes()
57            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
58}
59
60/// `<dir>/<name>.session`; an invalid name is refused, so a name can not
61/// lead out of `dir`.
62pub fn session_path(dir: &Path, name: &str) -> Result<PathBuf> {
63    if !valid_name(name) {
64        return Err(Error::new(
65            ErrorKind::InvalidInput,
66            format!("bad human name {name:?}"),
67        ));
68    }
69    Ok(dir.join(format!("{name}.session")))
70}
71
72fn refuse(path: &Path, why: &str) -> Error {
73    Error::new(
74        ErrorKind::PermissionDenied,
75        format!("{}: {why}; not read", path.display()),
76    )
77}
78
79/// The session at `path`. `Ok(None)` when there is no file: log in
80/// (`lonca human login --json`) and read again. A link, a file of another
81/// user, or one that group or others may read or write is refused, as is
82/// one that is not a session.
83pub fn read_session(path: &Path) -> Result<Option<HumanSession>> {
84    let mut file = match open_no_follow(path) {
85        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
86        other => other?,
87    };
88    // Of the file opened, not of the name: it can not be swapped between.
89    let meta = file.metadata()?;
90    if !meta.is_file() {
91        return Err(refuse(path, "not a regular file"));
92    }
93    if meta.uid() != current_uid() {
94        return Err(refuse(path, "belongs to another user"));
95    }
96    if meta.mode() & 0o077 != 0 {
97        return Err(refuse(path, "group or others may read or write it"));
98    }
99    if meta.len() > MAX_BYTES {
100        return Err(Error::new(ErrorKind::InvalidData, "not a session file"));
101    }
102    let mut bytes = Vec::new();
103    file.by_ref().take(MAX_BYTES).read_to_end(&mut bytes)?;
104    let session: HumanSession = serde_json::from_slice(&bytes)
105        .map_err(|e| Error::new(ErrorKind::InvalidData, format!("not a session file: {e}")))?;
106    Ok(Some(session))
107}
108
109/// Opens `path` for reading without following a link at its end.
110fn open_no_follow(path: &Path) -> Result<std::fs::File> {
111    use std::os::unix::fs::OpenOptionsExt;
112    let mut opts = std::fs::OpenOptions::new();
113    opts.read(true)
114        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
115    match opts.open(path) {
116        // O_NOFOLLOW on a link: ELOOP.
117        Err(e) if e.raw_os_error() == Some(libc::ELOOP) => Err(refuse(path, "a symbolic link")),
118        other => other,
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::os::unix::fs::PermissionsExt;
126
127    fn write(path: &Path, text: &str, mode: u32) {
128        std::fs::write(path, text).unwrap();
129        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
130    }
131
132    fn refused(r: Result<Option<HumanSession>>, says: &str) {
133        let e = r.unwrap_err();
134        assert_eq!(e.kind(), ErrorKind::PermissionDenied, "{e}");
135        assert!(e.to_string().contains(says), "{e}");
136    }
137
138    const GOOD: &str = r#"{"token": "lonca_h_x", "expires_ms": 1000}"#;
139
140    #[test]
141    fn a_session_file_is_read_as_lonca_left_it() {
142        let dir = tempfile::tempdir().unwrap();
143        let path = session_path(dir.path(), "ayse").unwrap();
144        assert_eq!(path, dir.path().join("ayse.session"));
145        assert_eq!(read_session(&path).unwrap(), None, "no file");
146        write(&path, GOOD, 0o600);
147        let s = read_session(&path).unwrap().unwrap();
148        assert_eq!(s.token, "lonca_h_x");
149        assert!(!s.expired(999));
150        assert!(s.expired(1000));
151        write(&path, GOOD, 0o400);
152        assert!(read_session(&path).unwrap().is_some(), "read-only is fine");
153    }
154
155    #[test]
156    fn a_file_others_can_reach_is_refused() {
157        let dir = tempfile::tempdir().unwrap();
158        let path = dir.path().join("ayse.session");
159        for mode in [0o640, 0o604, 0o620, 0o602, 0o610, 0o601, 0o666] {
160            write(&path, GOOD, mode);
161            refused(read_session(&path), "group or others");
162        }
163    }
164
165    #[test]
166    fn a_link_or_a_directory_is_refused() {
167        let dir = tempfile::tempdir().unwrap();
168        let real = dir.path().join("real");
169        write(&real, GOOD, 0o600);
170        let link = dir.path().join("ayse.session");
171        std::os::unix::fs::symlink(&real, &link).unwrap();
172        refused(read_session(&link), "symbolic link");
173        let sub = dir.path().join("bora.session");
174        std::fs::create_dir(&sub).unwrap();
175        std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
176        refused(read_session(&sub), "not a regular file");
177    }
178
179    #[test]
180    fn what_is_not_a_session_is_invalid() {
181        let dir = tempfile::tempdir().unwrap();
182        let path = dir.path().join("ayse.session");
183        for text in ["", "{}", r#"{"token": 1, "expires_ms": 2}"#, "not json"] {
184            write(&path, text, 0o600);
185            let e = read_session(&path).unwrap_err();
186            assert_eq!(e.kind(), ErrorKind::InvalidData, "{text}: {e}");
187        }
188        let big = format!(
189            r#"{{"token": "{}", "expires_ms": 1}}"#,
190            "x".repeat(MAX_BYTES as usize)
191        );
192        write(&path, &big, 0o600);
193        let e = read_session(&path).unwrap_err();
194        assert_eq!(e.kind(), ErrorKind::InvalidData);
195        // At the limit to the byte: read, all of it.
196        let frame = r#"{"token": "", "expires_ms": 1}"#.len();
197        let token = "x".repeat(MAX_BYTES as usize - frame);
198        let just = format!(r#"{{"token": "{token}", "expires_ms": 1}}"#);
199        assert_eq!(just.len() as u64, MAX_BYTES);
200        write(&path, &just, 0o600);
201        assert_eq!(read_session(&path).unwrap().unwrap().token, token);
202    }
203
204    /// A FIFO would hold an open for reading until someone writes: it is
205    /// opened without waiting, and refused.
206    #[test]
207    fn a_fifo_is_refused_without_waiting() {
208        let dir = tempfile::tempdir().unwrap();
209        let path = dir.path().join("ayse.session");
210        let made = std::process::Command::new("mkfifo")
211            .arg("-m")
212            .arg("600")
213            .arg(&path)
214            .status()
215            .unwrap();
216        assert!(made.success());
217        refused(read_session(&path), "not a regular file");
218    }
219
220    #[test]
221    fn the_directory_is_the_variable_or_under_home() {
222        let os = |s: &str| Some(std::ffi::OsString::from(s));
223        let at = |s: &str| Some(PathBuf::from(s));
224        assert_eq!(humans_dir_from(os("/h"), os("/home/a")), at("/h"));
225        let under = at("/home/a/.config/lonca/humans");
226        assert_eq!(humans_dir_from(None, os("/home/a")), under);
227        assert_eq!(
228            humans_dir_from(os(""), os("/home/a")),
229            under,
230            "empty is unset"
231        );
232        assert_eq!(humans_dir_from(None, os("")), None);
233        assert_eq!(humans_dir_from(None, None), None);
234        let env = |n| std::env::var_os(n);
235        assert_eq!(
236            humans_dir(),
237            humans_dir_from(env("LONCA_HUMANS"), env("HOME"))
238        );
239        assert!(humans_dir().is_some(), "tests run with HOME set");
240    }
241
242    #[test]
243    fn a_name_can_not_lead_out_of_the_directory() {
244        let dir = Path::new("/h");
245        for bad in ["", "../x", "a/b", ".hidden", "a b", "é"] {
246            let e = session_path(dir, bad).unwrap_err();
247            assert_eq!(e.kind(), ErrorKind::InvalidInput, "{bad}");
248        }
249        for good in ["ayse", "a.b_c-9", "A"] {
250            assert!(session_path(dir, good).is_ok(), "{good}");
251        }
252    }
253}