use clap::{Arg, Command};
use color_eyre::Result;
use color_eyre::eyre::{eyre, WrapErr};
use std::collections::HashSet;
use std::env;
use std::fs;
use crate::lfs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
const SYSTEM_ENABLED_STATE_DIR: &str = "/var/lib/systemd/deb-systemd-helper-enabled";
const USER_ENABLED_STATE_DIR: &str = "/var/lib/systemd/deb-systemd-user-helper-enabled";
const SYSTEM_MASKED_STATE_DIR: &str = "/var/lib/systemd/deb-systemd-helper-masked";
const USER_MASKED_STATE_DIR: &str = "/var/lib/systemd/deb-systemd-user-helper-masked";
#[derive(Debug, Clone)]
pub struct DebSystemdHelperOptions {
pub quiet: bool,
pub user: bool,
pub action: String,
pub units: Vec<String>,
}
pub fn parse_options(matches: &clap::ArgMatches) -> Result<DebSystemdHelperOptions> {
let quiet = matches.get_flag("quiet");
let user = matches.get_flag("user");
let action = matches
.get_one::<String>("action")
.cloned()
.ok_or_else(|| eyre!("missing action"))?;
let units: Vec<String> = matches
.get_many::<String>("units")
.map(|v| v.cloned().collect())
.unwrap_or_default();
Ok(DebSystemdHelperOptions {
quiet,
user,
action,
units,
})
}
pub fn command() -> Command {
Command::new("deb-systemd-helper")
.about("Helper for systemd unit files in Debian packages (enable/disable/unmask/purge/update-state/was-enabled/debian-installed)")
.arg(
Arg::new("quiet")
.long("quiet")
.action(clap::ArgAction::SetTrue)
.help("Suppress output"),
)
.arg(
Arg::new("user")
.long("user")
.action(clap::ArgAction::SetTrue)
.help("Handle user units"),
)
.arg(Arg::new("action").required(true).help(
"enable | disable | purge | unmask | update-state | was-enabled | debian-installed",
))
.arg(
Arg::new("units")
.num_args(0..)
.help("Unit names (e.g. ssh.service)"),
)
}
fn dpkg_root() -> PathBuf {
env::var_os("DPKG_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(""))
}
fn root_join(root: &Path, p: &str) -> PathBuf {
let p_trim = p.trim_start_matches('/');
if root.as_os_str().is_empty() || p_trim.is_empty() {
PathBuf::from(p)
} else {
root.join(p_trim)
}
}
fn require_maintscript() -> Result<()> {
if env::var_os("DPKG_MAINTSCRIPT_PACKAGE").is_none() {
return Err(eyre!(
"deb-systemd-helper was not called from dpkg. Exiting."
));
}
Ok(())
}
fn enabled_state_dir(user: bool, root: &Path) -> PathBuf {
let sub = if user {
USER_ENABLED_STATE_DIR
} else {
SYSTEM_ENABLED_STATE_DIR
};
root_join(root, sub)
}
fn masked_state_dir(user: bool, root: &Path) -> PathBuf {
let sub = if user {
USER_MASKED_STATE_DIR
} else {
SYSTEM_MASKED_STATE_DIR
};
root_join(root, sub)
}
fn etc_systemd_instance(user: bool) -> &'static str {
if user {
"user"
} else {
"system"
}
}
fn find_unit(root: &Path, unit_name: &str, user: bool) -> Option<PathBuf> {
let instance = etc_systemd_instance(user);
let search = [
format!("/etc/systemd/{}/{}", instance, unit_name),
format!("/lib/systemd/{}/{}", instance, unit_name),
format!("/usr/lib/systemd/{}/{}", instance, unit_name),
];
for p in &search {
let full = root_join(root, p);
if full.exists() {
return Some(PathBuf::from(p));
}
}
None
}
fn dsh_state_path(unit_name: &str, user: bool, root: &Path) -> PathBuf {
let basename = Path::new(unit_name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(unit_name);
let state_dir = enabled_state_dir(user, root);
state_dir.join(format!("{}.dsh-also", basename))
}
#[derive(Clone)]
struct UnitLink {
dest: String,
src: String,
}
fn parse_wanted_required(line: &str) -> Option<(bool, Vec<String>)> {
let line = line.trim();
if line.starts_with("WantedBy=") {
let v = line["WantedBy=".len()..].trim();
let values: Vec<String> = v
.split_whitespace()
.map(|s| s.trim_matches(|c| c == '"' || c == '\'').to_string())
.collect();
return Some((true, values));
}
if line.starts_with("RequiredBy=") {
let v = line["RequiredBy=".len()..].trim();
let values: Vec<String> = v
.split_whitespace()
.map(|s| s.trim_matches(|c| c == '"' || c == '\'').to_string())
.collect();
return Some((false, values));
}
None
}
fn get_link_closure(
root: &Path,
service_path: &Path,
instance: &str,
_visited: &mut HashSet<String>,
) -> Result<Vec<UnitLink>> {
let unit_name = service_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let full_path = root_join(root, &service_path.to_string_lossy());
let content = fs::read_to_string(&full_path)
.wrap_err_with(|| format!("unable to read {}", full_path.display()))?;
let mut wants_dirs: Vec<String> = Vec::new();
let wanted_target = unit_name;
for line in content.lines() {
if let Some((is_wants, values)) = parse_wanted_required(line) {
for value in values {
let dir = if is_wants {
format!("/etc/systemd/{}/{}.wants/", instance, value)
} else {
format!("/etc/systemd/{}/{}.requires/", instance, value)
};
wants_dirs.push(dir);
}
}
}
let dest_str = service_path.to_string_lossy().to_string();
let mut links = Vec::new();
for wants_dir in wants_dirs {
let src = format!("{}{}", wants_dir, wanted_target);
links.push(UnitLink {
dest: dest_str.clone(),
src,
});
}
Ok(links)
}
fn state_file_entries(state_path: &Path) -> Vec<String> {
let file = match fs::File::open(state_path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
BufReader::new(file)
.lines()
.filter_map(|r| r.ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn record_in_statefile(state_path: &Path, service_link: &str) -> Result<()> {
let entries = state_file_entries(state_path);
if entries.contains(&service_link.to_string()) {
return Ok(());
}
if let Some(parent) = state_path.parent() {
lfs::create_dir_all(parent)?;
}
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(state_path)
.wrap_err("open state file")?;
writeln!(f, "{}", service_link).wrap_err("write state file")?;
Ok(())
}
fn link_state_path(link_src: &str, user: bool, root: &Path) -> PathBuf {
let instance = etc_systemd_instance(user);
let prefix = format!("/etc/systemd/{}/", instance);
let rel = link_src.strip_prefix(&prefix).unwrap_or(link_src);
let state_dir = enabled_state_dir(user, root);
state_dir.join(rel.trim_start_matches('/'))
}
fn dsh_action_enable(
root: &Path,
user: bool,
unit_name: &str,
service_path: &Path,
) -> Result<()> {
let mut visited = HashSet::new();
let links = get_link_closure(
root,
service_path,
etc_systemd_instance(user),
&mut visited,
)?;
let dsh_state = dsh_state_path(unit_name, user, root);
for link in &links {
record_in_statefile(&dsh_state, &link.src)?;
let link_full = root_join(root, &link.src);
if link_full.exists() {
continue;
}
if let Some(parent) = link_full.parent() {
lfs::create_dir_all(parent)?;
}
let _target = Path::new(&link.dest);
#[cfg(unix)]
{
lfs::symlink_file_for_native(_target, &link_full)?;
}
#[cfg(not(unix))]
{
return Err(eyre!("symlink not supported on this platform"));
}
}
for link in &links {
let statefile = link_state_path(&link.src, user, root);
if let Some(p) = statefile.parent() {
lfs::create_dir_all(p).ok();
}
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&statefile)
.ok();
}
Ok(())
}
fn dsh_action_disable(root: &Path, user: bool, unit_name: &str, purge: bool) -> Result<()> {
let dsh_state = dsh_state_path(unit_name, user, root);
let entries = state_file_entries(&dsh_state);
if purge && dsh_state.exists() {
lfs::remove_file(&dsh_state).ok();
}
for link_src in &entries {
let link_state = link_state_path(link_src, user, root);
if purge || link_state.exists() {
lfs::remove_file(&link_state).ok();
}
let link_full = root_join(root, link_src);
if link_full.is_symlink() {
let _ = lfs::remove_file(&link_full);
}
}
Ok(())
}
fn dsh_action_unmask(root: &Path, user: bool, unit_name: &str) -> Result<bool> {
let basename = Path::new(unit_name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(unit_name);
let mask_link = root_join(
root,
&format!(
"/etc/systemd/{}/{}",
etc_systemd_instance(user),
basename
),
);
if !mask_link.exists() {
return Ok(true);
}
let target = fs::read_link(&mask_link).ok();
if target.as_ref().map(|t| t != Path::new("/dev/null")).unwrap_or(true) {
return Ok(true);
}
let statefile = masked_state_dir(user, root).join(basename);
if statefile.exists() {
lfs::remove_file(&mask_link).ok();
lfs::remove_file(&statefile).ok();
}
Ok(false)
}
fn dsh_action_update_state(
root: &Path,
user: bool,
unit_name: &str,
service_path: &Path,
) -> Result<()> {
let mut visited = HashSet::new();
let links = get_link_closure(
root,
service_path,
etc_systemd_instance(user),
&mut visited,
)?;
let dsh_state = dsh_state_path(unit_name, user, root);
if let Some(parent) = dsh_state.parent() {
lfs::create_dir_all(parent)?;
}
let mut f = lfs::file_create(&dsh_state)?;
for link in &links {
writeln!(f, "{}", link.src).wrap_err("write state")?;
}
Ok(())
}
fn dsh_action_was_enabled(
root: &Path,
user: bool,
unit_name: &str,
quiet: bool,
exit_rc: &mut i32,
) -> Result<()> {
let dsh_state = dsh_state_path(unit_name, user, root);
let entries = state_file_entries(&dsh_state);
let all_present = entries
.iter()
.all(|link_src| root_join(root, link_src).is_symlink());
if all_present && !entries.is_empty() {
*exit_rc = 0;
if !quiet {
eprintln!("enabled");
}
} else if !quiet {
eprintln!("disabled");
}
Ok(())
}
fn dsh_action_debian_installed(root: &Path, user: bool, unit_name: &str, exit_rc: &mut i32) -> Result<()> {
let dsh_state = dsh_state_path(unit_name, user, root);
if dsh_state.exists() {
*exit_rc = 0;
}
Ok(())
}
pub fn run(options: DebSystemdHelperOptions) -> Result<()> {
require_maintscript()?;
let root = dpkg_root();
let purge = options.action == "purge";
let action = if purge {
"disable"
} else {
options.action.as_str()
};
let mut exit_rc = 1;
if matches!(
action,
"was-enabled" | "debian-installed" | "is-enabled"
) {
exit_rc = 0;
}
for unit_name in &options.units {
let service_path = match find_unit(&root, unit_name, options.user) {
Some(p) => p,
None => {
if action == "debian-installed" {
continue;
}
return Err(eyre!("unit file not found: {}", unit_name));
}
};
match action {
"enable" => {
dsh_action_enable(&root, options.user, unit_name, &service_path)?;
}
"disable" => {
dsh_action_disable(&root, options.user, unit_name, purge)?;
}
"unmask" => {
if dsh_action_unmask(&root, options.user, unit_name)? {
continue;
}
}
"update-state" => {
dsh_action_update_state(&root, options.user, unit_name, &service_path)?;
}
"was-enabled" => {
dsh_action_was_enabled(
&root,
options.user,
unit_name,
options.quiet,
&mut exit_rc,
)?;
}
"debian-installed" => {
dsh_action_debian_installed(&root, options.user, unit_name, &mut exit_rc)?;
}
_ => {}
}
}
if options.units.is_empty() && (action == "was-enabled" || action == "debian-installed") {
}
std::process::exit(exit_rc);
}