use sysknife_types::CallerRole;
pub const OBSERVER_GROUP: &str = "sysknife-observer";
pub const DEV_GROUP: &str = "sysknife-dev";
pub const ADMIN_GROUP: &str = "sysknife-admin";
pub const BOOT_GROUP: &str = "sysknife-boot";
pub const WHEEL_GROUP: &str = "wheel";
pub fn highest_role_from_groups<I, S>(groups: I) -> CallerRole
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
groups
.into_iter()
.map(|group| role_for_group(group.as_ref()))
.fold(CallerRole::Observer, higher_role)
}
fn role_for_group(group: &str) -> CallerRole {
match group {
BOOT_GROUP => CallerRole::Boot,
ADMIN_GROUP | WHEEL_GROUP => CallerRole::Admin,
DEV_GROUP => CallerRole::Dev,
OBSERVER_GROUP => CallerRole::Observer,
_ => CallerRole::Observer,
}
}
fn higher_role(current: CallerRole, candidate: CallerRole) -> CallerRole {
if role_rank(&candidate) > role_rank(¤t) {
candidate
} else {
current
}
}
pub(crate) fn role_rank(role: &CallerRole) -> u8 {
match role {
CallerRole::Observer => 0,
CallerRole::Dev => 1,
CallerRole::Admin => 2,
CallerRole::Boot => 3,
}
}
pub fn validate_token_against_file(
presented_token: &str,
token_path: &std::path::Path,
) -> Option<CallerRole> {
if presented_token.is_empty() {
return None;
}
let stored = match std::fs::read_to_string(token_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!(
"[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
token_path.display()
);
return None;
}
Err(e) => {
eprintln!(
"[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
token_path.display()
);
return None;
}
};
let stored = stored.trim();
if stored.is_empty() {
return None;
}
if stored.len() != presented_token.len() {
return None;
}
use subtle::ConstantTimeEq;
if stored
.as_bytes()
.ct_eq(presented_token.as_bytes())
.unwrap_u8()
!= 1
{
return None;
}
Some(token_role())
}
pub fn token_role() -> CallerRole {
let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
match raw.trim().to_ascii_lowercase().as_str() {
"observer" => CallerRole::Observer,
"admin" => CallerRole::Admin,
"boot" => CallerRole::Boot,
"dev" | "" => CallerRole::Dev,
other => {
eprintln!(
"[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
failing closed to Observer (read-only)"
);
CallerRole::Observer
}
}
}
pub fn default_token_path() -> std::path::PathBuf {
sysknife_core::config::prefs_path()
.parent()
.unwrap_or_else(|| {
eprintln!(
"[sysknife-daemon] WARNING: prefs_path() has no parent; \
falling back to /tmp for token file — this is a misconfiguration"
);
std::path::Path::new("/tmp")
})
.join("token")
}
#[cfg(test)]
mod tests {
use super::*;
fn role(groups: &[&str]) -> CallerRole {
highest_role_from_groups(groups.iter().copied())
}
#[test]
fn empty_groups_resolves_to_observer() {
assert_eq!(role(&[]), CallerRole::Observer);
}
#[test]
fn unknown_group_resolves_to_observer() {
assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
}
#[test]
fn lacs_observer_group_resolves_to_observer() {
assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
}
#[test]
fn lacs_dev_group_resolves_to_dev() {
assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
}
#[test]
fn lacs_admin_group_resolves_to_admin() {
assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
}
#[test]
fn wheel_group_resolves_to_admin() {
assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
}
#[test]
fn lacs_boot_group_resolves_to_boot() {
assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
}
#[test]
fn highest_role_wins_when_multiple_groups_present() {
assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
}
#[test]
fn boot_role_beats_admin_and_wheel() {
assert_eq!(
role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
CallerRole::Boot
);
}
#[test]
fn mixed_known_and_unknown_groups_returns_highest_known() {
assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
}
#[test]
fn valid_token_matches_and_returns_dev_by_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "secret123").unwrap();
assert_eq!(
validate_token_against_file("secret123", &path),
Some(CallerRole::Dev)
);
}
#[test]
fn token_file_with_trailing_newline_still_matches() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "secret123\n").unwrap();
assert_eq!(
validate_token_against_file("secret123", &path),
Some(CallerRole::Dev)
);
}
#[test]
fn wrong_token_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "correct\n").unwrap();
assert_eq!(validate_token_against_file("wrong", &path), None);
}
#[test]
fn absent_token_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent");
assert_eq!(validate_token_against_file("any", &path), None);
}
#[test]
fn empty_presented_token_is_always_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "").unwrap();
assert_eq!(validate_token_against_file("", &path), None);
}
#[test]
fn empty_stored_token_is_rejected_even_with_matching_presented() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "\n").unwrap();
assert_eq!(validate_token_against_file("", &path), None);
}
#[test]
fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "abcdefghi").unwrap();
assert_eq!(
validate_token_against_file("abcdefghi", &path),
Some(CallerRole::Dev)
);
assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
assert_eq!(validate_token_against_file("abcdefghX", &path), None);
assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap();
match val {
Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
}
f();
std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
}
#[test]
fn token_role_defaults_to_dev_when_unset() {
with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
}
#[test]
fn token_role_explicit_dev() {
with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
}
#[test]
fn token_role_observer() {
with_role_env(Some("observer"), || {
assert_eq!(token_role(), CallerRole::Observer)
});
}
#[test]
fn token_role_admin() {
with_role_env(Some("admin"), || {
assert_eq!(token_role(), CallerRole::Admin)
});
}
#[test]
fn token_role_boot() {
with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
}
#[test]
fn token_role_unknown_value_fails_closed_to_observer() {
with_role_env(Some("superuser"), || {
assert_eq!(token_role(), CallerRole::Observer)
});
}
#[test]
fn token_role_is_case_insensitive() {
with_role_env(Some("ADMIN"), || {
assert_eq!(token_role(), CallerRole::Admin)
});
}
#[test]
fn token_role_trims_surrounding_whitespace() {
with_role_env(Some(" admin\n"), || {
assert_eq!(token_role(), CallerRole::Admin)
});
}
}