use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RuleAction {
Allow,
Disallow,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
pub struct OsCondition {
pub name: Option<String>,
pub arch: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct FeatureSet {
pub is_demo_user: Option<bool>,
pub has_custom_resolution: Option<bool>,
pub has_quick_plays_support: Option<bool>,
pub is_quick_play_singleplayer: Option<bool>,
pub is_quick_play_multiplayer: Option<bool>,
pub is_quick_play_realms: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Rule {
pub action: RuleAction,
pub os: Option<OsCondition>,
pub features: Option<FeatureSet>,
}
pub struct RuleContext<'a> {
pub os_name: &'a str,
pub os_version: &'a str,
pub arch: &'a str,
pub features: &'a FeatureSet,
}
pub fn evaluate(rules: &[Rule], ctx: &RuleContext) -> bool {
if rules.is_empty() {
return true;
}
let mut allowed = false;
for rule in rules {
if rule_matches(rule, ctx) {
allowed = matches!(rule.action, RuleAction::Allow);
}
}
allowed
}
fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool {
if let Some(os) = &rule.os {
if let Some(name) = &os.name
&& name != ctx.os_name
{
return false;
}
if let Some(arch) = &os.arch
&& arch != ctx.arch
{
return false;
}
if let Some(pattern) = &os.version
&& !os_version_matches(pattern, ctx.os_version)
{
return false;
}
}
if let Some(required) = &rule.features
&& !features_match(required, ctx.features)
{
return false;
}
true
}
fn os_version_matches(pattern: &str, host_version: &str) -> bool {
if host_version.is_empty() {
return false;
}
let needle = pattern
.trim_start_matches('^')
.trim_end_matches('$')
.trim_end_matches('.')
.trim_end_matches('\\');
host_version.contains(needle)
}
fn features_match(required: &FeatureSet, current: &FeatureSet) -> bool {
let pairs = [
(required.is_demo_user, current.is_demo_user),
(required.has_custom_resolution, current.has_custom_resolution),
(
required.has_quick_plays_support,
current.has_quick_plays_support,
),
(
required.is_quick_play_singleplayer,
current.is_quick_play_singleplayer,
),
(
required.is_quick_play_multiplayer,
current.is_quick_play_multiplayer,
),
(required.is_quick_play_realms, current.is_quick_play_realms),
];
pairs.iter().all(|(req, cur)| match req {
Some(want) => cur.unwrap_or(false) == *want,
None => true,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn linux_ctx<'a>(features: &'a FeatureSet) -> RuleContext<'a> {
RuleContext {
os_name: "linux",
os_version: "6.0",
arch: "x86_64",
features,
}
}
#[test]
fn empty_rules_allow() {
let features = FeatureSet::default();
let ctx = linux_ctx(&features);
assert!(evaluate(&[], &ctx));
}
#[test]
fn single_allow_matching_os_allows() {
let rules = vec![Rule {
action: RuleAction::Allow,
os: Some(OsCondition {
name: Some("linux".into()),
arch: None,
..Default::default()
}),
features: None,
}];
let features = FeatureSet::default();
let ctx = linux_ctx(&features);
assert!(evaluate(&rules, &ctx));
}
#[test]
fn single_disallow_matching_os_disallows() {
let rules = vec![Rule {
action: RuleAction::Disallow,
os: Some(OsCondition {
name: Some("linux".into()),
arch: None,
..Default::default()
}),
features: None,
}];
let features = FeatureSet::default();
let ctx = linux_ctx(&features);
assert!(!evaluate(&rules, &ctx));
}
#[test]
fn allow_without_os_match_disallows_by_default() {
let rules = vec![Rule {
action: RuleAction::Allow,
os: Some(OsCondition {
name: Some("windows".into()),
arch: None,
..Default::default()
}),
features: None,
}];
let features = FeatureSet::default();
let ctx = linux_ctx(&features);
assert!(!evaluate(&rules, &ctx));
}
#[test]
fn last_matching_rule_wins_when_allow_then_disallow() {
let rules = vec![
Rule {
action: RuleAction::Allow,
os: None,
features: None,
},
Rule {
action: RuleAction::Disallow,
os: Some(OsCondition {
name: Some("osx".into()),
arch: None,
..Default::default()
}),
features: None,
},
];
let features = FeatureSet::default();
let osx_ctx = RuleContext {
os_name: "osx",
os_version: "6.0",
arch: "x86_64",
features: &features,
};
assert!(!evaluate(&rules, &osx_ctx));
let lin = linux_ctx(&features);
assert!(evaluate(&rules, &lin));
}
#[test]
fn last_matching_rule_wins_when_disallow_then_allow() {
let rules = vec![
Rule {
action: RuleAction::Disallow,
os: None,
features: None,
},
Rule {
action: RuleAction::Allow,
os: Some(OsCondition {
name: Some("linux".into()),
arch: None,
..Default::default()
}),
features: None,
},
];
let features = FeatureSet::default();
let ctx = linux_ctx(&features);
assert!(evaluate(&rules, &ctx));
}
#[test]
fn arch_mismatch_blocks_rule() {
let rules = vec![Rule {
action: RuleAction::Allow,
os: Some(OsCondition {
name: Some("linux".into()),
arch: Some("arm64".into()),
version: None,
}),
features: None,
}];
let features = FeatureSet::default();
let ctx = linux_ctx(&features);
assert!(!evaluate(&rules, &ctx));
}
#[test]
fn feature_required_true_matches_when_ctx_true() {
let rules = vec![Rule {
action: RuleAction::Allow,
os: None,
features: Some(FeatureSet {
is_demo_user: Some(true),
..Default::default()
}),
}];
let demo_features = FeatureSet {
is_demo_user: Some(true),
..Default::default()
};
let ctx = RuleContext {
os_name: "linux",
os_version: "6.0",
arch: "x86_64",
features: &demo_features,
};
assert!(evaluate(&rules, &ctx));
}
#[test]
fn feature_required_true_blocks_when_ctx_false() {
let rules = vec![Rule {
action: RuleAction::Allow,
os: None,
features: Some(FeatureSet {
is_demo_user: Some(true),
..Default::default()
}),
}];
let features = FeatureSet::default();
let ctx = linux_ctx(&features);
assert!(!evaluate(&rules, &ctx));
}
#[test]
fn no_os_no_features_rule_always_matches() {
let rules = vec![Rule {
action: RuleAction::Allow,
os: None,
features: None,
}];
let features = FeatureSet::default();
let ctx = linux_ctx(&features);
assert!(evaluate(&rules, &ctx));
}
#[test]
fn os_version_pattern_matches_against_host() {
let rules = vec![Rule {
action: RuleAction::Allow,
os: Some(OsCondition {
name: Some("osx".into()),
arch: None,
version: Some("^10\\.".into()),
}),
features: None,
}];
let features = FeatureSet::default();
let ctx_match = RuleContext {
os_name: "osx",
os_version: "10.15.7",
arch: "x86_64",
features: &features,
};
assert!(evaluate(&rules, &ctx_match));
let ctx_mismatch = RuleContext {
os_name: "osx",
os_version: "13.2.1",
arch: "x86_64",
features: &features,
};
assert!(!evaluate(&rules, &ctx_mismatch));
}
#[test]
fn os_version_pattern_does_not_match_when_host_unknown() {
let rules = vec![Rule {
action: RuleAction::Allow,
os: Some(OsCondition {
name: Some("windows".into()),
arch: None,
version: Some("^10\\.".into()),
}),
features: None,
}];
let features = FeatureSet::default();
let ctx = RuleContext {
os_name: "windows",
os_version: "",
arch: "x86_64",
features: &features,
};
assert!(!evaluate(&rules, &ctx));
}
#[test]
fn rule_deserializes_from_mojang_json() {
let json = r#"{
"action": "allow",
"os": { "name": "osx" }
}"#;
let rule: Rule = serde_json::from_str(json).unwrap();
assert_eq!(rule.action, RuleAction::Allow);
assert_eq!(
rule.os.as_ref().and_then(|o| o.name.as_deref()),
Some("osx")
);
assert!(rule.os.as_ref().and_then(|o| o.arch.as_ref()).is_none());
assert!(rule.features.is_none());
}
#[test]
fn rule_deserializes_with_features() {
let json = r#"{
"action": "allow",
"features": { "is_demo_user": true }
}"#;
let rule: Rule = serde_json::from_str(json).unwrap();
assert_eq!(rule.action, RuleAction::Allow);
assert!(rule.os.is_none());
assert_eq!(
rule.features.as_ref().and_then(|f| f.is_demo_user),
Some(true)
);
}
}