whyno-cli 0.4.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::{MetadataParams, 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
    whyno nginx chmod /var/log/app.log
    whyno uid:33 chown-uid --new-uid 33 /var/www/html
    whyno uid:33 chown-gid --new-gid 33 /var/www/html
    whyno uid:33 setxattr --xattr-key user.custom /data/file
    whyno uid:33 setxattr --xattr-key security.selinux /data/file";

/// 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>,

    /// Target mode bits for `chmod` (octal, e.g. `644` or `0644`).
    #[arg(long, value_name = "MODE")]
    pub new_mode: Option<String>,

    /// Target UID for `chown-uid`.
    #[arg(long, value_name = "UID")]
    pub new_uid: Option<u32>,

    /// Target GID for `chown-gid`.
    #[arg(long, value_name = "GID")]
    pub new_gid: Option<u32>,

    /// Extended attribute key for `setxattr` (e.g. `user.custom`, `security.selinux`).
    ///
    /// Prefix determines namespace: `user.`, `trusted.`, `security.`, `system.posix_acl_`.
    #[arg(long, value_name = "KEY")]
    pub xattr_key: Option<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, chmod, chown-uid, chown-gid.
/// For `setxattr`, use [`parse_setxattr_operation`] — namespace comes from `--xattr-key`.
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),
        "chmod" => Ok(Operation::Chmod),
        "chown-uid" => Ok(Operation::ChownUid),
        "chown-gid" => Ok(Operation::ChownGid),
        _ => Err(CliError::InvalidOperation(input.to_string())),
    }
}

/// Constructs `Operation::SetXattr` from an xattr key string.
///
/// Derives namespace from the key prefix. Returns an error for unknown prefixes.
pub fn parse_setxattr_operation(xattr_key: &str) -> Result<Operation, CliError> {
    let namespace = parse_xattr_namespace(xattr_key)?;
    Ok(Operation::SetXattr { namespace })
}

/// Parses xattr namespace from key prefix.
///
/// Mappings:
/// - `user.` → `User`
/// - `trusted.` → `Trusted`
/// - `security.` → `Security`
/// - `system.posix_acl_` → `SystemPosixAcl`
pub fn parse_xattr_namespace(key: &str) -> Result<whyno_core::operation::XattrNamespace, CliError> {
    use whyno_core::operation::XattrNamespace;
    if key.starts_with("user.") {
        Ok(XattrNamespace::User)
    } else if key.starts_with("trusted.") {
        Ok(XattrNamespace::Trusted)
    } else if key.starts_with("security.") {
        Ok(XattrNamespace::Security)
    } else if key.starts_with("system.posix_acl_") {
        Ok(XattrNamespace::SystemPosixAcl)
    } else {
        Err(CliError::InvalidXattrKey(key.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(())
}

/// Parses `--new-mode` octal string to u32.
///
/// Accepts with or without `0` prefix (e.g. `"644"` → `0o644`, `"0755"` → `0o755`).
pub fn parse_new_mode(s: &str) -> Result<u32, CliError> {
    let stripped = s.strip_prefix('0').unwrap_or(s);
    u32::from_str_radix(stripped, 8).map_err(|_| CliError::InvalidMode(s.to_string()))
}

/// Builds `MetadataParams` from CLI flags.
///
/// `--new-mode` is parsed as octal. `--new-uid` and `--new-gid` are taken directly.
pub fn build_metadata_params(cli: &Cli) -> Result<MetadataParams, CliError> {
    let new_mode = cli.new_mode.as_deref().map(parse_new_mode).transpose()?;
    Ok(MetadataParams {
        new_mode,
        new_uid: cli.new_uid,
        new_gid: cli.new_gid,
    })
}

/// Resolves the `Operation` from positional arg and optional `--xattr-key`.
///
/// For `setxattr`, `--xattr-key` is required. Returns `MissingXattrKey` if absent.
pub fn resolve_operation(op_str: &str, xattr_key: Option<&str>) -> Result<Operation, CliError> {
    if op_str.eq_ignore_ascii_case("setxattr") {
        let key = xattr_key.ok_or(CliError::MissingXattrKey)?;
        return parse_setxattr_operation(key);
    }
    parse_operation(op_str)
}

/// 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;