1use 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
17const 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 pub fn expired(&self, now_ms: i64) -> bool {
29 now_ms >= self.expires_ms
30 }
31}
32
33pub 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
50pub 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
60pub 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
79pub 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 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
109fn 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 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 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 #[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}