Skip to main content

sysknife_daemon/
auth.rs

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    current.max(candidate)
32}
33
34/// The group that grants `role`, i.e. the inverse of the private
35/// `role_for_group` mapping above.
36///
37/// Exists so a refusal can name the group to join. `wheel` also grants Admin,
38/// but the SysKnife group is the one to recommend: it is what `make install`
39/// creates, and it scopes access to this daemon rather than to sudo at large.
40pub fn group_for_role(role: CallerRole) -> &'static str {
41    match role {
42        CallerRole::Boot => BOOT_GROUP,
43        CallerRole::Admin => ADMIN_GROUP,
44        CallerRole::Dev => DEV_GROUP,
45        CallerRole::Observer => OBSERVER_GROUP,
46    }
47}
48
49/// The message a caller sees when their role is too low for an action.
50///
51/// Names the action, both roles, the group that would grant the needed role,
52/// and the command to join it — including the part people lose an afternoon to,
53/// that a new group only takes effect in a new login session.
54pub fn denial_message(action: &str, caller: CallerRole, required: CallerRole) -> String {
55    format!(
56        "action '{action}' requires the {required:?} role, but you have {caller:?}. \
57         Join the group that grants it: sudo usermod -aG {group} $USER, \
58         then log out and back in (group membership only applies to a new login).",
59        group = group_for_role(required),
60    )
61}
62
63// ---------------------------------------------------------------------------
64// Caller principal
65// ---------------------------------------------------------------------------
66
67/// Who asked, as opposed to what they were allowed to do.
68///
69/// [`CallerRole`] answers "was this permitted"; two members of
70/// `sysknife-admin` share a role and are indistinguishable by it. The principal
71/// answers "which account", and is signed into the audit chain
72/// ([`crate::audit_chain::ChainIdentity::V3`]) so the trail can name the account
73/// rather than the class.
74///
75/// An account is not a person: a uid survives `su`, shared logins, and reuse
76/// after a user is deleted. See the attribution-strength limit in `SECURITY.md`.
77///
78/// Every rendering is `scheme:value`, and the scheme is signed along with the
79/// value on purpose, because the strength of the evidence differs. `uid:1000`
80/// was attested by the kernel through `SO_PEERCRED`. `token:vsock` means a
81/// pre-shared secret was presented, and any holder of that file could have
82/// presented it. `none:unattributed` means the daemon could establish neither.
83/// A bare string would erase those distinctions.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum CallerPrincipal {
86    /// A Unix-socket peer whose uid the kernel supplied at `connect()`.
87    Uid(u32),
88    /// A vsock connection authenticated by the pre-shared token. No uid exists
89    /// on that path: the peer is in another kernel.
90    VsockToken,
91    /// The daemon could not establish an account for this connection, either
92    /// because `SO_PEERCRED` failed, or because it returned no usable pid, or
93    /// because the peer is not representable in this daemon's namespaces (the
94    /// kernel then reports the overflow uid, which names `nobody` rather than
95    /// the caller).
96    ///
97    /// The connection is still handled, at `Observer` (the pre-existing
98    /// fallback), and the row records that attribution failed instead of
99    /// inventing a uid. A signed lie about who acted would be worse than a
100    /// signed admission of ignorance.
101    Unattributed,
102}
103
104impl CallerPrincipal {
105    /// Evidence class of this principal. Signed as the part before the colon.
106    ///
107    /// Split out from [`Self::as_signed_str`] so a new variant cannot be added
108    /// without declaring which class it belongs to, and so
109    /// `every_principal_renders_as_a_known_scheme` can walk the whole set.
110    pub fn scheme(&self) -> &'static str {
111        match self {
112            Self::Uid(_) => "uid",
113            Self::VsockToken => "token",
114            Self::Unattributed => "none",
115        }
116    }
117
118    /// The scheme-specific part, signed after the colon.
119    fn value(&self) -> std::borrow::Cow<'static, str> {
120        match self {
121            Self::Uid(uid) => std::borrow::Cow::Owned(uid.to_string()),
122            Self::VsockToken => std::borrow::Cow::Borrowed("vsock"),
123            Self::Unattributed => std::borrow::Cow::Borrowed("unattributed"),
124        }
125    }
126
127    /// The exact string signed into the chain and stored in `caller_principal`.
128    ///
129    /// Never empty for any variant, which matters: `ChainRow::identity` treats an
130    /// empty principal on a `chain_version = 3` row as a missing one and reports
131    /// the row broken. A variant that rendered empty would turn every honest row
132    /// on an affected host into a false tamper report.
133    pub fn as_signed_str(&self) -> String {
134        format!("{}:{}", self.scheme(), self.value())
135    }
136}
137
138/// Role plus principal for one connection, resolved by the daemon and never
139/// taken from the request body.
140///
141/// Fields are private and the constructors are per transport, so the pairings
142/// that matter cannot be built by accident. In particular
143/// [`Self::unattributed`] hardcodes `Observer`: an attribution failure must never
144/// carry a privileged role, because that combination signs the worst possible
145/// record — a claim that an Admin acted, naming nobody.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct CallerAttribution {
148    /// Authorization input. Decides what the connection may call.
149    role: CallerRole,
150    /// Audit-only. Never an authorization input: a principal must not be able to
151    /// widen what a role permits.
152    principal: CallerPrincipal,
153}
154
155impl CallerAttribution {
156    /// A Unix-socket peer, attributed to the uid `SO_PEERCRED` reported.
157    pub fn from_peer_uid(uid: u32, role: CallerRole) -> Self {
158        Self {
159            role,
160            principal: CallerPrincipal::Uid(uid),
161        }
162    }
163
164    /// A vsock peer that presented the pre-shared token.
165    pub fn from_vsock_token(role: CallerRole) -> Self {
166        Self {
167            role,
168            principal: CallerPrincipal::VsockToken,
169        }
170    }
171
172    /// A connection the daemon could not attribute. Always `Observer`.
173    pub fn unattributed() -> Self {
174        Self {
175            role: CallerRole::Observer,
176            principal: CallerPrincipal::Unattributed,
177        }
178    }
179
180    pub fn role(&self) -> CallerRole {
181        self.role
182    }
183
184    pub fn principal(&self) -> CallerPrincipal {
185        self.principal
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Token authentication (vsock connections)
191// ---------------------------------------------------------------------------
192
193/// Validate `presented_token` against the token stored in `token_path`.
194///
195/// Returns the role the token holder is granted (read from the
196/// `SYSKNIFE_TOKEN_ROLE` env var, defaulting to `Dev`) on success, or `None`
197/// if the token file is absent, unreadable, or the token does not match.
198///
199/// Whitespace (including trailing newlines) is stripped from the stored token
200/// before comparison. Provision the file with `install -m 600` — a token file
201/// readable by group or other is rejected outright (see below).
202///
203/// # Permissions
204///
205/// The file must not be group- or world-accessible (`mode & 0o077 == 0`),
206/// mirroring the check `AuditKey::load_or_generate` applies to the Ed25519
207/// signing key. Without it, a token written under a default `umask 022` lands
208/// at `0644`, and any local user who can read it can authenticate over vsock
209/// and receive `token_role()` — bypassing the `SO_PEERCRED` → group →
210/// `CallerRole` mechanism that is the documented authorization model for the
211/// Unix path.
212pub fn validate_token_against_file(
213    presented_token: &str,
214    token_path: &std::path::Path,
215) -> Option<CallerRole> {
216    if presented_token.is_empty() {
217        return None;
218    }
219    match token_file_permissions_ok(token_path) {
220        Ok(true) => {}
221        Ok(false) => return None,
222        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
223            eprintln!(
224                "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
225                token_path.display()
226            );
227            return None;
228        }
229        Err(e) => {
230            eprintln!(
231                "[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
232                token_path.display()
233            );
234            return None;
235        }
236    }
237    // Absence is already reported by the permission check above; anything
238    // reaching here is a genuine read failure (or a file removed in between).
239    let stored = match std::fs::read_to_string(token_path) {
240        Ok(s) => s,
241        Err(e) => {
242            eprintln!(
243                "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
244                token_path.display()
245            );
246            return None;
247        }
248    };
249    let stored = stored.trim();
250    if stored.is_empty() {
251        return None;
252    }
253    // Constant-time comparison to prevent timing oracles on credentials.
254    // Using `==` here would allow an attacker to learn the stored token
255    // byte-by-byte from response-time differences. `subtle::ConstantTimeEq`
256    // returns a `Choice` that takes the same time regardless of how many
257    // leading bytes match. Length mismatch short-circuits — that is fine,
258    // the secret is the bytes of the token, not its length class.
259    if stored.len() != presented_token.len() {
260        return None;
261    }
262    use subtle::ConstantTimeEq;
263    if stored
264        .as_bytes()
265        .ct_eq(presented_token.as_bytes())
266        .unwrap_u8()
267        != 1
268    {
269        return None;
270    }
271    Some(token_role())
272}
273
274/// Is the token file protected from other local users?
275///
276/// Returns `Ok(false)` (after warning) when the file is group- or
277/// world-accessible. The token is a bearer credential: anything that can read
278/// it can present it.
279fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
280    use std::os::unix::fs::PermissionsExt;
281
282    let mode = std::fs::metadata(token_path)?.permissions().mode();
283    if mode & 0o077 != 0 {
284        eprintln!(
285            "[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
286             readable by group or other (any local user could authenticate with it). \
287             Rejecting vsock auth — fix with: chmod 600 {}",
288            token_path.display(),
289            mode & 0o7777,
290            token_path.display()
291        );
292        return Ok(false);
293    }
294    Ok(true)
295}
296
297/// Return the `CallerRole` granted to token-authenticated vsock connections.
298///
299/// Reads `SYSKNIFE_TOKEN_ROLE` env var (surrounding whitespace is trimmed).
300/// When unset or empty it defaults to `Dev` — the documented default for
301/// token-authenticated vsock guests. An **unrecognized** value fails *closed*
302/// to `Observer` (read-only) with a warning, rather than silently granting the
303/// mutating `Dev` tier on an operator typo.
304pub fn token_role() -> CallerRole {
305    let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
306    match raw.trim().to_ascii_lowercase().as_str() {
307        "observer" => CallerRole::Observer,
308        "admin" => CallerRole::Admin,
309        "boot" => CallerRole::Boot,
310        "dev" | "" => CallerRole::Dev,
311        other => {
312            eprintln!(
313                "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
314                 failing closed to Observer (read-only)"
315            );
316            CallerRole::Observer
317        }
318    }
319}
320
321/// Default path for the daemon token file.
322pub fn default_token_path() -> std::path::PathBuf {
323    sysknife_core::config::prefs_path()
324        .parent()
325        .unwrap_or_else(|| {
326            eprintln!(
327                "[sysknife-daemon] WARNING: prefs_path() has no parent; \
328                 falling back to /tmp for token file — this is a misconfiguration"
329            );
330            std::path::Path::new("/tmp")
331        })
332        .join("token")
333}
334
335// ---------------------------------------------------------------------------
336// Tests
337// ---------------------------------------------------------------------------
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    /// Provision a token file the way the daemon requires (`install -m 600`).
344    /// A default-umask write lands at 0644, which is refused: a token any
345    /// local user can read is not a credential.
346    fn secure(path: &std::path::Path) {
347        use std::os::unix::fs::PermissionsExt;
348        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
349    }
350
351    fn role(groups: &[&str]) -> CallerRole {
352        highest_role_from_groups(groups.iter().copied())
353    }
354
355    // -----------------------------------------------------------------
356    // Telling a denied caller how to stop being denied
357    //
358    // Denials named the action and the caller's role and stopped there:
359    // "action 'AptInstall' is not allowed for Observer role". Accurate,
360    // and a dead end — the group that grants the role is not discoverable
361    // from that sentence, and unlike every other well-written message in
362    // this codebase it offered no command to run.
363    // -----------------------------------------------------------------
364
365    #[test]
366    fn every_role_maps_back_to_the_group_that_grants_it() {
367        assert_eq!(group_for_role(CallerRole::Observer), OBSERVER_GROUP);
368        assert_eq!(group_for_role(CallerRole::Dev), DEV_GROUP);
369        assert_eq!(group_for_role(CallerRole::Admin), ADMIN_GROUP);
370        assert_eq!(group_for_role(CallerRole::Boot), BOOT_GROUP);
371    }
372
373    #[test]
374    fn the_group_a_role_maps_to_actually_grants_that_role() {
375        // Guards against the two mappings drifting apart: whatever group we
376        // tell the user to join must resolve back to the role they need.
377        for wanted in [
378            CallerRole::Observer,
379            CallerRole::Dev,
380            CallerRole::Admin,
381            CallerRole::Boot,
382        ] {
383            let group = group_for_role(wanted);
384            assert_eq!(
385                role(&[group]),
386                wanted,
387                "joining {group} must grant {wanted:?}"
388            );
389        }
390    }
391
392    #[test]
393    fn a_denial_says_which_group_to_join_and_how() {
394        let msg = denial_message("AptInstall", CallerRole::Observer, CallerRole::Dev);
395        assert!(msg.contains("AptInstall"), "names the action: {msg}");
396        assert!(msg.contains("Observer"), "names the current role: {msg}");
397        assert!(msg.contains("Dev"), "names the required role: {msg}");
398        assert!(msg.contains(DEV_GROUP), "names the group: {msg}");
399        assert!(msg.contains("usermod"), "gives the command: {msg}");
400        assert!(
401            msg.contains("log out") || msg.contains("new login"),
402            "group membership needs a fresh session; say so: {msg}"
403        );
404    }
405
406    #[test]
407    fn empty_groups_resolves_to_observer() {
408        assert_eq!(role(&[]), CallerRole::Observer);
409    }
410
411    #[test]
412    fn unknown_group_resolves_to_observer() {
413        assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
414    }
415
416    #[test]
417    fn lacs_observer_group_resolves_to_observer() {
418        assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
419    }
420
421    #[test]
422    fn lacs_dev_group_resolves_to_dev() {
423        assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
424    }
425
426    #[test]
427    fn lacs_admin_group_resolves_to_admin() {
428        assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
429    }
430
431    #[test]
432    fn wheel_group_resolves_to_admin() {
433        assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
434    }
435
436    #[test]
437    fn lacs_boot_group_resolves_to_boot() {
438        assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
439    }
440
441    #[test]
442    fn highest_role_wins_when_multiple_groups_present() {
443        // A user in both sysknife-dev and wheel gets Admin (wheel > Dev).
444        assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
445    }
446
447    #[test]
448    fn boot_role_beats_admin_and_wheel() {
449        assert_eq!(
450            role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
451            CallerRole::Boot
452        );
453    }
454
455    #[test]
456    fn mixed_known_and_unknown_groups_returns_highest_known() {
457        assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
458    }
459
460    // --- token auth ---
461
462    #[test]
463    fn valid_token_matches_and_returns_dev_by_default() {
464        let dir = tempfile::tempdir().unwrap();
465        let path = dir.path().join("token");
466        std::fs::write(&path, "secret123").unwrap();
467        secure(&path);
468        assert_eq!(
469            validate_token_against_file("secret123", &path),
470            Some(CallerRole::Dev)
471        );
472    }
473
474    #[test]
475    fn token_file_with_trailing_newline_still_matches() {
476        // `echo TOKEN > file` appends a newline — must still work.
477        let dir = tempfile::tempdir().unwrap();
478        let path = dir.path().join("token");
479        std::fs::write(&path, "secret123\n").unwrap();
480        secure(&path);
481        assert_eq!(
482            validate_token_against_file("secret123", &path),
483            Some(CallerRole::Dev)
484        );
485    }
486
487    #[test]
488    fn wrong_token_returns_none() {
489        let dir = tempfile::tempdir().unwrap();
490        let path = dir.path().join("token");
491        std::fs::write(&path, "correct\n").unwrap();
492        secure(&path);
493        assert_eq!(validate_token_against_file("wrong", &path), None);
494    }
495
496    #[test]
497    fn absent_token_file_returns_none() {
498        let dir = tempfile::tempdir().unwrap();
499        let path = dir.path().join("nonexistent");
500        assert_eq!(validate_token_against_file("any", &path), None);
501    }
502
503    #[test]
504    fn empty_presented_token_is_always_rejected() {
505        let dir = tempfile::tempdir().unwrap();
506        let path = dir.path().join("token");
507        std::fs::write(&path, "").unwrap();
508        secure(&path);
509        assert_eq!(validate_token_against_file("", &path), None);
510    }
511
512    #[test]
513    fn empty_stored_token_is_rejected_even_with_matching_presented() {
514        let dir = tempfile::tempdir().unwrap();
515        let path = dir.path().join("token");
516        std::fs::write(&path, "\n").unwrap();
517        secure(&path);
518        assert_eq!(validate_token_against_file("", &path), None);
519    }
520
521    /// Regression test for the constant-time comparison path.
522    ///
523    /// We can't measure timing variance reliably in CI, so this just
524    /// documents intent: the comparator must return the same boolean
525    /// answer for an exact match, a wrong-prefix-same-length token, and a
526    /// wrong-suffix-same-length token. Equal-length non-matching inputs
527    /// are the path that would leak timing under a non-constant-time
528    /// compare; this test exercises that path.
529    #[test]
530    fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
531        let dir = tempfile::tempdir().unwrap();
532        let path = dir.path().join("token");
533        // Stored token is exactly 9 bytes; both candidates below are also
534        // exactly 9 bytes, so the length-mismatch shortcut does not apply
535        // and we genuinely traverse the constant-time `ct_eq` path.
536        std::fs::write(&path, "abcdefghi").unwrap();
537        secure(&path);
538
539        // Exact match → accepted.
540        assert_eq!(
541            validate_token_against_file("abcdefghi", &path),
542            Some(CallerRole::Dev)
543        );
544        // Equal-length, wrong first byte → rejected.
545        assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
546        // Equal-length, wrong last byte → rejected.
547        assert_eq!(validate_token_against_file("abcdefghX", &path), None);
548        // Equal-length, completely different → rejected.
549        assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
550    }
551
552    // --- token_role() ---
553
554    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
555
556    fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
557        let _guard = ENV_LOCK.lock().unwrap();
558        match val {
559            Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
560            None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
561        }
562        f();
563        std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
564    }
565
566    #[test]
567    fn token_role_defaults_to_dev_when_unset() {
568        with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
569    }
570
571    #[test]
572    fn token_role_explicit_dev() {
573        with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
574    }
575
576    #[test]
577    fn token_role_observer() {
578        with_role_env(Some("observer"), || {
579            assert_eq!(token_role(), CallerRole::Observer)
580        });
581    }
582
583    #[test]
584    fn token_role_admin() {
585        with_role_env(Some("admin"), || {
586            assert_eq!(token_role(), CallerRole::Admin)
587        });
588    }
589
590    #[test]
591    fn token_role_boot() {
592        with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
593    }
594
595    #[test]
596    fn token_role_unknown_value_fails_closed_to_observer() {
597        // An unrecognized value is a misconfiguration; fail closed to the
598        // least-privileged (read-only) role rather than granting Dev.
599        with_role_env(Some("superuser"), || {
600            assert_eq!(token_role(), CallerRole::Observer)
601        });
602    }
603
604    #[test]
605    fn token_role_is_case_insensitive() {
606        with_role_env(Some("ADMIN"), || {
607            assert_eq!(token_role(), CallerRole::Admin)
608        });
609    }
610
611    #[test]
612    fn token_role_trims_surrounding_whitespace() {
613        // Env values sourced from files often carry a trailing newline; it must
614        // not turn a valid role into an unknown value.
615        with_role_env(Some("  admin\n"), || {
616            assert_eq!(token_role(), CallerRole::Admin)
617        });
618    }
619    // ── Principal rendering ───────────────────────────────────────────────
620
621    /// Walks every variant through an exhaustive match, so adding one without
622    /// deciding how it renders is a compile error rather than a silent new
623    /// string in the audit chain.
624    ///
625    /// The empty check is the load-bearing one. `ChainRow::identity` treats an
626    /// empty `caller_principal` on a v3 row as missing and reports the row
627    /// *broken*, so a variant that rendered empty would turn every honest row on
628    /// an affected host into a false tamper report. That is a worse outcome than
629    /// the attribution failure it would be describing.
630    #[test]
631    fn every_principal_renders_as_a_non_empty_known_scheme() {
632        const SCHEMES: [&str; 3] = ["uid", "token", "none"];
633
634        for principal in [
635            CallerPrincipal::Uid(0),
636            CallerPrincipal::Uid(1000),
637            CallerPrincipal::Uid(u32::MAX),
638            CallerPrincipal::VsockToken,
639            CallerPrincipal::Unattributed,
640        ] {
641            // Exhaustive on purpose: a new variant must be added here too.
642            let expected_scheme = match principal {
643                CallerPrincipal::Uid(_) => "uid",
644                CallerPrincipal::VsockToken => "token",
645                CallerPrincipal::Unattributed => "none",
646            };
647
648            let signed = principal.as_signed_str();
649            assert!(
650                !signed.is_empty(),
651                "{principal:?} renders empty, which a v3 row reports as tampering"
652            );
653            assert_eq!(principal.scheme(), expected_scheme);
654            let (scheme, value) = signed
655                .split_once(':')
656                .unwrap_or_else(|| panic!("{principal:?} rendered {signed:?} with no scheme"));
657            assert_eq!(scheme, expected_scheme);
658            assert!(
659                !value.is_empty(),
660                "{principal:?} rendered {signed:?} with an empty value"
661            );
662            assert!(SCHEMES.contains(&scheme), "unknown scheme in {signed:?}");
663        }
664    }
665
666    /// Exact renderings, frozen. These strings are signed into audit rows, so
667    /// changing one silently re-encodes every future row and makes chains written
668    /// by two builds disagree about who acted.
669    #[test]
670    fn principal_renderings_are_frozen() {
671        assert_eq!(CallerPrincipal::Uid(0).as_signed_str(), "uid:0");
672        assert_eq!(CallerPrincipal::Uid(1000).as_signed_str(), "uid:1000");
673        assert_eq!(
674            CallerPrincipal::Uid(u32::MAX).as_signed_str(),
675            "uid:4294967295"
676        );
677        assert_eq!(CallerPrincipal::VsockToken.as_signed_str(), "token:vsock");
678        assert_eq!(
679            CallerPrincipal::Unattributed.as_signed_str(),
680            "none:unattributed"
681        );
682    }
683
684    /// No scheme may prefix another, or a stored principal could be read as
685    /// belonging to a different evidence class than the one that signed it.
686    #[test]
687    fn no_scheme_is_a_prefix_of_another() {
688        let schemes = ["uid", "token", "none"];
689        for a in schemes {
690            for b in schemes {
691                if a != b {
692                    assert!(
693                        !a.starts_with(b) && !b.starts_with(a),
694                        "scheme {a:?} and {b:?} are prefix-ambiguous"
695                    );
696                }
697            }
698        }
699    }
700
701    // ── Attribution pairing ───────────────────────────────────────────────
702
703    /// The pairing that must never exist: a privileged role with no account.
704    /// `unattributed()` hardcodes Observer, and the private fields mean no call
705    /// site can assemble the other combination.
706    #[test]
707    fn an_unattributed_caller_is_never_privileged() {
708        let caller = CallerAttribution::unattributed();
709        assert_eq!(caller.role(), CallerRole::Observer);
710        assert_eq!(caller.principal(), CallerPrincipal::Unattributed);
711    }
712
713    #[test]
714    fn transport_constructors_pick_the_matching_evidence_class() {
715        assert_eq!(
716            CallerAttribution::from_peer_uid(4242, CallerRole::Admin).principal(),
717            CallerPrincipal::Uid(4242)
718        );
719        assert_eq!(
720            CallerAttribution::from_vsock_token(CallerRole::Dev).principal(),
721            CallerPrincipal::VsockToken
722        );
723    }
724}