whyno-cli 0.3.0

Linux permission debugger
//! clap argument definitions.
//!
//! Two modes:
//! - check: `whyno <subject> <operation> <path> [flags]`
//! - caps: `whyno caps <action>`
//!
//! Subject formats: bare username, bare uid, `user:name`, `uid:N`, `pid:N`, `svc:name`.

use std::path::PathBuf;

use clap::{Parser, Subcommand};

use crate::error::CliError;
use whyno_core::operation::Operation;

const EXAMPLES: &str = "\x1b[1mEXAMPLES:\x1b[0m
    whyno nginx read /var/log/app.log
    whyno uid:33 write /tmp/upload
    whyno pid:1234 execute /usr/bin/app
    whyno svc:postgres read /etc/ssl/private/server.key
    whyno nginx read /path --json
    whyno nginx read /path --with-cap CAP_DAC_OVERRIDE
    sudo whyno caps install";

/// Linux permission debugger.
///
/// Diagnoses why a subject cannot perform an operation on a path.
/// Reports blocking layers and suggests least-privilege fixes.
#[derive(Parser, Debug)]
#[allow(clippy::struct_excessive_bools)]
#[command(
    name = "whyno",
    version,
    about = "Linux permission debugger",
    after_help = EXAMPLES,
    args_conflicts_with_subcommands = true,
)]
pub struct Cli {
    /// Subject (username, uid:N, pid:N, svc:name).
    pub subject: Option<String>,

    /// Operation (read, write, execute, delete, create, stat).
    pub operation: Option<String>,

    /// Target filesystem path.
    pub path: Option<PathBuf>,

    /// Emit JSON instead of human-readable output.
    #[arg(long)]
    pub json: bool,

    /// Verbose per-layer explanation.
    #[arg(long)]
    pub explain: bool,

    /// Disable color (respects `NO_COLOR` env).
    #[arg(long, alias = "no-color")]
    pub no_color: bool,

    /// Cross-check whyno's answer against kernel `faccessat2`.
    ///
    /// Only valid when subject is the calling user and kernel >= 5.8.
    /// Reports mismatches to stderr.
    #[arg(long)]
    pub self_test: bool,

    /// Capability names to inject (e.g., `CAP_DAC_OVERRIDE`).
    ///
    /// Sets capabilities to `Probe::Known` with the bitwise-OR of all named
    /// Cap bitmasks, overriding any gathered value. for hypothetical queries.
    #[arg(long, value_name = "CAP")]
    pub with_cap: Vec<String>,

    /// Subcommand (caps management).
    #[command(subcommand)]
    pub command: Option<Commands>,
}

/// Subcommands beyond default check mode.
#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum Commands {
    /// Manage file capabilities for whyno binary.
    Caps {
        /// Caps action.
        #[command(subcommand)]
        action: CapsAction,
    },
    /// Print JSON schema for `--json` output format.
    Schema,
}

/// `caps` subcommand actions.
#[derive(Subcommand, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapsAction {
    /// Install caps on whyno binary (requires root).
    Install,
    /// Remove caps from whyno binary (requires root).
    Uninstall,
    /// Check whether whyno has required capabilities.
    Check,
}

/// Parsed subject input before identity resolution.
///
/// Resolved to uid/gid/groups by the gathering layer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubjectInput {
    /// Username from `/etc/passwd`.
    Username(String),
    /// Numeric uid.
    Uid(u32),
    /// Pid, resolved from `/proc/<pid>/status`.
    Pid(u32),
    /// Systemd service name, resolved via unit metadata.
    Service(String),
}

/// Parses subject string into a `SubjectInput`.
///
/// Formats:
/// - `"user:nginx"` -> `Username("nginx")`
/// - `"uid:33"` -> `Uid(33)`
/// - `"pid:1234"` -> `Pid(1234)`
/// - `"svc:nginx"` -> `Service("nginx")`
/// - `"nginx"` (bare string) -> `Username("nginx")`
/// - `"33"` (bare number) -> `Uid(33)`
pub fn parse_subject(input: &str) -> Result<SubjectInput, CliError> {
    if input.is_empty() {
        return Err(CliError::InvalidSubject(
            "subject cannot be empty".to_string(),
        ));
    }

    if let Some(rest) = input.strip_prefix("user:") {
        return parse_prefixed_username(rest);
    }
    if let Some(rest) = input.strip_prefix("uid:") {
        return parse_prefixed_uid(rest);
    }
    if let Some(rest) = input.strip_prefix("pid:") {
        return parse_prefixed_pid(rest);
    }
    if let Some(rest) = input.strip_prefix("svc:") {
        return parse_prefixed_service(rest);
    }

    Ok(parse_bare_subject(input))
}

