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";
#[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 {
pub subject: Option<String>,
pub operation: Option<String>,
pub path: Option<PathBuf>,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub explain: bool,
#[arg(long, alias = "no-color")]
pub no_color: bool,
#[arg(long)]
pub self_test: bool,
#[arg(long, value_name = "CAP")]
pub with_cap: Vec<String>,
#[arg(long, value_name = "MODE")]
pub new_mode: Option<String>,
#[arg(long, value_name = "UID")]
pub new_uid: Option<u32>,
#[arg(long, value_name = "GID")]
pub new_gid: Option<u32>,
#[arg(long, value_name = "KEY")]
pub xattr_key: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum Commands {
Caps {
#[command(subcommand)]
action: CapsAction,
},
Schema,
}
#[derive(Subcommand, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapsAction {
Install,
Uninstall,
Check,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubjectInput {
Username(String),
Uid(u32),
Pid(u32),
Service(String),
}
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())
}
}
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 })
}
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(())
}
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,
})
}
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)
}
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;