mouse/cheesed

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")
        );
    }
}