Blob: src/policy.rs
Raw · Blame
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::io;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
const RUNLEVELS: [&str; 4] = ["sysinit", "boot", "default", "shutdown"];
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct RcPolicy {
enabled: BTreeSet<String>,
}
impl RcPolicy {
pub(crate) fn enabled(&self) -> impl Iterator<Item = &str> {
self.enabled.iter().map(String::as_str)
}
}
#[derive(Debug)]
pub(crate) struct ServiceCatalog {
definitions: PathBuf,
installed: BTreeSet<String>,
required_by_runlevel: BTreeMap<String, BTreeSet<String>>,
required: BTreeSet<String>,
}
impl ServiceCatalog {
pub(crate) fn discover(definitions: &Path, templates: &Path) -> Result<Self, PolicyError> {
let installed = read_named_entries(definitions, "service definition")?;
let mut required_by_runlevel = BTreeMap::new();
let mut required = BTreeSet::new();
for runlevel in RUNLEVELS {
let members = read_named_entries(
&templates.join(runlevel),
&format!("{runlevel} runlevel member"),
)?;
for service in &members {
if !installed.contains(service) {
return Err(PolicyError::new(format!(
"required {runlevel} service {service} has no definition"
)));
}
required.insert(service.clone());
}
required_by_runlevel.insert(runlevel.to_owned(), members);
}
Ok(Self {
definitions: definitions.to_path_buf(),
installed,
required_by_runlevel,
required,
})
}
#[cfg(target_os = "linux")]
pub(crate) fn required(&self) -> impl Iterator<Item = &str> {
self.required.iter().map(String::as_str)
}
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct PolicyError {
message: String,
}
impl PolicyError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for PolicyError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl From<io::Error> for PolicyError {
fn from(error: io::Error) -> Self {
Self::new(error.to_string())
}
}
pub(crate) fn parse_rc_conf(
contents: &str,
catalog: &ServiceCatalog,
) -> Result<RcPolicy, PolicyError> {
let mut assignments = BTreeMap::new();
for (index, line) in contents.lines().enumerate() {
let line_number = index + 1;
if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
return Err(line_error(
line_number,
"expected enable_name=YES or enable_name=NO",
));
};
if key.contains(char::is_whitespace)
|| value.contains(char::is_whitespace)
|| line.matches('=').count() != 1
{
return Err(line_error(
line_number,
"whitespace, quoting, comments, and extra assignments are not allowed",
));
}
let Some(name) = key.strip_prefix("enable_") else {
return Err(line_error(line_number, "unknown rc.conf key"));
};
if !valid_service_name(name) {
return Err(line_error(line_number, "invalid service name"));
}
let enabled = match value {
"YES" => true,
"NO" => false,
_ => {
return Err(line_error(
line_number,
"enablement value must be exactly YES or NO",
));
}
};
if !catalog.installed.contains(name) {
return Err(line_error(
line_number,
format!("service {name} has no installed definition"),
));
}
if catalog.required.contains(name) {
return Err(line_error(
line_number,
format!("required service {name} cannot be enabled or disabled"),
));
}
if assignments.insert(name.to_owned(), enabled).is_some() {
return Err(line_error(
line_number,
format!("duplicate assignment for service {name}"),
));
}
}
Ok(RcPolicy {
enabled: assignments
.into_iter()
.filter_map(|(name, enabled)| enabled.then_some(name))
.collect(),
})
}
pub(crate) fn generate_runlevels(
policy: &RcPolicy,
catalog: &ServiceCatalog,
runtime_root: &Path,
) -> Result<PathBuf, PolicyError> {
let generations = runtime_root.join("generations");
fs::create_dir_all(&generations)?;
let generation_number = next_generation(&generations)?;
let pending = generations.join(format!("{generation_number}.new"));
let generation = generations.join(generation_number.to_string());
fs::create_dir(&pending)?;
let result = (|| {
for runlevel in RUNLEVELS {
let runlevel_dir = pending.join(runlevel);
fs::create_dir(&runlevel_dir)?;
let required = catalog
.required_by_runlevel
.get(runlevel)
.expect("all fixed runlevels were discovered");
for service in required {
link_service(&catalog.definitions, &runlevel_dir, service)?;
}
}
for service in policy.enabled() {
link_service(&catalog.definitions, &pending.join("default"), service)?;
}
fs::rename(&pending, &generation)?;
let next_link = runtime_root.join("current.new");
remove_file_if_present(&next_link)?;
symlink(format!("generations/{generation_number}"), &next_link)?;
fs::rename(next_link, runtime_root.join("current"))?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_dir_all(&pending);
}
result.map(|()| generation)
}
fn read_named_entries(directory: &Path, kind: &str) -> Result<BTreeSet<String>, PolicyError> {
let mut names = BTreeSet::new();
let entries = fs::read_dir(directory).map_err(|error| {
PolicyError::new(format!("cannot read {}: {error}", directory.display()))
})?;
for entry in entries {
let entry = entry?;
let name = entry
.file_name()
.into_string()
.map_err(|_| PolicyError::new(format!("{kind} name is not UTF-8")))?;
if kind == "service definition" && name == "functions.sh" {
continue;
}
if !valid_service_name(&name) {
return Err(PolicyError::new(format!(
"{kind} {name:?} is not a valid MOUSE service name"
)));
}
if !names.insert(name.clone()) {
return Err(PolicyError::new(format!("duplicate {kind} {name}")));
}
}
Ok(names)
}
fn valid_service_name(name: &str) -> bool {
let mut bytes = name.bytes();
matches!(bytes.next(), Some(b'a'..=b'z'))
&& bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
}
fn line_error(line_number: usize, message: impl fmt::Display) -> PolicyError {
PolicyError::new(format!("/etc/rc.conf:{line_number}: {message}"))
}
fn next_generation(generations: &Path) -> Result<u64, PolicyError> {
let mut highest = 0;
for entry in fs::read_dir(generations)? {
let entry = entry?;
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if let Ok(number) = name.parse::<u64>() {
highest = highest.max(number);
}
}
highest
.checked_add(1)
.ok_or_else(|| PolicyError::new("runlevel generation counter overflowed"))
}
fn link_service(definitions: &Path, runlevel: &Path, service: &str) -> Result<(), PolicyError> {
let definition = definitions.join(service);
fs::metadata(&definition).map_err(|error| {
PolicyError::new(format!(
"cannot resolve service definition {}: {error}",
definition.display()
))
})?;
symlink(&definition, runlevel.join(service))?;
Ok(())
}
fn remove_file_if_present(path: &Path) -> Result<(), PolicyError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
#[cfg(test)]
mod tests {
use super::{RcPolicy, ServiceCatalog, generate_runlevels, parse_rc_conf};
use std::collections::BTreeSet;
use std::fs;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
struct TestLayout {
root: PathBuf,
definitions: PathBuf,
templates: PathBuf,
runtime: PathBuf,
}
impl TestLayout {
fn new() -> Self {
let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir()
.join(format!("cheesed-policy-{}-{serial}", std::process::id()));
let definitions = root.join("init.d");
let templates = root.join("templates");
let runtime = root.join("runlevels");
fs::create_dir_all(&definitions).unwrap();
for runlevel in ["sysinit", "boot", "default", "shutdown"] {
fs::create_dir_all(templates.join(runlevel)).unwrap();
}
fs::write(definitions.join("required"), "#!/bin/sh\n").unwrap();
fs::write(definitions.join("optional"), "#!/bin/sh\n").unwrap();
fs::write(definitions.join("functions.sh"), "# OpenRC compatibility\n").unwrap();
symlink(
definitions.join("required"),
templates.join("default/required"),
)
.unwrap();
Self {
root,
definitions,
templates,
runtime,
}
}
fn catalog(&self) -> ServiceCatalog {
ServiceCatalog::discover(&self.definitions, &self.templates).unwrap()
}
}
impl Drop for TestLayout {
fn drop(&mut self) {
fs::remove_dir_all(&self.root).unwrap();
}
}
fn enabled(policy: &RcPolicy) -> BTreeSet<&str> {
policy.enabled().collect()
}
#[test]
fn accepts_comments_empty_lines_and_strict_assignments() {
let layout = TestLayout::new();
let policy =
parse_rc_conf("\n # comment\nenable_optional=YES\n", &layout.catalog()).unwrap();
assert_eq!(enabled(&policy), BTreeSet::from(["optional"]));
}
#[test]
fn no_and_missing_assignments_leave_optional_services_disabled() {
let layout = TestLayout::new();
let catalog = layout.catalog();
assert!(enabled(&parse_rc_conf("", &catalog).unwrap()).is_empty());
assert!(enabled(&parse_rc_conf("enable_optional=NO\n", &catalog).unwrap()).is_empty());
}
#[test]
fn rejects_shell_and_ambiguous_input() {
let layout = TestLayout::new();
let catalog = layout.catalog();
for invalid in [
"enable_optional =YES",
"enable_optional=\"YES\"",
"enable_optional=YES # comment",
"enable_optional=$(hostname)",
"enable_optional=YES=NO",
"rc_parallel=YES",
"enable_Optional=YES",
] {
assert!(
parse_rc_conf(invalid, &catalog).is_err(),
"accepted {invalid:?}"
);
}
}
#[test]
fn rejects_duplicates_unknown_services_and_required_policy() {
let layout = TestLayout::new();
let catalog = layout.catalog();
for invalid in [
"enable_optional=YES\nenable_optional=NO\n",
"enable_missing=NO\n",
"enable_required=YES\n",
] {
assert!(
parse_rc_conf(invalid, &catalog).is_err(),
"accepted {invalid:?}"
);
}
}
#[test]
fn generates_complete_atomic_runtime_membership() {
let layout = TestLayout::new();
let catalog = layout.catalog();
let policy = parse_rc_conf("enable_optional=YES\n", &catalog).unwrap();
let first = generate_runlevels(&policy, &catalog, &layout.runtime).unwrap();
assert_eq!(first, layout.runtime.join("generations/1"));
assert!(first.join("default/required").is_symlink());
assert!(first.join("default/optional").is_symlink());
for runlevel in ["sysinit", "boot", "default", "shutdown"] {
assert!(first.join(runlevel).is_dir());
}
assert_eq!(
fs::read_link(layout.runtime.join("current")).unwrap(),
Path::new("generations/1")
);
let disabled = parse_rc_conf("enable_optional=NO\n", &catalog).unwrap();
let second = generate_runlevels(&disabled, &catalog, &layout.runtime).unwrap();
assert_eq!(second, layout.runtime.join("generations/2"));
assert!(!second.join("default/optional").exists());
assert_eq!(
fs::read_link(layout.runtime.join("current")).unwrap(),
Path::new("generations/2")
);
}
}