openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! The logged-in OS user on this host (I-1 D-05).
//!
//! Hand-rolled rather than delegated to the `whoami` crate, for one decisive
//! reason: under `sudo` the real **and** effective uid are both 0, so any crate
//! reporting the effective user answers `root` for every developer on the
//! machine. `$SUDO_USER` is the only signal that survives, which means the
//! *order* is the whole feature — and `whoami`'s order is undocumented, so it
//! could change under us. (It also drags two Apple-only crates into every
//! lockfile and SBOM for a value we read once per process.)
//!
//! # Resolution order
//!
//! | # | Unix | Windows |
//! |---|------|---------|
//! | 1 | `$OPENLATCH_TEST_OS_USER` | `$OPENLATCH_TEST_OS_USER` |
//! | 2 | `$SUDO_USER` | `GetUserNameW` |
//! | 3 | `getpwuid_r(getuid())` | `%USERNAME%` |
//! | 4 | `$USER` | — |
//! | 5 | `$LOGNAME` | — |
//!
//! Every step that produces an empty string is treated as if it produced
//! nothing, and every failure falls through silently to the next — the resolver
//! returns `Option<String>` and never a `Result`, because there is no error here
//! a user could act on and no new `OL-` code to spend (D-12). A host with no
//! interactive user simply sends no `osuser` attribute.
//!
//! # On the test seam
//!
//! `OPENLATCH_TEST_OS_USER` is honored unconditionally, in release builds too
//! (D-11). That adds no attack surface worth naming: the value below it in the
//! ladder is `$SUDO_USER`, an environment variable. These signals are
//! self-reported telemetry hints by construction — the platform treats
//! `os_user` as one rung of a match ladder and never as authorization input —
//! and the PRD's platform-side E2E recipe sets this variable against *release*
//! binaries, so a `#[cfg(test)]` seam would not exist where it is needed.
//!
//! On Windows the value is sent exactly as captured. `GetUserNameW` returns the
//! bare account name; a `DOMAIN\`-qualified value only ever arrives through the
//! `%USERNAME%` fallback. Either form goes out unaltered — normalization is the
//! platform's job (PRD "Match normalization"), and stripping a domain here would
//! throw away half of an identity the directory match may need.

use std::sync::OnceLock;

/// Test seam (D-11). Honored on every platform, before every other source.
const TEST_SEAM_ENV: &str = "OPENLATCH_TEST_OS_USER";

/// Resolved once per process — the OS user cannot change inside a daemon's
/// lifetime, and the syscall below is not free enough to pay per event.
static OS_USER: OnceLock<Option<String>> = OnceLock::new();

/// The logged-in OS user, or `None` when this host has no answer.
///
/// Memoised process-wide, so this is always ready by the time the stamping gate
/// asks for it — unlike `gitemail` / `provideracct`, `osuser` never has to be
/// omitted for a first event because resolution had not finished.
pub fn os_user() -> Option<String> {
    OS_USER.get_or_init(resolve).clone()
}

/// The uncached ladder. Separate from [`os_user`] so tests can exercise every
/// rung — calling the memoised wrapper once would freeze the first answer for
/// the whole test binary.
fn resolve() -> Option<String> {
    if let Some(seam) = env_non_empty(TEST_SEAM_ENV) {
        return Some(seam);
    }
    resolve_platform()
}

#[cfg(unix)]
fn resolve_platform() -> Option<String> {
    // `$SUDO_USER` FIRST. Under sudo both uids are 0, so the syscall below would
    // answer `root` and attribute every action on the host to nobody.
    //
    // `$DOAS_USER` is deliberately not consulted: doas is not in this
    // initiative's scope, OpenBSD is not a supported target, and an untested
    // branch on an attribution path is worse than an absent attribute.
    env_non_empty("SUDO_USER")
        .or_else(passwd_name)
        .or_else(|| env_non_empty("USER"))
        .or_else(|| env_non_empty("LOGNAME"))
}

/// `getpwuid_r(getuid())` — the **real** uid, matching the `$SUDO_USER`
/// reasoning above.
///
/// Sized by `_SC_GETPW_R_SIZE_MAX` and retried on `ERANGE` up to a ceiling,
/// because that sysconf value is a hint on glibc and a host with large NSS
/// records (LDAP/AD-joined) legitimately needs more.
#[cfg(unix)]
fn passwd_name() -> Option<String> {
    /// Give up rather than grow without bound if the C library keeps asking.
    const MAX_BUF: usize = 64 * 1024;

    let uid = unsafe { libc::getuid() };
    let mut bufsize = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } {
        n if n > 0 => n as usize,
        _ => 1024,
    };

    loop {
        let mut buf = vec![0 as libc::c_char; bufsize];
        let mut passwd: libc::passwd = unsafe { std::mem::zeroed() };
        let mut result: *mut libc::passwd = std::ptr::null_mut();

        // SAFETY: `passwd` and `result` are live, writable, correctly-typed
        // out-parameters; `buf` is `bufsize` writable bytes owned for the whole
        // call. `getpwuid_r` is the reentrant form and touches no global state.
        let rc =
            unsafe { libc::getpwuid_r(uid, &mut passwd, buf.as_mut_ptr(), bufsize, &mut result) };

        if rc == 0 && !result.is_null() {
            // SAFETY: on success `pw_name` points into `buf`, which is still
            // alive here, and is NUL-terminated by the C library.
            let name = unsafe { std::ffi::CStr::from_ptr(passwd.pw_name) };
            return name
                .to_str()
                .ok()
                .filter(|s| !s.is_empty())
                .map(str::to_owned);
        }
        // rc == 0 with a null result means "no entry for this uid" — a real
        // answer, not a sizing problem.
        if rc != libc::ERANGE || bufsize >= MAX_BUF {
            return None;
        }
        bufsize = (bufsize * 2).min(MAX_BUF);
    }
}

