diff --git a/Cargo.lock b/Cargo.lock index 575c46a..359ba38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,6 +362,7 @@ dependencies = [ "hex", "nix", "ntest", + "quick-xml", "rand", "serde", "tokio", @@ -1284,6 +1285,16 @@ dependencies = [ "prost", ] +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.36" diff --git a/Cargo.toml b/Cargo.toml index 05e128b..cc26029 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ tracing-subscriber = { version = "0.3.18", features = [ "ansi", ], default-features = false, optional = true } anyhow = "1.0.82" +quick-xml = { version = "0.36.2", features = ["overlapped-lists", "serialize"] } # Explicitly depend on serde to enable `rc` feature. serde = { version = "1.0.200", features = ["rc"] } futures-util = "0.3.30" diff --git a/src/configuration.rs b/src/configuration.rs new file mode 100644 index 0000000..95756c7 --- /dev/null +++ b/src/configuration.rs @@ -0,0 +1,454 @@ +//! implementation of "Configuration File" described at: +//! https://dbus.freedesktop.org/doc/dbus-daemon.1.html + +use std::{path::PathBuf, str::FromStr}; + +use serde::Deserialize; + +mod policy; +mod raw; + +use crate::configuration::{policy::Policy, raw::RawConfiguration}; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ApparmorMode { + Disabled, + #[default] + Enabled, + Required, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +pub struct Associate { + #[serde(rename = "@context")] + context: String, + #[serde(rename = "@own")] + own: String, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Configuration { + allow_anonymous: Option, + apparmor: ApparmorMode, + auth: Vec, + fork: Option, + // TODO: consider processing `include` more to remove XML-specific structure + include: Vec, + includedir: Vec, + keep_umask: Option, + // TODO: consider processing `include` more to remove XML-specific structure + limit: Vec, + listen: Vec, + pidfile: Option, + policy: Vec, + selinux: Vec, + servicedir: Vec, + servicehelper: Option, + standard_session_servicedirs: Option, + standard_system_servicedirs: Option, + syslog: Option, + r#type: Option, + user: Option, +} +impl FromStr for Configuration { + type Err = Error; + + fn from_str(s: &str) -> Result { + RawConfiguration::from_str(s) + .map_err(Error::DeserializeXml) + .and_then(Self::try_from) + } +} +impl TryFrom for Configuration { + type Error = Error; + + fn try_from(value: RawConfiguration) -> Result { + let mut policy = Vec::with_capacity(value.policy.len()); + for rp in value.policy { + match Policy::try_from(rp) { + Ok(p) => policy.push(p), + Err(err) => { + return Err(err); + } + } + } + + let mut bc = Self { + allow_anonymous: value.allow_anonymous.map(|_| true), + apparmor: match value.apparmor { + Some(a) => a.mode, + None => None, + } + .unwrap_or_default(), + auth: value.auth, + fork: value.fork.map(|_| true), + include: value.include, + includedir: value.includedir.into_iter().map(|pb| pb.text).collect(), + keep_umask: value.keep_umask.map(|_| true), + limit: value.limit, + listen: value.listen, + pidfile: value.pidfile, + policy, + // TODO: SELinux could probably more-conveniently be represented as a HashMap + // TODO: last one wins for SELinux associates with the same name + selinux: match value.selinux { + Some(s) => s.associate, + None => vec![], + }, + servicedir: value.servicedir.into_iter().map(|pb| pb.text).collect(), + servicehelper: value.servicehelper, + standard_session_servicedirs: value.standard_session_servicedirs.map(|_| true), + standard_system_servicedirs: value.standard_system_servicedirs.map(|_| true), + syslog: value.syslog.map(|_| true), + ..Default::default() + }; + + // > The last element "wins" + if let Some(te) = value.r#type.into_iter().last() { + bc.r#type = Some(te.text); + } + if let Some(ue) = value.user.into_iter().last() { + bc.user = Some(ue.text); + } + + Ok(bc) + } +} + +#[derive(Clone, Debug)] +pub enum Error { + DeserializeXml(quick_xml::DeError), + PolicyHasMultipleAttributes, + RuleHasInvalidCombinationOfAttributes, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum IgnoreMissing { + No, + Yes, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub struct IncludeElement { + #[serde(rename = "@ignore_missing")] + ignore_missing: Option, + #[serde(rename = "$text")] + text: PathBuf, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub struct LimitElement { + #[serde(rename = "@name")] + name: LimitName, + #[serde(rename = "$text")] + text: i32, // semantically should be u32, but i32 for compatibility +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum LimitName { + AuthTimeout, + MaxCompletedConnections, + MaxConnectionsPerUser, + MaxIncomingBytes, + MaxIncomingUnixFds, + MaxIncompleteConnections, + MaxMatchRulesPerConnection, + MaxMessageSize, + MaxMessageUnixFds, + MaxNamesPerConnection, + MaxOutgoingBytes, + MaxOutgoingUnixFds, + MaxPendingServiceStarts, + MaxRepliesPerConnection, + PendingFdTimeout, + ServiceStartTimeout, + ReplyTimeout, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase", untagged)] +pub enum Principal { + Id(u32), + Name(String), +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Type { + Session, + System, +} + +#[cfg(test)] +mod tests { + use crate::configuration::policy::{ + OwnRule, ReceiveRule, RuleEffect, RuleMatch, RulePhase, SendRule, + }; + + use super::*; + + #[test] + fn busconfig_fromstr_last_type_wins_ok() { + let input = r#" + + + system + session + + "#; + + let got = Configuration::from_str(input).expect("should parse input XML"); + + assert_eq!(got.r#type, Some(Type::Session)); + } + + #[test] + fn busconfig_fromstr_last_user_wins_ok() { + let input = r#" + + + 1234 + nobody + + "#; + + let got = Configuration::from_str(input).expect("should parse input XML"); + + assert_eq!(got.user, Some(Principal::Name(String::from("nobody")))); + } + + #[test] + fn busconfig_fromstr_allow_deny_allow_ok() { + // from https://github.com/OpenPrinting/system-config-printer/blob/caa1ba33da20fd2a82cee0bcc97589fede512cc8/dbus/com.redhat.PrinterDriversInstaller.conf + // selected because it has a in the middle of a list of s + let input = r#" + + + + + + + + + + + + + + + "#; + + let got = Configuration::from_str(input).expect("should parse input XML"); + + assert_eq!( + got, + Configuration { + policy: vec![ + Policy::User { + rules: vec![( + RuleEffect::Allow, + RulePhase::Send(SendRule { + send_destination: Some(RuleMatch::One(String::from( + "com.redhat.PrinterDriversInstaller" + ))), + send_interface: Some(RuleMatch::One(String::from( + "com.redhat.PrinterDriversInstaller" + ))), + ..Default::default() + }) + )], + user: Principal::Name(String::from("root")), + }, + Policy::DefaultContext { + rules: vec![ + ( + RuleEffect::Allow, + RulePhase::Own(OwnRule { + own: Some(RuleMatch::One(String::from( + "com.redhat.PrinterDriversInstaller" + ))), + ..Default::default() + }) + ), + ( + RuleEffect::Deny, + RulePhase::Send(SendRule { + send_destination: Some(RuleMatch::One(String::from( + "com.redhat.PrinterDriversInstaller" + ))), + send_interface: Some(RuleMatch::One(String::from( + "com.redhat.PrinterDriversInstaller" + ))), + ..Default::default() + }) + ), + ( + RuleEffect::Allow, + RulePhase::Send(SendRule { + send_destination: Some(RuleMatch::One(String::from( + "com.redhat.PrinterDriversInstaller" + ))), + send_interface: Some(RuleMatch::One(String::from( + "org.freedesktop.DBus.Introspectable" + ))), + ..Default::default() + }) + ), + ( + RuleEffect::Allow, + RulePhase::Send(SendRule { + send_destination: Some(RuleMatch::One(String::from( + "com.redhat.PrinterDriversInstaller" + ))), + send_interface: Some(RuleMatch::One(String::from( + "org.freedesktop.DBus.Properties" + ))), + ..Default::default() + }) + ), + ] + } + ], + ..Default::default() + } + ); + } + + #[test] + fn busconfig_fromstr_limit_ok() { + let input = r#" + + + 133169152 + 64 + + "#; + + let got = Configuration::from_str(input).expect("should parse input XML"); + + assert_eq!( + got, + Configuration { + limit: vec![ + LimitElement { + name: LimitName::MaxIncomingBytes, + text: 133169152 + }, + LimitElement { + name: LimitName::MaxIncomingUnixFds, + text: 64 + }, + ], + ..Default::default() + } + ); + } + + #[test] + fn busconfig_fromstr_apparmor_and_selinux_ok() { + let input = r#" + + + + + + + + "#; + + let got = Configuration::from_str(input).expect("should parse input XML"); + + assert_eq!( + got, + Configuration { + apparmor: ApparmorMode::Required, + selinux: vec![Associate { + context: String::from("foo_t"), + own: String::from("org.freedesktop.Foobar") + },], + ..Default::default() + } + ); + } + + #[test] + fn busconfig_fromstr_receiverule_ok() { + // from https://github.com/OpenPrinting/system-config-printer/blob/caa1ba33da20fd2a82cee0bcc97589fede512cc8/dbus/com.redhat.PrinterDriversInstaller.conf + // selected because it has a in the middle of a list of s + let input = r#" + + + + + + + + + + "#; + + let got = Configuration::from_str(input).expect("should parse input XML"); + + assert_eq!( + got, + Configuration { + policy: vec![Policy::DefaultContext { + rules: vec![ + ( + RuleEffect::Allow, + RulePhase::Receive(ReceiveRule { + eavesdrop: Some(false), + ..Default::default() + }) + ), + ( + RuleEffect::Allow, + RulePhase::Receive(ReceiveRule { + eavesdrop: Some(true), + ..Default::default() + }) + ), + ( + RuleEffect::Deny, + RulePhase::Receive(ReceiveRule { + eavesdrop: Some(false), + receive_requested_reply: Some(true), + ..Default::default() + }) + ), + ( + RuleEffect::Deny, + RulePhase::Receive(ReceiveRule { + eavesdrop: Some(true), + receive_requested_reply: Some(true), + ..Default::default() + }) + ), + ] + }], + ..Default::default() + } + ); + } +} diff --git a/src/configuration/policy.rs b/src/configuration/policy.rs new file mode 100644 index 0000000..0a68323 --- /dev/null +++ b/src/configuration/policy.rs @@ -0,0 +1,270 @@ +use serde::Deserialize; + +use super::{ + raw::{RawPolicy, RawPolicyContext, RawRule, RawRuleAttributes}, + Error, Principal, +}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ConnectRule { + pub group: Option, + pub user: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OwnRule { + pub own: Option, + pub own_prefix: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Policy { + Console { rules: Vec }, + DefaultContext { rules: Vec }, + Group { group: Principal, rules: Vec }, + MandatoryContext { rules: Vec }, + NoConsole { rules: Vec }, + User { user: Principal, rules: Vec }, +} +impl TryFrom for Policy { + type Error = Error; + fn try_from(value: RawPolicy) -> Result { + let mut rules: Vec = Vec::with_capacity(value.rules.len()); + for rule in value.rules { + match Rule::try_from(rule) { + Ok(ok) => rules.push(ok), + Err(err) => return Err(err), + } + } + + match value { + RawPolicy { + at_console: Some(b), + context: None, + group: None, + user: None, + .. + } => Ok(match b { + true => Self::Console { rules }, + false => Self::NoConsole { rules }, + }), + RawPolicy { + at_console: None, + context: Some(pc), + group: None, + user: None, + .. + } => Ok(match pc { + RawPolicyContext::Default => Self::DefaultContext { rules }, + RawPolicyContext::Mandatory => Self::MandatoryContext { rules }, + }), + RawPolicy { + at_console: None, + context: None, + group: Some(p), + user: None, + .. + } => Ok(Self::Group { group: p, rules }), + RawPolicy { + at_console: None, + context: None, + group: None, + user: Some(p), + .. + } => Ok(Self::User { user: p, rules }), + _ => Err(Error::PolicyHasMultipleAttributes), + } + } +} +// TODO: impl PartialOrd/Ord for Policy, for order in which policies are applied to a connection + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReceiveRule { + pub eavesdrop: Option, + pub receive_error: Option, + pub receive_interface: Option, + pub receive_member: Option, + pub receive_path: Option, + pub receive_requested_reply: Option, + pub receive_sender: Option, + pub receive_type: Option, +} + +pub type Rule = (RuleEffect, RulePhase); +impl TryFrom for Rule { + type Error = Error; + + fn try_from(value: RawRule) -> Result { + let (effect, attributes) = match value { + RawRule::Allow(attributes) => (RuleEffect::Allow, attributes), + RawRule::Deny(attributes) => (RuleEffect::Deny, attributes), + }; + match attributes { + RawRuleAttributes { + eavesdrop, + group: None, + own: None, + own_prefix: None, + receive_error, + receive_interface, + receive_member, + receive_path, + receive_requested_reply, + receive_sender, + receive_type, + send_broadcast: None, + send_destination: None, + send_destination_prefix: None, + send_error: None, + send_interface: None, + send_member: None, + send_path: None, + send_requested_reply: None, + send_type: None, + user: None, + } => Ok(( + effect, + RulePhase::Receive(ReceiveRule { + eavesdrop, + receive_error, + receive_interface, + receive_member, + receive_path, + receive_requested_reply, + receive_sender, + receive_type, + }), + )), + RawRuleAttributes { + eavesdrop, + group: None, + own: None, + own_prefix: None, + receive_error: None, + receive_interface: None, + receive_member: None, + receive_path: None, + receive_requested_reply: None, + receive_sender: None, + receive_type: None, + send_broadcast, + send_destination, + send_destination_prefix, + send_error, + send_interface, + send_member, + send_path, + send_requested_reply, + send_type, + user: None, + } => Ok(( + effect, + RulePhase::Send(SendRule { + eavesdrop, + send_broadcast, + send_destination, + send_destination_prefix, + send_error, + send_interface, + send_member, + send_path, + send_requested_reply, + send_type, + }), + )), + RawRuleAttributes { + eavesdrop: None, + group: None, + own, + own_prefix, + receive_error: None, + receive_interface: None, + receive_member: None, + receive_path: None, + receive_requested_reply: None, + receive_sender: None, + receive_type: None, + send_broadcast: None, + send_destination: None, + send_destination_prefix: None, + send_error: None, + send_interface: None, + send_member: None, + send_path: None, + send_requested_reply: None, + send_type: None, + user: None, + } => Ok((effect, RulePhase::Own(OwnRule { own, own_prefix }))), + RawRuleAttributes { + eavesdrop: None, + group, + own: None, + own_prefix: None, + receive_error: None, + receive_interface: None, + receive_member: None, + receive_path: None, + receive_requested_reply: None, + receive_sender: None, + receive_type: None, + send_broadcast: None, + send_destination: None, + send_destination_prefix: None, + send_error: None, + send_interface: None, + send_member: None, + send_path: None, + send_requested_reply: None, + send_type: None, + user, + } => Ok((effect, RulePhase::Connect(ConnectRule { group, user }))), + _ => Err(Error::RuleHasInvalidCombinationOfAttributes), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RuleEffect { + Allow, + Deny, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RulePhase { + Connect(ConnectRule), + Own(OwnRule), + Receive(ReceiveRule), + Send(SendRule), +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case", untagged)] +pub enum RuleMatch { + #[serde(rename = "*")] + Any, + One(String), +} +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum RuleMatchType { + #[serde(rename = "*")] + Any, + Error, + MethodCall, + MethodReturn, + Signal, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SendRule { + pub eavesdrop: Option, + pub send_broadcast: Option, + pub send_destination: Option, + pub send_destination_prefix: Option, + pub send_error: Option, + pub send_interface: Option, + pub send_member: Option, + pub send_path: Option, + pub send_requested_reply: Option, + pub send_type: Option, +} diff --git a/src/configuration/raw.rs b/src/configuration/raw.rs new file mode 100644 index 0000000..243c597 --- /dev/null +++ b/src/configuration/raw.rs @@ -0,0 +1,155 @@ +//! internal implementation details for handling configuration XML + +use std::{path::PathBuf, str::FromStr}; + +use serde::Deserialize; + +use super::{ + policy::{RuleMatch, RuleMatchType}, + ApparmorMode, Associate, IncludeElement, LimitElement, Principal, Type, +}; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) struct RawApparmor { + #[serde(rename = "@mode")] + pub mode: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default)] +pub(super) struct RawConfiguration { + pub allow_anonymous: Option<()>, + pub apparmor: Option, + pub auth: Vec, + pub fork: Option<()>, + pub include: Vec, + pub includedir: Vec, + pub keep_umask: Option<()>, + pub limit: Vec, + pub listen: Vec, + pub pidfile: Option, + pub policy: Vec, + pub selinux: Option, + pub servicedir: Vec, + pub servicehelper: Option, + pub standard_session_servicedirs: Option<()>, + pub standard_system_servicedirs: Option<()>, + pub syslog: Option<()>, + pub r#type: Vec, + pub user: Vec, +} +impl FromStr for RawConfiguration { + type Err = quick_xml::DeError; + + fn from_str(s: &str) -> Result { + // TODO: validate expected DOCTYPE + // TODO: validate expected root element (busconfig) + quick_xml::de::from_str(s) + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) struct RawPolicy { + #[serde(rename = "@at_console")] + pub at_console: Option, + #[serde(rename = "@context")] + pub context: Option, + #[serde(rename = "@group")] + pub group: Option, + #[serde(default, rename = "$value")] + pub rules: Vec, + #[serde(rename = "@user")] + pub user: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) enum RawPolicyContext { + Default, + Mandatory, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) enum RawRule { + Allow(RawRuleAttributes), + Deny(RawRuleAttributes), +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, rename_all = "lowercase")] +pub(super) struct RawRuleAttributes { + #[serde(rename = "@send_interface")] + pub send_interface: Option, + #[serde(rename = "@send_member")] + pub send_member: Option, + #[serde(rename = "@send_error")] + pub send_error: Option, + #[serde(rename = "@send_broadcast")] + pub send_broadcast: Option, + #[serde(rename = "@send_destination")] + pub send_destination: Option, + #[serde(rename = "@send_destination_prefix")] + pub send_destination_prefix: Option, + #[serde(rename = "@send_type")] + pub send_type: Option, + #[serde(rename = "@send_path")] + pub send_path: Option, + #[serde(rename = "@receive_interface")] + pub receive_interface: Option, + #[serde(rename = "@receive_member")] + pub receive_member: Option, + #[serde(rename = "@receive_error")] + pub receive_error: Option, + #[serde(rename = "@receive_sender")] + pub receive_sender: Option, + #[serde(rename = "@receive_type")] + pub receive_type: Option, + #[serde(rename = "@receive_path")] + pub receive_path: Option, + #[serde(rename = "@send_requested_reply")] + pub send_requested_reply: Option, + #[serde(rename = "@receive_requested_reply")] + pub receive_requested_reply: Option, + #[serde(rename = "@eavesdrop")] + pub eavesdrop: Option, + #[serde(rename = "@own")] + pub own: Option, + #[serde(rename = "@own_prefix")] + pub own_prefix: Option, + #[serde(rename = "@user")] + pub user: Option, + #[serde(rename = "@group")] + pub group: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default)] +pub(super) struct RawSelinux { + pub associate: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) struct RawTypeElement { + #[serde(rename = "$text")] + pub text: Type, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) struct RawUserElement { + #[serde(rename = "$text")] + pub text: Principal, +} + +// reuse this between Vec fields, +// except those with field-specific attributes +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) struct RawPathBufElement { + #[serde(rename = "$text")] + pub text: PathBuf, +} diff --git a/src/lib.rs b/src/lib.rs index 07a5f33..67f0bd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod bus; +pub mod configuration; pub mod fdo; pub mod match_rules; pub mod name_registry; diff --git a/tests/configuration.rs b/tests/configuration.rs new file mode 100644 index 0000000..2751997 --- /dev/null +++ b/tests/configuration.rs @@ -0,0 +1,37 @@ +use std::{ + ffi::OsStr, + fs::{read_dir, read_to_string, DirEntry}, + path::PathBuf, + str::FromStr, +}; + +use busd::configuration::Configuration; + +#[test] +fn find_and_parse_real_configuration_files() { + let mut file_paths = vec![ + PathBuf::from("/usr/share/dbus-1/session.conf"), + PathBuf::from("/usr/share/dbus-1/system.conf"), + ]; + + for dir_path in ["/usr/share/dbus-1/session.d", "/usr/share/dbus-1/system.d"] { + if let Ok(rd) = read_dir(dir_path) { + file_paths.extend( + rd.flatten() + .map(|fp| DirEntry::path(&fp)) + .filter(|fp| fp.extension() == Some(OsStr::new("conf"))), + ); + } + } + + for file_path in file_paths { + let configuration_text = match read_to_string(&file_path) { + Ok(ok) => ok, + Err(_) => continue, + }; + + Configuration::from_str(&configuration_text).unwrap_or_else(|err| { + panic!("should correctly parse {}: {err:?}", file_path.display()) + }); + } +}