1use sysknife_types::CallerRole;
2
3pub const OBSERVER_GROUP: &str = "sysknife-observer";
4pub const DEV_GROUP: &str = "sysknife-dev";
5pub const ADMIN_GROUP: &str = "sysknife-admin";
6pub const BOOT_GROUP: &str = "sysknife-boot";
7pub const WHEEL_GROUP: &str = "wheel";
8
9pub fn highest_role_from_groups<I, S>(groups: I) -> CallerRole
10where
11 I: IntoIterator<Item = S>,
12 S: AsRef<str>,
13{
14 groups
15 .into_iter()
16 .map(|group| role_for_group(group.as_ref()))
17 .fold(CallerRole::Observer, higher_role)
18}
19
20fn role_for_group(group: &str) -> CallerRole {
21 match group {
22 BOOT_GROUP => CallerRole::Boot,
23 ADMIN_GROUP | WHEEL_GROUP => CallerRole::Admin,
24 DEV_GROUP => CallerRole::Dev,
25 OBSERVER_GROUP => CallerRole::Observer,
26 _ => CallerRole::Observer,
27 }
28}
29
30fn higher_role(current: CallerRole, candidate: CallerRole) -> CallerRole {
31 if role_rank(&candidate) > role_rank(¤t) {
32 candidate
33 } else {
34 current
35 }
36}
37
38pub(crate) fn role_rank(role: &CallerRole) -> u8 {
39 match role {
40 CallerRole::Observer => 0,
41 CallerRole::Dev => 1,
42 CallerRole::Admin => 2,
43 CallerRole::Boot => 3,
44 }
45}
46
47pub fn validate_token_against_file(
61 presented_token: &str,
62 token_path: &std::path::Path,
63) -> Option<CallerRole> {
64 if presented_token.is_empty() {
65 return None;
66 }
67 let stored = match std::fs::read_to_string(token_path) {
68 Ok(s) => s,
69 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
70 eprintln!(
71 "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
72 token_path.display()
73 );
74 return None;
75 }
76 Err(e) => {
77 eprintln!(
78 "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
79 token_path.display()
80 );
81 return None;
82 }
83 };
84 let stored = stored.trim();
85 if stored.is_empty() {
86 return None;
87 }
88 if stored.len() != presented_token.len() {
95 return None;
96 }
97 use subtle::ConstantTimeEq;
98 if stored
99 .as_bytes()
100 .ct_eq(presented_token.as_bytes())
101 .unwrap_u8()
102 != 1
103 {
104 return None;
105 }
106 Some(token_role())
107}
108
109pub fn token_role() -> CallerRole {
117 let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
118 match raw.trim().to_ascii_lowercase().as_str() {
119 "observer" => CallerRole::Observer,
120 "admin" => CallerRole::Admin,
121 "boot" => CallerRole::Boot,
122 "dev" | "" => CallerRole::Dev,
123 other => {
124 eprintln!(
125 "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
126 failing closed to Observer (read-only)"
127 );
128 CallerRole::Observer
129 }
130 }
131}
132
133pub fn default_token_path() -> std::path::PathBuf {
135 sysknife_core::config::prefs_path()
136 .parent()
137 .unwrap_or_else(|| {
138 eprintln!(
139 "[sysknife-daemon] WARNING: prefs_path() has no parent; \
140 falling back to /tmp for token file — this is a misconfiguration"
141 );
142 std::path::Path::new("/tmp")
143 })
144 .join("token")
145}
146
147#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn role(groups: &[&str]) -> CallerRole {
156 highest_role_from_groups(groups.iter().copied())
157 }
158
159 #[test]
160 fn empty_groups_resolves_to_observer() {
161 assert_eq!(role(&[]), CallerRole::Observer);
162 }
163
164 #[test]
165 fn unknown_group_resolves_to_observer() {
166 assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
167 }
168
169 #[test]
170 fn lacs_observer_group_resolves_to_observer() {
171 assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
172 }
173
174 #[test]
175 fn lacs_dev_group_resolves_to_dev() {
176 assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
177 }
178
179 #[test]
180 fn lacs_admin_group_resolves_to_admin() {
181 assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
182 }
183
184 #[test]
185 fn wheel_group_resolves_to_admin() {
186 assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
187 }
188
189 #[test]
190 fn lacs_boot_group_resolves_to_boot() {
191 assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
192 }
193
194 #[test]
195 fn highest_role_wins_when_multiple_groups_present() {
196 assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
198 }
199
200 #[test]
201 fn boot_role_beats_admin_and_wheel() {
202 assert_eq!(
203 role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
204 CallerRole::Boot
205 );
206 }
207
208 #[test]
209 fn mixed_known_and_unknown_groups_returns_highest_known() {
210 assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
211 }
212
213 #[test]
216 fn valid_token_matches_and_returns_dev_by_default() {
217 let dir = tempfile::tempdir().unwrap();
218 let path = dir.path().join("token");
219 std::fs::write(&path, "secret123").unwrap();
220 assert_eq!(
221 validate_token_against_file("secret123", &path),
222 Some(CallerRole::Dev)
223 );
224 }
225
226 #[test]
227 fn token_file_with_trailing_newline_still_matches() {
228 let dir = tempfile::tempdir().unwrap();
230 let path = dir.path().join("token");
231 std::fs::write(&path, "secret123\n").unwrap();
232 assert_eq!(
233 validate_token_against_file("secret123", &path),
234 Some(CallerRole::Dev)
235 );
236 }
237
238 #[test]
239 fn wrong_token_returns_none() {
240 let dir = tempfile::tempdir().unwrap();
241 let path = dir.path().join("token");
242 std::fs::write(&path, "correct\n").unwrap();
243 assert_eq!(validate_token_against_file("wrong", &path), None);
244 }
245
246 #[test]
247 fn absent_token_file_returns_none() {
248 let dir = tempfile::tempdir().unwrap();
249 let path = dir.path().join("nonexistent");
250 assert_eq!(validate_token_against_file("any", &path), None);
251 }
252
253 #[test]
254 fn empty_presented_token_is_always_rejected() {
255 let dir = tempfile::tempdir().unwrap();
256 let path = dir.path().join("token");
257 std::fs::write(&path, "").unwrap();
258 assert_eq!(validate_token_against_file("", &path), None);
259 }
260
261 #[test]
262 fn empty_stored_token_is_rejected_even_with_matching_presented() {
263 let dir = tempfile::tempdir().unwrap();
264 let path = dir.path().join("token");
265 std::fs::write(&path, "\n").unwrap();
266 assert_eq!(validate_token_against_file("", &path), None);
267 }
268
269 #[test]
278 fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
279 let dir = tempfile::tempdir().unwrap();
280 let path = dir.path().join("token");
281 std::fs::write(&path, "abcdefghi").unwrap();
285
286 assert_eq!(
288 validate_token_against_file("abcdefghi", &path),
289 Some(CallerRole::Dev)
290 );
291 assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
293 assert_eq!(validate_token_against_file("abcdefghX", &path), None);
295 assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
297 }
298
299 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
302
303 fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
304 let _guard = ENV_LOCK.lock().unwrap();
305 match val {
306 Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
307 None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
308 }
309 f();
310 std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
311 }
312
313 #[test]
314 fn token_role_defaults_to_dev_when_unset() {
315 with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
316 }
317
318 #[test]
319 fn token_role_explicit_dev() {
320 with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
321 }
322
323 #[test]
324 fn token_role_observer() {
325 with_role_env(Some("observer"), || {
326 assert_eq!(token_role(), CallerRole::Observer)
327 });
328 }
329
330 #[test]
331 fn token_role_admin() {
332 with_role_env(Some("admin"), || {
333 assert_eq!(token_role(), CallerRole::Admin)
334 });
335 }
336
337 #[test]
338 fn token_role_boot() {
339 with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
340 }
341
342 #[test]
343 fn token_role_unknown_value_fails_closed_to_observer() {
344 with_role_env(Some("superuser"), || {
347 assert_eq!(token_role(), CallerRole::Observer)
348 });
349 }
350
351 #[test]
352 fn token_role_is_case_insensitive() {
353 with_role_env(Some("ADMIN"), || {
354 assert_eq!(token_role(), CallerRole::Admin)
355 });
356 }
357
358 #[test]
359 fn token_role_trims_surrounding_whitespace() {
360 with_role_env(Some(" admin\n"), || {
363 assert_eq!(token_role(), CallerRole::Admin)
364 });
365 }
366}