mod confirm;
mod doctor;
mod guard_unit;
mod run;
use clap::{Parser, Subcommand};
use common::{build_limit, format_bytes, Config, Error, Limit, Result};
use confirm::Confirm;
use rlm_core::process::{self, current_uid, ProcessInfo};
use rlm_core::CgroupManager;
use std::collections::HashSet;
use std::io::{self, IsTerminal};
use std::process::ExitCode;
fn parse_pid_list(pids_str: &str) -> Result<Vec<u32>> {
pids_str
.split(',')
.map(|s| {
s.trim()
.parse::<u32>()
.map_err(|_| Error::InvalidArgs(format!("invalid PID: {}", s.trim())))
})
.collect()
}
fn resolve_name_pids(name: &str, my_uid: u32) -> Result<Vec<u32>> {
if my_uid == 0 {
return process::find_by_name(name);
}
let matches = process::find_by_name_for_uid(name, my_uid)?;
if matches.other_users > 0 {
eprintln!(
"note: skipped {} matching process(es) owned by other users",
matches.other_users
);
}
if matches.pids.is_empty() {
return Err(Error::ProcessNameNotFound(name.to_string()));
}
Ok(matches.pids)
}
fn resolve_name_targets(name: &str, my_uid: u32) -> Result<Vec<ProcessInfo>> {
resolve_name_pids(name, my_uid)?
.into_iter()
.map(|pid| process::read_process(pid).ok_or(Error::ProcessNotFound(pid)))
.collect()
}
fn filter_own_uid(all: Vec<ProcessInfo>, my_uid: u32) -> Vec<ProcessInfo> {
if my_uid == 0 {
return all;
}
let (mine, other): (Vec<_>, Vec<_>) = all.into_iter().partition(|p| p.uid == my_uid);
if !other.is_empty() {
eprintln!(
"note: skipped {} matching process(es) owned by other users",
other.len()
);
}
mine
}
fn needs_cgroup_manager(cmd: &Commands) -> bool {
match cmd {
Commands::Status
| Commands::Limit { .. }
| Commands::Unlimit { .. }
| Commands::Run { .. } => true,
Commands::Rule { .. }
| Commands::Profiles
| Commands::Export { .. }
| Commands::Import { .. }
| Commands::Doctor
| Commands::Guard { .. } => false,
}
}
fn validate_limit_args(save: bool, application: Option<&str>) -> Result<()> {
if save && application.is_none() {
return Err(Error::InvalidArgs(
"--save requires --application (there is nothing else to key the saved rule by)".into(),
));
}
Ok(())
}
fn config_for_limit(
loaded: Result<Config>,
force: bool,
path: &str,
) -> std::result::Result<(Config, Option<String>), String> {
match loaded {
Ok(c) => Ok((c, None)),
Err(e) => {
let line = rlm_core::guard::report::config_error_line(path, &e);
if force {
Ok((
Config::default(),
Some(format!(
"warning: config {line}\n continuing without your settings because of --force"
)),
))
} else {
Err(format!(
"error: config {line}\n rlm limit reads your guard protect list from it. Fix the file, or pass --force to limit without it."
))
}
}
}
}
fn check_target(
p: &ProcessInfo,
my_uid: u32,
protect: &HashSet<String>,
force: bool,
) -> Result<()> {
if p.uid != my_uid && my_uid != 0 {
return Err(Error::InvalidArgs(format!(
"process {} ({}) belongs to uid {}; rlm only limits your own processes",
p.pid,
p.display_name(),
p.uid
)));
}
if !force && common::is_protected(protect, &p.name, p.exe_name()) {
return Err(Error::InvalidArgs(format!(
"process {} ({}) is on the guard protect list (desktop session, shells, audio); pass --force to limit it anyway",
p.pid,
p.display_name()
)));
}
Ok(())
}
fn unlimit_pid_target(pid: u32, found: Option<&str>) -> Result<String> {
match found {
None => Err(Error::InvalidArgs(format!("pid {pid} is not limited by rlm"))),
Some(cgroup) if cgroup == format!("pid-{pid}") => Ok(cgroup.to_string()),
Some(cgroup) => Err(Error::InvalidArgs(format!(
"pid {pid} shares cgroup '{cgroup}' with other processes; remove the whole group with: rlm unlimit --cgroup {cgroup}"
))),
}
}
fn confirm_or_exit(pids: &[u32], action: &str, yes: bool) -> Result<Option<ExitCode>> {
let interactive = io::stdin().is_terminal();
let mut input = io::stdin().lock();
let mut out = io::stdout();
match confirm::confirm_batch(pids, action, yes, interactive, &mut input, &mut out) {
Confirm::Proceed => Ok(None),
Confirm::Cancelled => {
eprintln!("cancelled");
Ok(Some(ExitCode::from(1)))
}
Confirm::NeedsYes => Err(Error::InvalidArgs(format!(
"refusing to change {} processes without confirmation because stdin is not a terminal; pass --yes",
pids.len()
))),
}
}
#[derive(Parser)]
#[command(name = "rlm", bin_name = "rlm")]
#[command(about = "Resource Limit Manager - control process resource usage via cgroups v2")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Limit {
#[arg(long, conflicts_with_all = ["name", "application", "all_pids"])]
pid: Option<u32>,
#[arg(long, conflicts_with_all = ["pid", "application", "all_pids"])]
name: Option<String>,
#[arg(long, conflicts_with_all = ["pid", "name", "all_pids"])]
application: Option<String>,
#[arg(long, conflicts_with_all = ["pid", "name", "application"])]
all_pids: Option<String>,
#[arg(long, value_name = "SIZE")]
memory: Option<String>,
#[arg(long, value_name = "PERCENT")]
cpu: Option<String>,
#[arg(long, value_name = "SIZE")]
io_read: Option<String>,
#[arg(long, value_name = "SIZE")]
io_write: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long, requires = "application")]
save: bool,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long)]
force: bool,
},
Unlimit {
#[arg(long, conflicts_with_all = ["name", "application", "cgroup"])]
pid: Option<u32>,
#[arg(long, conflicts_with_all = ["pid", "application", "cgroup"])]
name: Option<String>,
#[arg(long, conflicts_with_all = ["pid", "name", "cgroup"])]
application: Option<String>,
#[arg(long, conflicts_with_all = ["pid", "name", "application"])]
cgroup: Option<String>,
#[arg(long)]
forget: bool,
#[arg(long, short = 'y')]
yes: bool,
},
Rule {
#[command(subcommand)]
action: RuleAction,
},
Run {
#[arg(long, short)]
profile: Option<String>,
#[arg(long, value_name = "SIZE")]
memory: Option<String>,
#[arg(long, value_name = "PERCENT")]
cpu: Option<String>,
#[arg(long, value_name = "SIZE")]
io_read: Option<String>,
#[arg(long, value_name = "SIZE")]
io_write: Option<String>,
#[arg(trailing_var_arg = true, required = true)]
command: Vec<String>,
},
Profiles,
Export {
#[arg(value_name = "FILE")]
file: String,
},
Import {
#[arg(value_name = "FILE")]
file: String,
#[arg(long)]
overwrite: bool,
},
Status,
Doctor,
Guard {
#[command(subcommand)]
action: GuardAction,
},
}
#[derive(Subcommand)]
enum GuardAction {
Status,
Enable,
Disable,
Test,
History {
#[arg(short = 'n', long, default_value_t = 20)]
lines: usize,
},
}
#[derive(Subcommand)]
enum RuleAction {
List,
Remove {
name: String,
},
}
fn main() -> ExitCode {
rlm_core::logging::init(tracing::Level::WARN);
match run() {
Ok(code) => code,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<ExitCode> {
let cli = Cli::parse();
let manager = if needs_cgroup_manager(&cli.command) {
Some(CgroupManager::new()?)
} else {
None
};
match cli.command {
Commands::Limit {
pid,
name,
application,
all_pids,
memory,
cpu,
io_read,
io_write,
dry_run,
save,
yes,
force,
} => {
let manager = manager.as_ref().expect("checked by needs_cgroup_manager");
validate_limit_args(save, application.as_deref())?;
let limit = build_limit(
memory.as_deref(),
cpu.as_deref(),
io_read.as_deref(),
io_write.as_deref(),
)?;
if limit.memory.is_none() && limit.cpu.is_none() && limit.io.is_none() {
return Err(Error::InvalidArgs(
"specify at least one limit (--memory, --cpu, --io-read, --io-write)".into(),
));
}
let save_app = if save { application.clone() } else { None };
let my_uid = current_uid();
let config = match config_for_limit(
Config::load(),
force,
&rlm_core::guard::report::config_error_path(),
) {
Ok((config, warning)) => {
if let Some(w) = warning {
eprintln!("{w}");
}
config
}
Err(msg) => {
eprintln!("{msg}");
return Ok(ExitCode::FAILURE);
}
};
let protect = common::protect_set(&config.guard.selection.protect);
let (targets, cgroup_name, is_shared) = if let Some(app_name) = application {
let all = process::find_all_by_executable(&app_name)?;
let targets = filter_own_uid(all, my_uid);
if targets.is_empty() {
return Err(Error::ProcessNameNotFound(app_name));
}
let cgroup_name = format!("app-{}", app_name.replace(['/', ' '], "_"));
println!(
"Found {} process(es) for application '{}'",
targets.len(),
app_name
);
(targets, cgroup_name, true)
} else if let Some(pids_str) = all_pids {
let pids = parse_pid_list(&pids_str)?;
if pids.is_empty() {
return Err(Error::InvalidArgs("no valid PIDs specified".into()));
}
let targets: Vec<ProcessInfo> = pids
.iter()
.map(|&pid| process::read_process(pid).ok_or(Error::ProcessNotFound(pid)))
.collect::<Result<_>>()?;
let cgroup_name = format!("multi-{}", pids[0]);
(targets, cgroup_name, true)
} else if let Some(name) = name {
let targets = resolve_name_targets(&name, my_uid)?;
(targets, String::new(), false)
} else if let Some(pid) = pid {
let p = process::read_process(pid).ok_or(Error::ProcessNotFound(pid))?;
(vec![p], String::new(), false)
} else {
return Err(Error::InvalidArgs(
"specify --pid, --name, --application, or --all-pids".into(),
));
};
for p in &targets {
check_target(p, my_uid, &protect, force)?;
}
let pids: Vec<u32> = targets.iter().map(|p| p.pid).collect();
if dry_run {
println!(
"Dry run - would apply limits to {} process(es):",
targets.len()
);
for p in &targets {
println!(" {}: {}", p.pid, p.display_name());
}
if is_shared {
println!("\nnote: all processes share these limits (one combined pool)");
} else {
println!("\nLimits (per process):");
}
if let Some(ref mem) = limit.memory {
println!(" Memory: {}", format_bytes(mem.bytes()));
}
if let Some(ref cpu) = limit.cpu {
println!(" CPU: {}%", cpu.percent());
}
if let Some(ref io) = limit.io {
if let Some(r) = io.read_bps {
println!(" I/O Read: {}/s", format_bytes(r));
}
if let Some(w) = io.write_bps {
println!(" I/O Write: {}/s", format_bytes(w));
}
}
return Ok(ExitCode::SUCCESS);
}
if let Some(code) = confirm_or_exit(&pids, "Limit", yes)? {
return Ok(code);
}
if is_shared {
for w in manager.apply_limit_to_multiple(&pids, &limit, &cgroup_name)? {
eprintln!("warning: {w}");
}
println!(
"Applied shared limits to {} process(es) in cgroup '{}'",
pids.len(),
cgroup_name
);
println!("note: all processes share these limits (one combined pool)");
if let Some(app) = save_app {
let mut config = Config::load()?;
config.add_rule(
&app,
common::AppRule {
match_exe: vec![app.clone()],
memory: memory.clone(),
cpu: cpu.clone(),
io_read: io_read.clone(),
io_write: io_write.clone(),
},
);
config.save()?;
println!("Saved persistent rule '{app}'");
if is_guard_active() {
println!(
" note: restart the daemon to load it: systemctl --user restart rlm-guard"
);
} else {
println!(" note: enable the daemon to enforce it: rlm guard enable");
}
}
} else {
for pid in &pids {
for w in manager.apply_limit(*pid, &limit)? {
eprintln!("warning: {w}");
}
println!("applied limits to pid {pid}");
}
}
}
Commands::Unlimit {
pid,
name,
application,
cgroup,
forget,
yes,
} => {
let manager = manager.as_ref().expect("checked by needs_cgroup_manager");
if let Some(cgroup_name) = cgroup {
if !manager.cgroup_exists(&cgroup_name) {
return Err(Error::InvalidArgs(format!(
"no rlm cgroup named '{cgroup_name}' (see: rlm status)"
)));
}
manager.remove_application_limit(&cgroup_name)?;
println!("removed limits from cgroup '{}'", cgroup_name);
} else if let Some(app_name) = application {
let cgroup_name = format!("app-{}", app_name.replace(['/', ' '], "_"));
if !manager.cgroup_exists(&cgroup_name) {
return Err(Error::InvalidArgs(format!(
"no rlm cgroup named '{cgroup_name}' (see: rlm status)"
)));
}
manager.remove_application_limit(&cgroup_name)?;
println!("removed limits from application '{}'", app_name);
if forget {
let mut config = Config::load()?;
if config.remove_rule(&app_name) {
config.save()?;
println!("forgot persistent rule '{}'", app_name);
}
} else {
let config = Config::load()?;
if config.rules.contains_key(&app_name) {
println!(
" note: persistent rule '{}' still saved (rlm-guard will re-apply it); use --forget to delete it",
app_name
);
}
}
} else if let Some(name) = name {
let my_uid = current_uid();
let pids = resolve_name_pids(&name, my_uid)?;
if let Some(code) = confirm_or_exit(&pids, "Unlimit", yes)? {
return Ok(code);
}
let mut removed = 0usize;
for pid in &pids {
match unlimit_pid_target(*pid, manager.find_cgroup_for_pid(*pid).as_deref()) {
Ok(cgroup_name) => {
manager.remove_application_limit(&cgroup_name)?;
println!("removed limits from pid {pid}");
removed += 1;
}
Err(e) => eprintln!("warning: {e}"),
}
}
if removed == 0 {
return Err(Error::InvalidArgs(format!(
"no process matching '{name}' is limited by rlm"
)));
}
} else if let Some(pid) = pid {
let cgroup_name =
unlimit_pid_target(pid, manager.find_cgroup_for_pid(pid).as_deref())?;
manager.remove_application_limit(&cgroup_name)?;
println!("removed limits from pid {pid}");
} else {
return Err(Error::InvalidArgs(
"specify --pid, --name, --application, or --cgroup".into(),
));
}
}
Commands::Run {
profile,
memory,
cpu,
io_read,
io_write,
command,
} => {
let manager = manager.as_ref().expect("checked by needs_cgroup_manager");
let base = match profile {
Some(profile_name) => {
let config = Config::load()?;
let p = config.get_profile(&profile_name).ok_or_else(|| {
Error::Config(format!(
"profile '{profile_name}' not found (see: rlm profiles)"
))
})?;
p.to_limit()?
}
None => Limit::default(),
};
let flags = build_limit(
memory.as_deref(),
cpu.as_deref(),
io_read.as_deref(),
io_write.as_deref(),
)?;
let limit = base.overlay(&flags);
if limit.is_empty() {
return Err(Error::InvalidArgs(
"specify --profile or at least one limit".into(),
));
}
return run::run_with_limits(manager, &limit, &command);
}
Commands::Profiles => {
let config = Config::load()?;
let all_profiles = config.all_profiles();
println!(
"{:<15} {:>10} {:>10} {:>10} {:>10}",
"NAME", "MEMORY", "CPU", "IO_READ", "IO_WRITE"
);
println!("{}", "-".repeat(60));
let mut names: Vec<_> = all_profiles.keys().collect();
names.sort();
for name in names {
let profile = &all_profiles[name];
let mem = profile.memory.as_deref().unwrap_or("-");
let cpu = profile.cpu.as_deref().unwrap_or("-");
let ior = profile.io_read.as_deref().unwrap_or("-");
let iow = profile.io_write.as_deref().unwrap_or("-");
println!(
"{:<15} {:>10} {:>10} {:>10} {:>10}",
name, mem, cpu, ior, iow
);
}
if config.profiles.is_empty() {
println!("\n(showing built-in presets; add custom profiles to ~/.config/rlm/config.yaml)");
}
}
Commands::Export { file } => {
let config = Config::load()?;
let profiles = config.profiles.clone();
if profiles.is_empty() {
println!(
"no user-defined profiles to export (built-in presets are always available)"
);
} else {
let export = serde_yaml_ng::to_string(&profiles)
.map_err(|e| Error::Config(format!("Failed to serialize profiles: {e}")))?;
std::fs::write(&file, export)?;
println!("exported {} profiles to {}", profiles.len(), file);
}
}
Commands::Import { file, overwrite } => {
let metadata = std::fs::metadata(&file)?;
if metadata.len() > 1024 * 1024 {
return Err(Error::Config("import file too large (max 1MB)".into()));
}
let content = std::fs::read_to_string(&file)?;
let imported: std::collections::HashMap<String, common::Profile> =
serde_yaml_ng::from_str(&content)
.map_err(|e| Error::Config(format!("Failed to parse profiles: {e}")))?;
validate_import(&imported)?;
if imported.is_empty() {
println!("no profiles in file");
} else {
let mut config = Config::load()?;
let mut added = 0;
let mut skipped = 0;
for (name, profile) in imported {
if config.profiles.contains_key(&name) && !overwrite {
println!("skipped '{}' (already exists, use --overwrite)", name);
skipped += 1;
} else {
config.profiles.insert(name.clone(), profile);
println!("imported '{}'", name);
added += 1;
}
}
config.save()?;
println!("\nimported {} profiles ({} skipped)", added, skipped);
}
}
Commands::Status => {
let manager = manager.as_ref().expect("checked by needs_cgroup_manager");
let processes = rlm_core::status::get_managed_processes(manager)?;
if processes.is_empty() {
println!("no processes currently managed");
} else {
println!(
"{:<8} {:<25} {:>12} {:>15} {:>10} {:>15}",
"PID", "NAME", "MEMORY", "CPU", "I/O", "TYPE"
);
println!("{}", "-".repeat(85));
for p in processes {
let mem = p.memory_max.map(format_bytes).unwrap_or_else(|| "-".into());
let cpu = p
.cpu_quota
.map(|q| format!("{}%", q))
.unwrap_or_else(|| "-".into());
let io = if p.io_read_bps.is_some() || p.io_write_bps.is_some() {
"limited".to_string()
} else {
"-".to_string()
};
let type_info = if p.is_shared {
if let Some(count) = p.process_count {
format!("shared ({} procs)", count)
} else {
"shared".to_string()
}
} else {
"individual".to_string()
};
println!(
"{:<8} {:<25} {:>12} {:>15} {:>10} {:>15}",
p.pid, p.name, mem, cpu, io, type_info
);
}
println!("\nNote: 'shared' means multiple processes share the same limit pool");
}
let empty = rlm_core::status::empty_cgroups(manager);
if !empty.is_empty() {
println!(
"note: {} empty rlm cgroup(s): {}. Remove with: rlm unlimit --cgroup <name>",
empty.len(),
empty.join(", ")
);
}
}
Commands::Doctor => {
return Ok(doctor::run());
}
Commands::Guard { action } => {
return run_guard(action);
}
Commands::Rule { action } => {
return run_rule(action);
}
}
Ok(ExitCode::SUCCESS)
}
fn is_guard_active() -> bool {
std::process::Command::new("systemctl")
.args(["--user", "is-active", "--quiet", "rlm-guard"])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_rule(action: RuleAction) -> Result<ExitCode> {
match action {
RuleAction::List => {
let config = Config::load()?;
if config.rules.is_empty() {
println!("no persistent rules configured");
println!(" create one with: rlm limit --application <exe> --memory <size> --save");
return Ok(ExitCode::SUCCESS);
}
println!(
"{:<20} {:>10} {:>8} {:>10} {:>10}",
"RULE", "MEMORY", "CPU", "IO_READ", "IO_WRITE"
);
println!("{}", "-".repeat(62));
let mut names: Vec<_> = config.rules.keys().collect();
names.sort();
for name in names {
let r = &config.rules[name];
println!(
"{:<20} {:>10} {:>8} {:>10} {:>10}",
name,
r.memory.as_deref().unwrap_or("-"),
r.cpu.as_deref().unwrap_or("-"),
r.io_read.as_deref().unwrap_or("-"),
r.io_write.as_deref().unwrap_or("-"),
);
}
Ok(ExitCode::SUCCESS)
}
RuleAction::Remove { name } => {
let mut config = Config::load()?;
if config.remove_rule(&name) {
config.save()?;
println!("removed rule '{name}'");
println!(" note: this does not drop a currently-applied limit; use `rlm unlimit --application {name}` for that");
Ok(ExitCode::SUCCESS)
} else {
Err(Error::InvalidArgs(format!("no rule named '{name}'")))
}
}
}
}
fn run_guard(action: GuardAction) -> Result<ExitCode> {
match action {
GuardAction::Enable => guard_enable(),
GuardAction::Disable => systemctl(&["disable", "--now", "rlm-guard"]),
GuardAction::Status => Ok(guard_status()),
GuardAction::Test => Ok(guard_test()),
GuardAction::History { lines } => {
guard_history(lines);
Ok(ExitCode::SUCCESS)
}
}
}
fn validate_import(profiles: &std::collections::HashMap<String, common::Profile>) -> Result<()> {
let mut errors: Vec<(String, String)> = profiles
.iter()
.filter_map(|(name, p)| p.validate().err().map(|e| (name.clone(), e.to_string())))
.collect();
if errors.is_empty() {
return Ok(());
}
errors.sort_by(|a, b| a.0.cmp(&b.0));
let list: Vec<String> = errors
.into_iter()
.map(|(name, e)| format!("'{name}': {e}"))
.collect();
Err(Error::Config(format!(
"import rejected, nothing was written. Fix these profiles: {}",
list.join("; ")
)))
}
fn guard_enable() -> Result<ExitCode> {
let config_dir = dirs::config_dir()
.ok_or_else(|| Error::InvalidArgs("cannot find the user config directory".into()))?;
let unit_path = guard_unit::user_unit_path(&config_dir);
let state = rlm_core::guard::service::query();
if let Some(msg) = guard_unit::masked_error(&state.enabled) {
eprintln!("error: {msg}");
return Ok(ExitCode::FAILURE);
}
let was_active = state.active == "active";
let existing = std::fs::read_to_string(&unit_path).ok();
let current_exe = std::env::current_exe().ok();
let path_env = std::env::var_os("PATH");
let guard_bin = guard_unit::find_guard_binary(current_exe.as_deref(), path_env.as_deref());
if let Some(bin) = &guard_bin {
if let Some(problem) = guard_unit::unit_path_problem(bin) {
eprintln!(
"error: cannot write a unit for {}: {problem}. Install rlm-guard under a plain path and rerun: rlm guard enable",
bin.display()
);
return Ok(ExitCode::FAILURE);
}
}
let system_dirs: Vec<&std::path::Path> = guard_unit::SYSTEM_UNIT_DIRS
.iter()
.map(std::path::Path::new)
.collect();
let plan = guard_unit::plan_enable(
guard_unit::system_unit_installed(&system_dirs),
existing.as_deref(),
guard_bin.as_deref(),
&unit_path,
);
let mut unit_written = false;
match plan {
guard_unit::EnablePlan::NoBinary => {
return Err(Error::InvalidArgs(
"rlm-guard was not found next to rlm or on PATH. Install it with: cargo install --path cli (from a source checkout) or: cargo install rlmctl".into(),
));
}
guard_unit::EnablePlan::WriteUserUnit { path, contents } => {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
if let Ok(old) = std::fs::read_to_string(&path) {
if !old.starts_with(guard_unit::GENERATED_MARKER) {
let backup = path.with_extension("service.bak");
std::fs::write(&backup, old)?;
println!("saved the previous unit as {}", backup.display());
}
}
guard_unit::write_atomically(&path, &contents)?;
println!("wrote {}", path.display());
unit_written = true;
let reload = systemctl(&["daemon-reload"])?;
if reload != ExitCode::SUCCESS {
return Ok(reload);
}
}
guard_unit::EnablePlan::UserUnitCustom => {
println!("note: keeping your own {}", unit_path.display());
}
guard_unit::EnablePlan::UseSystemUnit | guard_unit::EnablePlan::UserUnitCurrent => {}
}
let code = systemctl(&["enable", "--now", "rlm-guard"])?;
if let Some(note) = guard_unit::restart_note(was_active, unit_written) {
println!("{note}");
}
Ok(code)
}
fn systemctl(args: &[&str]) -> Result<ExitCode> {
let status = std::process::Command::new("systemctl")
.arg("--user")
.args(args)
.status()
.map_err(|e| Error::InvalidArgs(format!("failed to run systemctl: {e}")))?;
Ok(if status.success() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
}
fn guard_status() -> ExitCode {
let (cfg, config_err) = match Config::load_validated() {
Ok(c) => (c, None),
Err(e) => (Config::load().unwrap_or_default(), Some(e)),
};
println!(
"Service: {}",
rlm_core::guard::service::describe(&rlm_core::guard::service::query())
);
match &config_err {
None => println!("Config: ok"),
Some(e) => println!(
"Config: {}",
rlm_core::guard::report::config_error_line(
&rlm_core::guard::report::config_error_path(),
e
)
),
}
let sampler =
rlm_core::guard::Sampler::new(cfg.guard.clone(), std::process::id(), current_uid(), None);
match sampler.sample() {
Some(s) => println!("Pressure: {}", rlm_core::guard::report::pressure_line(&s)),
None => println!(
"Pressure: {}",
rlm_core::guard::report::pressure_unavailable()
),
}
println!(
"Policy: {}",
rlm_core::guard::report::trigger_line(&cfg.guard.trigger)
);
let entries = rlm_core::guard::Journal::read_entries(
&rlm_core::guard::journal_path(),
&rlm_core::guard::cgfs::boot_id(),
);
if entries.is_empty() {
println!("Interventions: none");
} else {
println!("Interventions:");
for e in &entries {
println!(" {}", rlm_core::guard::report::intervention_line(e));
}
}
let recent =
rlm_core::guard::history::read_recent(&rlm_core::guard::history::history_path(), 5);
if recent.is_empty() {
println!("History: none recorded yet");
} else {
println!("History:");
let now = rlm_core::guard::history::unix_now();
for e in &recent {
println!(" {}", rlm_core::guard::report::history_line(e, now));
}
}
println!("Full history: rlm guard history (also: journalctl --user -u rlm-guard)");
if config_err.is_some() {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn guard_test() -> ExitCode {
let cfg = match Config::load_validated() {
Ok(c) => c,
Err(e) => {
eprintln!(
"error: {}",
rlm_core::guard::report::config_error_line(
&rlm_core::guard::report::config_error_path(),
&e
)
);
return ExitCode::FAILURE;
}
};
let base_path = CgroupManager::default_base_path();
let rlm_base = rlm_core::guard::sampler::strip_cgroup_root(&base_path);
if rlm_base.is_none() {
tracing::error!(
"base_path {:?} isn't under /sys/fs/cgroup; escalation target resolution disabled",
base_path
);
}
let sampler = rlm_core::guard::Sampler::new(
cfg.guard.clone(),
std::process::id(),
current_uid(),
rlm_base,
);
let mut engine = rlm_core::guard::PolicyEngine::new(cfg.guard);
let Some(sample) = sampler.sample() else {
println!(
"Pressure: {}; cannot evaluate guard actions.",
rlm_core::guard::report::pressure_unavailable()
);
return ExitCode::SUCCESS;
};
let snapshot = rlm_core::process::list_for_uid(current_uid()).unwrap_or_default();
let procs = sampler.candidates(&snapshot);
let targets =
rlm_core::guard::sampler::targets_from_procs(&procs, &rlm_core::guard::cgfs::current_bytes);
let live = std::collections::HashSet::new();
println!(
"{} | {} eligible process(es)",
rlm_core::guard::report::pressure_line(&sample),
procs.len()
);
let actions = engine.tick(0, sample, &targets, &live);
if actions.is_empty() {
println!("No action would be taken right now.");
} else {
println!("Would take {} action(s):", actions.len());
for a in &actions {
println!(" {a:?}");
}
}
ExitCode::SUCCESS
}
fn guard_history(lines: usize) {
let events =
rlm_core::guard::history::read_recent(&rlm_core::guard::history::history_path(), lines);
if events.is_empty() {
println!("no guard interventions recorded yet");
return;
}
let now = rlm_core::guard::history::unix_now();
for e in &events {
println!("{}", rlm_core::guard::report::history_line(e, now));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn limit_refuses_an_invalid_config_unless_forced() {
let path = "/h/.config/rlm/config.yaml";
let bad = || Err(Error::Config("failed to parse: unknown field".into()));
let msg = config_for_limit(bad(), false, path).unwrap_err();
assert!(msg.contains(path), "{msg}");
assert!(msg.contains("unknown field"), "{msg}");
assert!(msg.contains("--force"), "{msg}");
let (cfg, warning) = config_for_limit(bad(), true, path).unwrap();
assert!(cfg.guard.selection.protect.is_empty());
let warning = warning.unwrap();
assert!(warning.starts_with("warning:") && warning.contains(path));
let (_, none) = config_for_limit(Ok(Config::default()), false, path).unwrap();
assert_eq!(none, None);
}
#[test]
fn packaging_metadata_is_consistent() {
const MANIFEST: &str = include_str!("../Cargo.toml");
assert!(
MANIFEST.contains("name = \"rlmctl\""),
"crates.io package name"
);
assert!(
MANIFEST.contains("name = \"rlm\"\npath = \"src/main.rs\""),
"binary stays rlm"
);
assert!(
MANIFEST.contains("name = \"rlm-guard\""),
"guard ships in the same package"
);
assert!(MANIFEST.contains("rlm-delegate.conf"));
assert!(!MANIFEST.contains("dist/delegate.conf"));
assert!(MANIFEST.contains("maintainer-scripts = \"../dist/deb\""));
assert_eq!(env!("CARGO_PKG_VERSION"), "0.2.1");
}
#[test]
fn parse_pid_list_basic() {
assert_eq!(parse_pid_list("1,2,3").unwrap(), vec![1, 2, 3]);
}
#[test]
fn parse_pid_list_trims_whitespace() {
assert_eq!(parse_pid_list(" 10 , 20 ,30 ").unwrap(), vec![10, 20, 30]);
}
#[test]
fn parse_pid_list_single() {
assert_eq!(parse_pid_list("42").unwrap(), vec![42]);
}
#[test]
fn parse_pid_list_rejects_invalid() {
assert!(parse_pid_list("1,abc,3").is_err());
assert!(parse_pid_list("1,,3").is_err()); assert!(parse_pid_list("-1").is_err()); }
#[test]
fn commands_that_need_the_cgroup_manager() {
let needs =
|args: &[&str]| needs_cgroup_manager(&Cli::try_parse_from(args).unwrap().command);
assert!(!needs(&["rlm", "doctor"]));
assert!(!needs(&["rlm", "profiles"]));
assert!(!needs(&["rlm", "export", "x.yaml"]));
assert!(!needs(&["rlm", "import", "x.yaml"]));
assert!(!needs(&["rlm", "guard", "status"]));
assert!(!needs(&["rlm", "rule", "list"]));
assert!(needs(&["rlm", "status"]));
assert!(needs(&["rlm", "limit", "--pid", "5", "--memory", "1G"]));
assert!(needs(&["rlm", "unlimit", "--pid", "5"]));
assert!(needs(&["rlm", "run", "--memory", "1G", "--", "true"]));
}
#[test]
fn save_without_application_is_rejected() {
assert!(validate_limit_args(true, None).is_err());
assert!(validate_limit_args(true, Some("firefox")).is_ok());
assert!(validate_limit_args(false, None).is_ok());
}
#[test]
fn other_users_and_protected_processes_are_refused() {
let protect = common::protect_set(&[]);
let p = |uid: u32, comm: &str, exe: &str| rlm_core::process::ProcessInfo {
pid: 42,
uid,
name: comm.into(),
executable: Some(exe.into()),
..Default::default()
};
assert!(check_target(
&p(1000, "firefox", "/usr/bin/firefox"),
1000,
&protect,
false
)
.is_ok());
let e = check_target(&p(0, "sshd", "/usr/sbin/sshd"), 1000, &protect, true)
.unwrap_err()
.to_string();
assert!(e.contains("belongs to uid 0"), "{e}");
let e = check_target(&p(1000, "bash", "/usr/bin/bash"), 1000, &protect, false)
.unwrap_err()
.to_string();
assert!(e.contains("--force"), "{e}");
assert!(check_target(&p(1000, "bash", "/usr/bin/bash"), 1000, &protect, true).is_ok());
assert!(
check_target(&p(1000, "bash", "/usr/bin/bash"), 0, &protect, false).is_err(),
"root still respects the protect list"
);
}
#[test]
fn unlimit_pid_reports_what_it_would_touch() {
assert_eq!(unlimit_pid_target(5, Some("pid-5")).unwrap(), "pid-5");
let e = unlimit_pid_target(5, None).unwrap_err().to_string();
assert!(e.contains("not limited by rlm"), "{e}");
let e = unlimit_pid_target(5, Some("app-firefox"))
.unwrap_err()
.to_string();
assert!(e.contains("rlm unlimit --cgroup app-firefox"), "{e}");
}
#[test]
fn import_rejects_the_whole_file_if_any_profile_is_invalid() {
let mut m = std::collections::HashMap::new();
m.insert(
"good".to_string(),
common::Profile {
cpu: Some("50%".into()),
..Default::default()
},
);
m.insert(
"bad".to_string(),
common::Profile {
memory: Some("1K".into()),
..Default::default()
},
);
let e = validate_import(&m).unwrap_err().to_string();
assert!(e.contains("bad") && !e.contains("good"), "{e}");
}
}