#[cfg(windows)]
fn resolve_platform() -> Option<String> {
    windows_user_name().or_else(|| env_non_empty("USERNAME"))
}

/// `GetUserNameW` into a `UNLEN + 1` buffer — one call, no retry.
///
/// `UNLEN` (256) is the hard maximum Windows will mint, so this buffer cannot be
/// too small and `ERROR_INSUFFICIENT_BUFFER` cannot fire. A growth retry here
/// would be unreachable code guarding an impossible case. Any failure returns
/// `None` and the caller falls through to `%USERNAME%`.
#[cfg(windows)]
fn windows_user_name() -> Option<String> {
    use windows_sys::Win32::System::WindowsProgramming::GetUserNameW;

    /// Documented maximum user-name length, plus the terminating NUL.
    const UNLEN_NUL: usize = 257;

    let mut size = UNLEN_NUL as u32;
    let mut buf = vec![0u16; UNLEN_NUL];

    // SAFETY: `buf` is `size` writable UTF-16 units and `size` is a live u32
    // out-parameter, which is exactly the contract GetUserNameW documents.
    if unsafe { GetUserNameW(buf.as_mut_ptr(), &mut size) } == 0 {
        return None;
    }

    // `size` counts the terminating NUL on success.
    let len = (size as usize).saturating_sub(1).min(buf.len());
    let name = String::from_utf16_lossy(&buf[..len]);
    if name.is_empty() {
        None
    } else {
        Some(name)
    }
}

/// Read an environment variable, treating an empty value as absent.
///
/// A set-but-empty `$SUDO_USER` is not an identity, and stamping `osuser: ""`
/// would give the platform's match ladder a rung made of nothing.
fn env_non_empty(key: &str) -> Option<String> {
    std::env::var(key).ok().filter(|v| !v.is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::daemon::identity::test_support::EnvGuard;

    /// One test function, run sequentially, because these are process-global
    /// environment mutations.
    ///
    /// It takes [`ENV_LOCK`](crate::daemon::identity::ENV_LOCK) like every other
    /// identity test. The variables below look private to this resolver, but
    /// `git_email`'s `strip_git_env` walks the whole environment with
    /// `vars_os()` while it builds a child command — mutating that environment
    /// from a parallel thread is precisely what the lock exists to prevent.
    /// `blocking_lock` is correct here because this is a synchronous test, with
    /// no runtime to block.
    ///
    /// It also exercises the uncached [`resolve`] rather than [`os_user`]: one
    /// call to the memoised wrapper would freeze whichever value happened to be
    /// set first for every case after it.
    #[test]
    fn resolution_order_is_seam_then_platform() {
        let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let env = EnvGuard::clear();

        // 1. The seam outranks everything, on every platform.
        env.set(TEST_SEAM_ENV, "seam-user");
        assert_eq!(resolve().as_deref(), Some("seam-user"));

        // 2. An empty value is not an identity — the ladder continues past it.
        env.set(TEST_SEAM_ENV, "");
        assert_ne!(
            resolve().as_deref(),
            Some(""),
            "an empty seam must be treated as unset, never stamped"
        );
        env.unset(TEST_SEAM_ENV);

        #[cfg(unix)]
        unix_ladder(&env);
        #[cfg(windows)]
        windows_ladder();
    }

    #[cfg(unix)]
    fn unix_ladder(env: &EnvGuard) {
        // The syscall answers on its own, with no environment help at all —
        // the guard cleared `$SUDO_USER` / `$USER` / `$LOGNAME` on construction.
        let from_syscall = resolve();
        assert!(
            from_syscall.as_deref().is_none_or(|s| !s.is_empty()),
            "getpwuid_r must return a real name or nothing, never an empty string"
        );

        // $SUDO_USER outranks it — the whole reason this resolver is hand-rolled.
        // A name no passwd database can hold, so the comparison below cannot be
        // satisfied by coincidence on somebody's runner.
        env.set("SUDO_USER", "ol-sudo-user-fixture");
        assert_eq!(resolve().as_deref(), Some("ol-sudo-user-fixture"));
        assert_ne!(
            resolve(),
            from_syscall,
            "under sudo the syscall answers root; SUDO_USER must win"
        );

        // Empty is unset here too, so the ladder falls through to the syscall.
        env.set("SUDO_USER", "");
        assert_eq!(resolve(), from_syscall);
        env.unset("SUDO_USER");

        // $USER / $LOGNAME are the last rungs, and their relative order is not
        // observable on a runner where getpwuid_r answers — reaching them would
        // mean faking a passwd database. Assert the ordering that IS observable:
        // whatever the syscall found still outranks both.
        env.set("USER", "env-user");
        env.set("LOGNAME", "env-logname");
        if from_syscall.is_some() {
            assert_eq!(resolve(), from_syscall, "the syscall outranks $USER");
        }
    }

    #[cfg(windows)]
    fn windows_ladder() {
        // `GetUserNameW` needs no environment to answer on an interactive
        // session, and must never produce an empty string.
        let name = super::windows_user_name();
        assert!(
            name.as_deref().is_none_or(|s| !s.is_empty()),
            "GetUserNameW must return a real name or nothing"
        );
        if name.is_some() {
            assert_eq!(resolve(), name, "the syscall outranks %USERNAME%");
        }
    }
}