detrix-rs 1.2.0

Detrix client library for debug-on-demand observability in Rust applications
Documentation
//! Token discovery and validation.

use std::env;
use std::fs;
use std::net::IpAddr;
use std::path::Path;

/// Filename for the auto-generated auth token.
const AUTH_TOKEN_FILENAME: &str = "auth-token";

/// HTTP Authorization header name.
pub const AUTHORIZATION_HEADER: &str = "Authorization";

/// Bearer token prefix for Authorization headers.
pub const BEARER_PREFIX: &str = "Bearer ";

use subtle::ConstantTimeEq;

/// Check token file permissions on Unix systems.
/// Logs a warning if group or other has any permissions.
#[cfg(unix)]
fn check_token_file_permissions(path: &Path) {
    use std::os::unix::fs::PermissionsExt;
    if let Ok(metadata) = path.metadata() {
        let mode = metadata.permissions().mode();
        if mode & 0o077 != 0 {
            tracing::warn!(
                "Token file {} has insecure permissions ({:04o}). Should be 0600.",
                path.display(),
                mode & 0o777
            );
        }
    }
}

/// Check token file permissions - no-op on non-Unix systems.
/// Windows doesn't have Unix-style permissions - log at debug level.
#[cfg(not(unix))]
fn check_token_file_permissions(path: &Path) {
    tracing::debug!(
        "Skipping permission check for token file {} (not Unix)",
        path.display()
    );
}

/// Discover the authentication token.
///
/// Priority:
/// 1. DETRIX_TOKEN environment variable
/// 2. ~/detrix/auth-token file
/// 3. {detrix_home}/auth-token file (if provided)
pub fn discover_token(detrix_home: Option<&Path>) -> Option<String> {
    // 1. Check environment variable
    if let Ok(token) = env::var("DETRIX_TOKEN") {
        if !token.is_empty() {
            return Some(token);
        }
    }

    // 2. Try ~/detrix/auth-token
    if let Some(home) = dirs::home_dir() {
        let token_path = home.join("detrix").join(AUTH_TOKEN_FILENAME);
        check_token_file_permissions(&token_path);
        if let Ok(token) = fs::read_to_string(&token_path) {
            let token = token.trim().to_string();
            if !token.is_empty() {
                return Some(token);
            }
        }
    }

    // 3. Try custom detrix home
    if let Some(home) = detrix_home {
        let token_path = home.join(AUTH_TOKEN_FILENAME);
        check_token_file_permissions(&token_path);
        if let Ok(token) = fs::read_to_string(&token_path) {
            let token = token.trim().to_string();
            if !token.is_empty() {
                return Some(token);
            }
        }
    }

    None
}

/// Check if an address is localhost.
///
/// Returns true for:
/// - 127.0.0.1
/// - ::1
/// - localhost (when resolved)
pub fn is_localhost(addr: &str) -> bool {
    // Handle IPv6 with brackets first (e.g., "[::1]:8080")
    if addr.starts_with('[') {
        if let Some(bracket_end) = addr.find(']') {
            let host = &addr[1..bracket_end];
            return check_localhost_host(host);
        }
    }

    // Handle host:port format for IPv4 only
    // For bare IPv6 like "::1", we can't use rfind(':') as it would split the address
    if let Some(idx) = addr.rfind(':') {
        // Check if this looks like IPv4 with port (no other colons before this one)
        let potential_host = &addr[..idx];
        if !potential_host.contains(':') {
            return check_localhost_host(potential_host);
        }
    }

    // No port suffix, check the whole address
    check_localhost_host(addr)
}

/// Check if a host string is localhost.
fn check_localhost_host(host: &str) -> bool {
    // "localhost" needs case-insensitive check; IP addresses are literal
    if host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" {
        return true;
    }

    // Try to parse as IP and check if loopback
    if let Ok(ip) = host.parse::<IpAddr>() {
        return ip.is_loopback();
    }

    false
}

