car-ffi-common 0.15.1

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
Documentation
//! Shared auth-token path resolution and IO for car-server's
//! per-launch ephemeral handshake (Parslee-ai/car-releases#32).
//!
//! Every consumer that touches the token agrees on the same path:
//! - `car-server` writes it on startup unless `--no-auth` is set
//!   (the default flipped from opt-in to opt-out on 2026-05 after
//!   a security audit)
//! - `car-ffi-common::proxy` reads it on connect so NAPI/PyO3
//!   daemon-mode just-works
//! - The colocated UI server exposes it at `GET /auth-token` so
//!   the local HTML renderer can `session.auth` before any other
//!   RPC
//! - External WS clients can read it directly with `0600` perms
//!   guaranteeing only the launching user can do so
//!
//! ## Path
//!
//! - **macOS**: `~/Library/Application Support/ai.parslee.car/auth-token`
//! - **Linux**: `$XDG_RUNTIME_DIR/ai.parslee.car/auth-token` if set,
//!   otherwise `~/.config/ai.parslee.car/auth-token`
//! - **Windows**: `%LOCALAPPDATA%\ai.parslee.car\auth-token` (per-user
//!   profile-private — Windows ACLs handle the equivalent of `0600`
//!   for files under the user's profile)
//!
//! ## File format
//!
//! Single line, no trailing newline. The token itself is 32 random
//! bytes encoded as base64url-no-pad (`base64::URL_SAFE_NO_PAD`),
//! which is 43 ASCII chars. Distinct per launch; never persisted
//! across daemon restarts.

use std::io;
use std::path::PathBuf;

const DIR_NAME: &str = "ai.parslee.car";
const FILE_NAME: &str = "auth-token";

/// Resolve the auth-token path for this platform. Returns
/// `Err(io::Error)` if neither HOME nor the platform-specific
/// fallback environment variable is set — in which case auth can't
/// be turned on at all.
pub fn default_path() -> io::Result<PathBuf> {
    let dir = default_dir()?;
    Ok(dir.join(FILE_NAME))
}

fn default_dir() -> io::Result<PathBuf> {
    #[cfg(target_os = "macos")]
    {
        if let Some(home) = std::env::var_os("HOME") {
            return Ok(PathBuf::from(home)
                .join("Library")
                .join("Application Support")
                .join(DIR_NAME));
        }
    }
    #[cfg(target_os = "linux")]
    {
        if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") {
            return Ok(PathBuf::from(rt).join(DIR_NAME));
        }
        if let Some(home) = std::env::var_os("HOME") {
            return Ok(PathBuf::from(home).join(".config").join(DIR_NAME));
        }
    }
    #[cfg(target_os = "windows")]
    {
        if let Some(local) = std::env::var_os("LOCALAPPDATA") {
            return Ok(PathBuf::from(local).join(DIR_NAME));
        }
    }
    // Last-resort fallback for any platform that didn't match any
    // of the above branches: `$HOME/.car/`. Less standard but always
    // produces a usable path on a typical unix-style host.
    if let Some(home) = std::env::var_os("HOME") {
        return Ok(PathBuf::from(home).join(".car"));
    }
    Err(io::Error::new(
        io::ErrorKind::NotFound,
        "no HOME / XDG_RUNTIME_DIR / LOCALAPPDATA — can't resolve auth-token directory",
    ))
}

/// Read the token from [`default_path`]. Returns `Ok(None)` when the
/// file doesn't exist (auth disabled on the daemon side); returns
/// the token string on success.
pub fn read() -> io::Result<Option<String>> {
    read_at(&default_path()?)
}

/// Read the token from an explicit path — used by tests; production
/// callers use [`read`].
pub fn read_at(path: &std::path::Path) -> io::Result<Option<String>> {
    match std::fs::read_to_string(path) {
        Ok(s) => Ok(Some(s.trim().to_string())),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e),
    }
}

/// Write `token` atomically to [`default_path`]. Creates the parent
/// directory if missing. Sets `0600` permissions on POSIX (Windows
/// inherits the per-user-profile ACL).
pub fn write(token: &str) -> io::Result<PathBuf> {
    let path = default_path()?;
    write_at(&path, token)?;
    Ok(path)
}

/// Write to an explicit path — used by tests; production callers
/// use [`write`].
pub fn write_at(path: &std::path::Path, token: &str) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
        }
    }
    let tmp = path.with_extension("tmp");
    // Write under tight perms first, then rename — POSIX
    // guarantees the rename is atomic on the same fs, so concurrent
    // readers never see a partial write.
    std::fs::write(&tmp, token)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
    }
    std::fs::rename(&tmp, path)?;
    Ok(())
}

/// Remove the token file. Idempotent — `Ok(())` whether or not the
/// file existed. Used by the `car-server` shutdown path so a stale
/// token doesn't outlive the daemon that minted it.
pub fn remove() -> io::Result<()> {
    let path = default_path()?;
    match std::fs::remove_file(&path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

/// Generate a fresh 32-byte token, base64url-no-pad encoded
/// (43 chars). Uses the system CSPRNG via `getrandom` indirectly
/// through `uuid::Uuid::new_v4` which seeds from `getrandom` — we
/// concatenate two UUIDs (16 bytes each) and re-encode.
pub fn generate() -> String {
    use base64::Engine as _;
    let a = uuid::Uuid::new_v4();
    let b = uuid::Uuid::new_v4();
    let mut bytes = [0u8; 32];
    bytes[..16].copy_from_slice(a.as_bytes());
    bytes[16..].copy_from_slice(b.as_bytes());
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generated_token_is_43_chars_base64url() {
        let t = generate();
        assert_eq!(t.len(), 43);
        for c in t.chars() {
            assert!(
                c.is_ascii_alphanumeric() || c == '-' || c == '_',
                "non-base64url char: {c:?}"
            );
        }
    }

    #[test]
    fn read_at_missing_returns_none() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("nonexistent.token");
        let result = read_at(&path).unwrap();
        assert_eq!(result, None);
    }

    #[test]
    fn write_at_then_read_at_round_trips() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("ai.parslee.car").join("auth-token");
        let token = generate();
        write_at(&path, &token).unwrap();
        assert_eq!(read_at(&path).unwrap().as_deref(), Some(token.as_str()));
        assert!(path.exists());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
            assert_eq!(mode & 0o777, 0o600, "token must be 0600");
        }
    }

    #[test]
    fn write_at_overwrites_atomically() {
        // Repeated writes must replace cleanly — the rename-into-place
        // pattern guarantees no partial-write window. Two consecutive
        // writes with different content; second wins.
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("auth-token");
        write_at(&path, "first").unwrap();
        write_at(&path, "second").unwrap();
        assert_eq!(read_at(&path).unwrap().as_deref(), Some("second"));
    }
}