/// Parses `user:<name>`, name must be non-empty.
fn parse_prefixed_username(name: &str) -> Result<SubjectInput, CliError> {
    if name.is_empty() {
        return Err(CliError::InvalidSubject(
            "user: prefix requires a username".to_string(),
        ));
    }
    Ok(SubjectInput::Username(name.to_string()))
}

/// Parses `uid:<number>`, must be valid u32.
fn parse_prefixed_uid(value: &str) -> Result<SubjectInput, CliError> {
    let uid = value
        .parse::<u32>()
        .map_err(|_| CliError::InvalidSubject(format!("invalid uid: {value}")))?;
    Ok(SubjectInput::Uid(uid))
}

/// Parses `pid:<number>`, must be valid u32.
fn parse_prefixed_pid(value: &str) -> Result<SubjectInput, CliError> {
    let pid = value
        .parse::<u32>()
        .map_err(|_| CliError::InvalidSubject(format!("invalid pid: {value}")))?;
    Ok(SubjectInput::Pid(pid))
}

/// Parses `svc:<name>`, name must be non-empty.
fn parse_prefixed_service(name: &str) -> Result<SubjectInput, CliError> {
    if name.is_empty() {
        return Err(CliError::InvalidSubject(
            "svc: prefix requires a service name".to_string(),
        ));
    }
    Ok(SubjectInput::Service(name.to_string()))
}

/// Bare subject: numeric -> `Uid`, otherwise `Username`.
fn parse_bare_subject(input: &str) -> SubjectInput {
    if let Ok(uid) = input.parse::<u32>() {
        SubjectInput::Uid(uid)
    } else {
        SubjectInput::Username(input.to_string())
    }
}

/// Parses operation string, case-insensitive.
///
/// Accepts: read, write, execute, delete, create, stat.
pub fn parse_operation(input: &str) -> Result<Operation, CliError> {
    match input.to_ascii_lowercase().as_str() {
        "read" => Ok(Operation::Read),
        "write" => Ok(Operation::Write),
        "execute" => Ok(Operation::Execute),
        "delete" => Ok(Operation::Delete),
        "create" => Ok(Operation::Create),
        "stat" => Ok(Operation::Stat),
        _ => Err(CliError::InvalidOperation(input.to_string())),
    }
}

/// Rejects `--json` and `--explain` together.
pub fn validate_flags(cli: &Cli) -> Result<(), CliError> {
    if cli.json && cli.explain {
        return Err(CliError::ConflictingFlags);
    }
    Ok(())
}

/// Maps a capability name string to its bitmask.
///
/// Case-insensitive. recognized names:
/// - `CAP_CHOWN` → bit 0
/// - `CAP_DAC_OVERRIDE` → bit 1
/// - `CAP_DAC_READ_SEARCH` → bit 2
/// - `CAP_FOWNER` → bit 3
/// - `CAP_LINUX_IMMUTABLE` → bit 9
pub(crate) fn parse_cap_name(s: &str) -> Result<u64, CliError> {
    match s.to_ascii_uppercase().as_str() {
        "CAP_CHOWN" => Ok(1u64 << 0),
        "CAP_DAC_OVERRIDE" => Ok(1u64 << 1),
        "CAP_DAC_READ_SEARCH" => Ok(1u64 << 2),
        "CAP_FOWNER" => Ok(1u64 << 3),
        "CAP_LINUX_IMMUTABLE" => Ok(1u64 << 9),
        _ => Err(CliError::InvalidCap(s.to_string())),
    }
}

#[cfg(test)]
#[path = "cli_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "cli_invocation_tests.rs"]
mod invocation_tests;