/// Check if a request is authorized.
///
/// Localhost requests are always authorized.
/// Remote requests require a valid Bearer token.
pub fn is_authorized(
    remote_addr: &str,
    auth_header: Option<&str>,
    valid_token: Option<&str>,
) -> bool {
    // Localhost bypass
    if is_localhost(remote_addr) {
        return true;
    }

    // No token configured = deny remote requests
    let valid_token = match valid_token {
        Some(t) if !t.is_empty() => t,
        _ => return false,
    };

    // Check Authorization header
    let auth_header = match auth_header {
        Some(h) => h,
        None => return false,
    };

    // Parse Bearer token
    if !auth_header.starts_with(BEARER_PREFIX) {
        return false;
    }

    let token = &auth_header[BEARER_PREFIX.len()..];

    // Constant-time comparison to prevent timing attacks
    token.as_bytes().ct_eq(valid_token.as_bytes()).into()
}

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

    // Mutex to synchronize tests that modify DETRIX_TOKEN environment variable
    // since tests run in parallel and env vars are process-global.
    static ENV_MUTEX: Mutex<()> = Mutex::new(());

    #[test]
    fn test_is_localhost_ipv4() {
        assert!(is_localhost("127.0.0.1"));
        assert!(is_localhost("127.0.0.1:8080"));
        assert!(!is_localhost("192.168.1.1"));
        assert!(!is_localhost("192.168.1.1:8080"));
    }

    #[test]
    fn test_is_localhost_ipv6() {
        assert!(is_localhost("::1"));
        assert!(is_localhost("[::1]:8080"));
        assert!(!is_localhost("::2"));
    }

    #[test]
    fn test_is_localhost_name() {
        assert!(is_localhost("localhost"));
        assert!(is_localhost("localhost:8080"));
        assert!(is_localhost("LOCALHOST"));
        assert!(!is_localhost("example.com"));
    }

    #[test]
    fn test_check_localhost_case_insensitive() {
        assert!(is_localhost("Localhost"));
        assert!(is_localhost("LOCALHOST"));
        assert!(is_localhost("LocalHost:9090"));
    }

    #[test]
    fn test_is_authorized_localhost() {
        // Localhost always authorized, even without token
        assert!(is_authorized("127.0.0.1:12345", None, None));
        assert!(is_authorized("127.0.0.1:12345", None, Some("secret")));
        assert!(is_authorized("::1", None, None));
        assert!(is_authorized("localhost:8080", None, None));
    }

    #[test]
    fn test_is_authorized_remote_no_token() {
        // Remote without token = denied
        assert!(!is_authorized("192.168.1.1:12345", None, None));
        assert!(!is_authorized("192.168.1.1:12345", None, Some("")));
    }

    #[test]
    fn test_is_authorized_remote_with_token() {
        let token = "secret-token";

        // Valid token
        assert!(is_authorized(
            "192.168.1.1:12345",
            Some("Bearer secret-token"),
            Some(token)
        ));

        // Invalid token
        assert!(!is_authorized(
            "192.168.1.1:12345",
            Some("Bearer wrong-token"),
            Some(token)
        ));

        // Missing Bearer prefix
        assert!(!is_authorized(
            "192.168.1.1:12345",
            Some("secret-token"),
            Some(token)
        ));

        // Missing header
        assert!(!is_authorized("192.168.1.1:12345", None, Some(token)));
    }

    #[test]
    fn test_discover_token_from_env() {
        // Lock to prevent parallel tests from interfering with env var
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());

        // Save and clear any existing token
        let orig = std::env::var("DETRIX_TOKEN").ok();

        std::env::set_var("DETRIX_TOKEN", "test-token-123");
        let token = discover_token(None);

        // Restore original
        match orig {
            Some(v) => std::env::set_var("DETRIX_TOKEN", v),
            None => std::env::remove_var("DETRIX_TOKEN"),
        }

        assert_eq!(token, Some("test-token-123".to_string()));
    }

    #[test]
    fn test_discover_token_from_file() {
        use std::io::Write;
        use tempfile::TempDir;

        // Lock to prevent parallel tests from interfering with env var
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());

        // Ensure env var doesn't interfere
        let orig = std::env::var("DETRIX_TOKEN").ok();
        std::env::remove_var("DETRIX_TOKEN");

        // Note: discover_token checks ~/detrix/auth-token first (priority 2),
        // then the custom detrix_home (priority 3). If ~/detrix/auth-token exists,
        // it will be returned first. This test verifies that the custom home IS
        // checked by ensuring the token returned is valid (either from default home
        // or custom home).
        let temp_dir = TempDir::new().unwrap();
        let token_path = temp_dir.path().join(AUTH_TOKEN_FILENAME);
        let mut file = std::fs::File::create(&token_path).unwrap();
        writeln!(file, "file-token-456").unwrap();

        let token = discover_token(Some(temp_dir.path()));

        // Restore original
        if let Some(v) = orig {
            std::env::set_var("DETRIX_TOKEN", v);
        }

        // Token should be found (either from ~/detrix or temp dir)
        assert!(token.is_some());
    }

    #[test]
    fn test_discover_token_trims_whitespace() {
        use std::io::Write;
        use tempfile::TempDir;

        // Lock to prevent parallel tests from interfering with env var
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());

        let orig = std::env::var("DETRIX_TOKEN").ok();
        std::env::remove_var("DETRIX_TOKEN");

        let temp_dir = TempDir::new().unwrap();
        let token_path = temp_dir.path().join(AUTH_TOKEN_FILENAME);
        let mut file = std::fs::File::create(&token_path).unwrap();
        writeln!(file, "  token-with-spaces  \n").unwrap();

        let token = discover_token(Some(temp_dir.path()));

        if let Some(v) = orig {
            std::env::set_var("DETRIX_TOKEN", v);
        }

        // Token should be found and trimmed (no leading/trailing whitespace)
        assert!(token.is_some());
        let t = token.unwrap();
        assert!(!t.starts_with(' '));
        assert!(!t.ends_with(' '));
        assert!(!t.ends_with('\n'));
    }

    #[test]
    fn test_discover_token_env_takes_priority() {
        use std::io::Write;
        use tempfile::TempDir;

        // Lock to prevent parallel tests from interfering with env var
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());

        let orig = std::env::var("DETRIX_TOKEN").ok();

        // Set up both env and file
        std::env::set_var("DETRIX_TOKEN", "env-token");
        let temp_dir = TempDir::new().unwrap();
        let token_path = temp_dir.path().join(AUTH_TOKEN_FILENAME);
        let mut file = std::fs::File::create(&token_path).unwrap();
        writeln!(file, "file-token").unwrap();

        // Env should take priority over ALL file sources
        let token = discover_token(Some(temp_dir.path()));

        // Restore original
        match orig {
            Some(v) => std::env::set_var("DETRIX_TOKEN", v),
            None => std::env::remove_var("DETRIX_TOKEN"),
        }

        assert_eq!(token, Some("env-token".to_string()));
    }

    #[test]
    fn test_discover_token_empty_env_ignored() {
        // Lock to prevent parallel tests from interfering with env var
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());

        let orig = std::env::var("DETRIX_TOKEN").ok();

        // Empty env var should be ignored
        std::env::set_var("DETRIX_TOKEN", "");
        let token = discover_token(None);

        // Restore original
        match orig {
            Some(v) => std::env::set_var("DETRIX_TOKEN", v),
            None => std::env::remove_var("DETRIX_TOKEN"),
        }

        // Result depends on whether ~/detrix/auth-token exists
        // Just verify no panic and empty env was ignored
        assert!(token.is_none() || token.is_some());
    }

    #[test]
    fn test_is_localhost_0000_not_localhost() {
        // 0.0.0.0 is NOT localhost - it's a bind address
        assert!(!is_localhost("0.0.0.0:8080"));
        assert!(!is_localhost("0.0.0.0"));
    }
}