use std::collections::{HashMap, HashSet};
use std::os::unix::fs::MetadataExt;
use crate::guard::cgfs;
use crate::process::ProcessInfo;
use crate::CgroupManager;
use common::{AppRule, Config, Limit};
pub struct CompiledRule {
pub name: String,
pub match_exe: Vec<String>,
pub limit: Limit,
pub cgroup: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleAction {
EnsureCgroup { rule: String },
AddPid { rule: String, pid: u32 },
TeardownEmpty { rule: String },
}
pub fn cgroup_name_for(rule_name: &str) -> String {
format!("app-{}", rule_name.replace(['/', ' '], "_"))
}
impl CompiledRule {
fn compile(name: &str, rule: &AppRule) -> Option<Self> {
match rule.to_limit() {
Ok(limit) => Some(CompiledRule {
name: name.to_string(),
match_exe: rule.match_exe.clone(),
limit,
cgroup: cgroup_name_for(name),
}),
Err(e) => {
tracing::warn!(rule = name, error = %e, "skipping rule with invalid limits");
None
}
}
}
fn matches(&self, proc: &ProcessInfo) -> bool {
self.match_exe.iter().any(|want| {
proc.name == *want
|| proc
.executable
.as_ref()
.and_then(|exe| exe.file_name())
.and_then(|n| n.to_str())
.map(|n| n == want)
.unwrap_or(false)
})
}
}
pub fn plan(
rule: &CompiledRule,
procs: &[ProcessInfo],
already_placed: &[u32],
cgroup_exists: bool,
held: bool,
) -> Vec<RuleAction> {
if held {
return Vec::new();
}
let matches: Vec<&ProcessInfo> = procs.iter().filter(|p| rule.matches(p)).collect();
if matches.is_empty() {
return if cgroup_exists && already_placed.is_empty() {
vec![RuleAction::TeardownEmpty {
rule: rule.name.clone(),
}]
} else {
Vec::new()
};
}
let mut actions = vec![RuleAction::EnsureCgroup {
rule: rule.name.clone(),
}];
for p in matches {
if !already_placed.contains(&p.pid) {
actions.push(RuleAction::AddPid {
rule: rule.name.clone(),
pid: p.pid,
});
}
}
actions
}
pub fn needs_ensure(recorded_inode: Option<u64>, current_inode: Option<u64>) -> bool {
current_inode.is_none() || recorded_inode != current_inode
}
pub struct RulesEnforcer {
rules: Vec<CompiledRule>,
ensured: HashMap<String, u64>,
}
impl RulesEnforcer {
pub fn new(cfg: &Config) -> Self {
let rules = cfg
.rules
.iter()
.filter_map(|(name, rule)| CompiledRule::compile(name, rule))
.collect();
Self {
rules,
ensured: HashMap::new(),
}
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
pub fn reconcile(
&mut self,
mgr: &CgroupManager,
procs: &[ProcessInfo],
held_cgroups: &[String],
) -> Vec<RuleAction> {
let rlm_rel = crate::guard::sampler::strip_cgroup_root(mgr.base_path());
let held: HashSet<&str> = held_cgroups.iter().map(String::as_str).collect();
let mut applied = Vec::new();
for rule in &self.rules {
let mut blocked = false;
if let Some(rel) = &rlm_rel {
let cg_path = format!("{rel}/{}", rule.cgroup);
if held.contains(cg_path.as_str()) {
blocked = true;
}
if !blocked && cgfs::read_frozen(&cg_path) == Some(true) {
blocked = true;
}
}
let placed = mgr.pids_in_cgroup(&rule.cgroup);
let exists = !placed.is_empty() || mgr.cgroup_exists(&rule.cgroup);
for action in plan(rule, procs, &placed, exists, blocked) {
match apply(mgr, rule, &action, &mut self.ensured) {
Ok(true) => applied.push(action),
Ok(false) => {}
Err(e) => tracing::warn!(?action, error = %e, "rules: action failed"),
}
}
}
applied
}
}
fn cgroup_inode(mgr: &CgroupManager, cgroup: &str) -> Option<u64> {
std::fs::metadata(mgr.base_path().join(cgroup))
.ok()
.map(|m| m.ino())
}
fn apply(
mgr: &CgroupManager,
rule: &CompiledRule,
action: &RuleAction,
ensured: &mut HashMap<String, u64>,
) -> common::Result<bool> {
match action {
RuleAction::EnsureCgroup { .. } => {
let current = cgroup_inode(mgr, &rule.cgroup);
if !needs_ensure(ensured.get(&rule.cgroup).copied(), current) {
return Ok(false);
}
let prepared = mgr.prepare_cgroup(&rule.cgroup, &rule.limit)?;
for w in &prepared.warnings {
tracing::warn!(cgroup = %rule.cgroup, "{w}");
}
match cgroup_inode(mgr, &rule.cgroup) {
Some(ino) => ensured.insert(rule.cgroup.clone(), ino),
None => ensured.remove(&rule.cgroup),
};
Ok(true)
}
RuleAction::AddPid { pid, .. } => {
let path = mgr.base_path().join(&rule.cgroup);
mgr.add_to_cgroup(&path, *pid).map(|()| true)
}
RuleAction::TeardownEmpty { .. } => {
ensured.remove(&rule.cgroup);
mgr.cleanup_cgroup(&rule.cgroup).map(|()| true)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn rule(name: &str, exes: &[&str]) -> CompiledRule {
CompiledRule {
name: name.to_string(),
match_exe: exes.iter().map(|s| s.to_string()).collect(),
limit: Limit::default(),
cgroup: cgroup_name_for(name),
}
}
fn proc(pid: u32, name: &str, exe: Option<&str>) -> ProcessInfo {
ProcessInfo {
pid,
name: name.to_string(),
executable: exe.map(PathBuf::from),
..Default::default()
}
}
#[test]
fn ensure_only_when_new_or_recreated() {
assert!(needs_ensure(None, None), "missing cgroup");
assert!(needs_ensure(None, Some(7)), "never written");
assert!(needs_ensure(Some(7), Some(9)), "recreated with a new inode");
assert!(needs_ensure(Some(7), None), "removed since");
assert!(!needs_ensure(Some(7), Some(7)), "unchanged: no writes");
}
#[test]
fn cgroup_name_matches_cli_scheme() {
assert_eq!(cgroup_name_for("firefox"), "app-firefox");
assert_eq!(cgroup_name_for("my app/x"), "app-my_app_x");
}
#[test]
fn matches_by_comm_or_exe_basename() {
let r = rule("firefox", &["firefox"]);
assert!(r.matches(&proc(1, "firefox", None)));
assert!(r.matches(&proc(2, "Web Content", Some("/usr/lib/firefox/firefox"))));
assert!(!r.matches(&proc(3, "code", Some("/usr/bin/code"))));
}
#[test]
fn plan_ensures_and_adds_unplaced_matches() {
let r = rule("firefox", &["firefox"]);
let procs = vec![proc(10, "firefox", None), proc(11, "firefox", None)];
let actions = plan(&r, &procs, &[], false, false);
assert_eq!(
actions[0],
RuleAction::EnsureCgroup {
rule: "firefox".into()
}
);
assert!(actions.contains(&RuleAction::AddPid {
rule: "firefox".into(),
pid: 10
}));
assert!(actions.contains(&RuleAction::AddPid {
rule: "firefox".into(),
pid: 11
}));
}
#[test]
fn plan_is_idempotent_when_all_placed() {
let r = rule("firefox", &["firefox"]);
let procs = vec![proc(10, "firefox", None)];
let actions = plan(&r, &procs, &[10], true, false);
assert_eq!(
actions,
vec![RuleAction::EnsureCgroup {
rule: "firefox".into()
}]
);
}
#[test]
fn plan_adds_only_new_pid() {
let r = rule("firefox", &["firefox"]);
let procs = vec![proc(10, "firefox", None), proc(12, "firefox", None)];
let actions = plan(&r, &procs, &[10], true, false);
assert_eq!(
actions,
vec![
RuleAction::EnsureCgroup {
rule: "firefox".into()
},
RuleAction::AddPid {
rule: "firefox".into(),
pid: 12
},
]
);
}
#[test]
fn plan_teardown_only_when_empty_and_present() {
let r = rule("firefox", &["firefox"]);
let actions = plan(&r, &[proc(1, "code", None)], &[], true, false);
assert_eq!(
actions,
vec![RuleAction::TeardownEmpty {
rule: "firefox".into()
}]
);
}
#[test]
fn plan_does_not_evict_occupied_cgroup_with_no_matches() {
let r = rule("firefox", &["firefox"]);
let actions = plan(&r, &[proc(1, "code", None)], &[999], true, false);
assert!(
actions.is_empty(),
"must not evict an occupied cgroup: {actions:?}"
);
}
#[test]
fn plan_noop_when_no_matches_and_no_cgroup() {
let r = rule("firefox", &["firefox"]);
let actions = plan(&r, &[proc(1, "code", None)], &[], false, false);
assert!(actions.is_empty());
}
#[test]
fn plan_produces_no_actions_when_rule_cgroup_is_guard_held() {
let r = rule("firefox", &["firefox"]);
let procs = vec![proc(10, "firefox", None), proc(11, "firefox", None)];
let actions = plan(&r, &procs, &[10], true, true);
assert!(
actions.is_empty(),
"a guard-held rule cgroup must get no actions: {actions:?}"
);
}
#[test]
fn plan_unheld_rule_is_unaffected() {
let r = rule("firefox", &["firefox"]);
let procs = vec![proc(10, "firefox", None), proc(11, "firefox", None)];
let actions = plan(&r, &procs, &[10], true, false);
assert!(actions.contains(&RuleAction::EnsureCgroup {
rule: "firefox".into()
}));
assert!(actions.contains(&RuleAction::AddPid {
rule: "firefox".into(),
pid: 11
}));
}
}