whyno-cli 0.5.0

Linux permission debugger
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>,
}

#[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,
}

#[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,
}

/// Subject input before identity resolution; resolved to uid/gid/groups by the gathering layer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubjectInput {
    Username(String),
    Uid(u32),
    Pid(u32),
    Service(String),
}

/// Parses subject string — `user:`, `uid:`, `pid:`, `svc:` prefixes or bare name/number.
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))
}

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()))
}

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))
}

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))
}

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()))
}

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. For `setxattr` use [`parse_setxattr_operation`].
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())),
    }
}

pub fn parse_setxattr_operation(xattr_key: &str) -> Result<Operation, CliError> {
    let namespace = parse_xattr_namespace(xattr_key)?;
    Ok(Operation::SetXattr { namespace })
}

/// Maps xattr key prefix to namespace: `user.`, `trusted.`, `security.`, `system.posix_acl_`.
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()))
    }
}

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()))
}

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 operation; `setxattr` requires `--xattr-key` or returns `MissingXattrKey`.
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 cap name (case-insensitive) to bitmask. Recognized: CHOWN, `DAC_OVERRIDE`, `DAC_READ_SEARCH`, FOWNER, `LINUX_IMMUTABLE`.
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;