mouse/cheesed
Diff
6df4f01ac29e → e15f2330f78d
PLAN.md
Mode 100644 → 100644; object 25af1ae02383 → 41499fe7db8d
@@ -235,6 +235,10 @@ ## Milestones +Current status: C0 and C1 are implemented in the MOUSE image. The C1 proof +service also exercises the C2 `supervise-daemon` and runtime-control path; C2 +remains open until its failure cases are covered as a distinct milestone. + ### C0: executable skeleton Create the Rust crate, cross/static build configuration, and a QEMU boot path
README.md
Mode 100644 → 100644; object ba267da1e454 → 7ce4d1903764
@@ -1,9 +1,11 @@ # cheesed -`cheesed` is the PID 1 and service supervisor for MOUSE. The repository is -currently implementing milestone C0 from [`PLAN.md`](PLAN.md): a static Linux -init which logs to the console, reaps children, provides an emergency `tcsh`, -and handles reboot and poweroff signals without ever returning from PID 1. +`cheesed` is MOUSE's PID 1 and boot coordinator. Milestones C0 and C1 from +[`PLAN.md`](PLAN.md) are implemented: the static Linux init performs early +bootstrap, strictly translates `/etc/rc.conf` into disposable OpenRC +runlevels, runs the boot graph, reaps children during transitions and steady +state, provides a recovery `tcsh`, and coordinates OpenRC shutdown before +reboot or poweroff. ## Development checks @@ -22,7 +24,7 @@ scripts/build-static.sh ``` -## C0 QEMU boot +## Standalone QEMU boot The initramfs deliberately takes an explicit static `tcsh`; it does not copy a host shell with unresolved dynamic-library dependencies.
src/lib.rs
Mode 100644 → 100644; object a3dcd4ec10d0 → 8d5975b7110f
@@ -2,6 +2,9 @@
use std::fmt;
+#[cfg(any(target_os = "linux", test))]
+mod policy;
+
/// The first message emitted after PID 1 bootstrap succeeds.
pub const STARTUP_BANNER: &str = concat!("Cheesed to meet you! v", env!("CARGO_PKG_VERSION"));
src/linux.rs
Mode 100644 → 100644; object d15a4d24ca98 → c77400ea4607
@@ -1,8 +1,10 @@
+use crate::policy::{ServiceCatalog, generate_runlevels, parse_rc_conf};
use crate::{STARTUP_BANNER, ShutdownAction, shutdown_action};
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::os::unix::process::CommandExt;
-use std::process::{Command, Stdio};
+use std::os::unix::process::ExitStatusExt;
+use std::process::{Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::thread;
use std::time::{Duration, Instant};
@@ -10,9 +12,17 @@
const CONSOLE_PATH: &str = "/dev/console";
const EMERGENCY_SHELL: &str = "/bin/tcsh";
const HOSTNAME_PATH: &str = "/etc/hostname";
+const OPENRC_PATH: &str = "/sbin/openrc";
+const RC_CONF_PATH: &str = "/etc/rc.conf";
+const SERVICE_DEFINITIONS: &str = "/etc/init.d";
+const RUNLEVEL_TEMPLATES: &str = "/usr/lib/mouse/runlevels";
+const RUNTIME_RUNLEVELS: &str = "/run/openrc/runlevels";
const POLL_INTERVAL: Duration = Duration::from_millis(50);
const SHELL_RESTART_DELAY: Duration = Duration::from_secs(1);
-const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
+const BOOT_TRANSITION_TIMEOUT: Duration = Duration::from_secs(120);
+const SHUTDOWN_TRANSITION_TIMEOUT: Duration = Duration::from_secs(30);
+const SERVICE_STATUS_TIMEOUT: Duration = Duration::from_secs(10);
+const TERMINATION_GRACE: Duration = Duration::from_secs(5);
static CHILD_EXITED: AtomicBool = AtomicBool::new(false);
static SHUTDOWN_SIGNAL: AtomicI32 = AtomicI32::new(0);
@@ -21,7 +31,7 @@
if signal == libc::SIGCHLD {
CHILD_EXITED.store(true, Ordering::Relaxed);
} else {
- SHUTDOWN_SIGNAL.store(signal, Ordering::Relaxed);
+ let _ = SHUTDOWN_SIGNAL.compare_exchange(0, signal, Ordering::Relaxed, Ordering::Relaxed);
}
}
@@ -72,7 +82,12 @@
}
console.write_line(STARTUP_BANNER);
- console.log("C0 bootstrap complete; cheesed is PID 1");
+ console.log("C1 bootstrap complete; cheesed is PID 1");
+ match boot_services(&mut console) {
+ Ok(Some(action)) => perform_shutdown(&mut console, None, action),
+ Ok(None) => {}
+ Err(error) => console.log(&format!("{error}; entering recovery console")),
+ }
let mut shell_pid = match spawn_emergency_shell(&mut console) {
Ok(pid) => Some(pid),
Err(error) => {
@@ -171,7 +186,8 @@
fn mount_early_filesystems() -> io::Result<()> {
mount_filesystem(c"devtmpfs".as_ptr(), c"/dev".as_ptr(), c"devtmpfs".as_ptr())?;
mount_filesystem(c"proc".as_ptr(), c"/proc".as_ptr(), c"proc".as_ptr())?;
- mount_filesystem(c"sysfs".as_ptr(), c"/sys".as_ptr(), c"sysfs".as_ptr())
+ mount_filesystem(c"sysfs".as_ptr(), c"/sys".as_ptr(), c"sysfs".as_ptr())?;
+ mount_filesystem(c"tmpfs".as_ptr(), c"/run".as_ptr(), c"tmpfs".as_ptr())
}
fn mount_filesystem(
@@ -216,6 +232,239 @@
return Err(io::Error::last_os_error());
}
Ok(())
+}
+
+fn boot_services(console: &mut Console) -> Result<Option<ShutdownAction>, String> {
+ let catalog = ServiceCatalog::discover(
+ std::path::Path::new(SERVICE_DEFINITIONS),
+ std::path::Path::new(RUNLEVEL_TEMPLATES),
+ )
+ .map_err(|error| format!("cannot discover services: {error}"))?;
+ let contents = std::fs::read_to_string(RC_CONF_PATH)
+ .map_err(|error| format!("cannot read {RC_CONF_PATH}: {error}"))?;
+ let policy = parse_rc_conf(&contents, &catalog)
+ .map_err(|error| format!("invalid service policy: {error}"))?;
+ let enabled = policy.enabled().collect::<Vec<_>>();
+ if enabled.is_empty() {
+ console.log("enabled optional services: (none)");
+ } else {
+ console.log(&format!(
+ "enabled optional services: {}",
+ enabled.join(", ")
+ ));
+ }
+
+ let generation = generate_runlevels(&policy, &catalog, std::path::Path::new(RUNTIME_RUNLEVELS))
+ .map_err(|error| format!("cannot generate OpenRC runlevels: {error}"))?;
+ console.log(&format!(
+ "generated OpenRC runlevels at {}",
+ generation.display()
+ ));
+
+ for runlevel in ["sysinit", "boot"] {
+ if let Some(action) = run_openrc(console, runlevel, BOOT_TRANSITION_TIMEOUT, true)? {
+ return Ok(Some(action));
+ }
+ }
+ if let Some(action) = run_openrc(console, "default", BOOT_TRANSITION_TIMEOUT, false)? {
+ return Ok(Some(action));
+ }
+ for service in catalog.required() {
+ if let Some(action) = run_service_status(console, service)
+ .map_err(|error| format!("required service {service} is not started: {error}"))?
+ {
+ return Ok(Some(action));
+ }
+ }
+ for service in policy.enabled() {
+ match run_service_status(console, service) {
+ Ok(Some(action)) => return Ok(Some(action)),
+ Ok(None) => {}
+ Err(error) => console.log(&format!("optional service {service} failed: {error}")),
+ }
+ }
+ Ok(None)
+}
+
+fn run_openrc(
+ console: &mut Console,
+ runlevel: &str,
+ timeout: Duration,
+ require_success: bool,
+) -> Result<Option<ShutdownAction>, String> {
+ console.log(&format!("starting OpenRC {runlevel} transition"));
+ let status = match command_with_console(OPENRC_PATH, &[runlevel], console, timeout)? {
+ CommandResult::Exited(status) => status,
+ CommandResult::Shutdown(action) => return Ok(Some(action)),
+ };
+ if require_success && !status.success() {
+ return Err(format!(
+ "OpenRC {runlevel} transition exited with {}",
+ describe_exit_status(status)
+ ));
+ }
+ if status.success() {
+ console.log(&format!("OpenRC {runlevel} transition complete"));
+ } else {
+ console.log(&format!(
+ "OpenRC {runlevel} transition exited with {}; checking required services",
+ describe_exit_status(status)
+ ));
+ }
+ Ok(None)
+}
+
+fn run_service_status(
+ console: &mut Console,
+ service: &str,
+) -> Result<Option<ShutdownAction>, String> {
+ let result = command_with_console(
+ "/sbin/rc-service",
+ &[service, "status"],
+ console,
+ SERVICE_STATUS_TIMEOUT,
+ )?;
+ let status = match result {
+ CommandResult::Exited(status) => status,
+ CommandResult::Shutdown(action) => return Ok(Some(action)),
+ };
+ if status.success() {
+ Ok(None)
+ } else {
+ Err(format!(
+ "status command exited with {}",
+ describe_exit_status(status)
+ ))
+ }
+}
+
+fn command_with_console(
+ program: &str,
+ arguments: &[&str],
+ console: &mut Console,
+ timeout: Duration,
+) -> Result<CommandResult, String> {
+ let stdin = console
+ .clone_file()
+ .map_err(|error| format!("cannot duplicate console input: {error}"))?;
+ let stdout = console
+ .clone_file()
+ .map_err(|error| format!("cannot duplicate console output: {error}"))?;
+ let stderr = console
+ .clone_file()
+ .map_err(|error| format!("cannot duplicate console error output: {error}"))?;
+ let mut command = Command::new(program);
+ command
+ .args(arguments)
+ .stdin(Stdio::from(stdin))
+ .stdout(Stdio::from(stdout))
+ .stderr(Stdio::from(stderr));
+ // SAFETY: this closure runs between fork and exec, and setpgid is
+ // async-signal-safe. A separate group lets PID 1 terminate a whole
+ // transition, including service-script descendants.
+ unsafe {
+ command.pre_exec(|| {
+ if libc::setpgid(0, 0) == -1 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+ let child = command
+ .spawn()
+ .map_err(|error| format!("cannot run {program}: {error}"))?;
+ let pid = i32::try_from(child.id()).map_err(|_| "child PID does not fit pid_t".to_owned())?;
+ let deadline = Instant::now() + timeout;
+ loop {
+ if let Some(status) = reap_during_command(console, pid)? {
+ return Ok(CommandResult::Exited(status));
+ }
+ if let Some(action) = take_shutdown_action() {
+ terminate_process_group(pid, program, console);
+ return Ok(CommandResult::Shutdown(action));
+ }
+ if Instant::now() >= deadline {
+ terminate_process_group(pid, program, console);
+ return Err(format!(
+ "{program} {} timed out after {} seconds",
+ arguments.join(" "),
+ timeout.as_secs()
+ ));
+ }
+ thread::sleep(POLL_INTERVAL);
+ }
+}
+
+enum CommandResult {
+ Exited(ExitStatus),
+ Shutdown(ShutdownAction),
+}
+
+fn take_shutdown_action() -> Option<ShutdownAction> {
+ shutdown_action(SHUTDOWN_SIGNAL.swap(0, Ordering::Relaxed))
+}
+
+fn reap_during_command(
+ console: &mut Console,
+ command_pid: libc::pid_t,
+) -> Result<Option<ExitStatus>, String> {
+ CHILD_EXITED.store(false, Ordering::Relaxed);
+ loop {
+ let mut status = 0;
+ // SAFETY: status is writable and WNOHANG makes the call nonblocking.
+ let pid = unsafe { libc::waitpid(-1, &raw mut status, libc::WNOHANG) };
+ match pid {
+ value if value == command_pid => {
+ return Ok(Some(ExitStatus::from_raw(status)));
+ }
+ value if value > 0 => {
+ console.log(&format!(
+ "reaped orphan PID {value} ({})",
+ describe_wait_status(status)
+ ));
+ }
+ 0 => return Ok(None),
+ _ => {
+ let error = io::Error::last_os_error();
+ return if error.raw_os_error() == Some(libc::ECHILD) {
+ Err("transition child disappeared without an exit status".to_owned())
+ } else {
+ Err(format!("waitpid failed during transition: {error}"))
+ };
+ }
+ }
+ }
+}
+
+fn terminate_process_group(pid: libc::pid_t, program: &str, console: &mut Console) {
+ // SAFETY: negative pid addresses the process group created before exec.
+ let term_result = unsafe { libc::kill(-pid, libc::SIGTERM) };
+ if term_result == -1 && io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) {
+ console.log(&format!("cannot terminate {program} process group {pid}"));
+ }
+ let deadline = Instant::now() + TERMINATION_GRACE;
+ while Instant::now() < deadline {
+ match reap_during_command(console, pid) {
+ Ok(Some(_)) | Err(_) => return,
+ Ok(None) => thread::sleep(POLL_INTERVAL),
+ }
+ }
+ // SAFETY: negative pid addresses the same known process group.
+ unsafe {
+ libc::kill(-pid, libc::SIGKILL);
+ }
+ loop {
+ match reap_during_command(console, pid) {
+ Ok(Some(_)) | Err(_) => return,
+ Ok(None) => thread::sleep(POLL_INTERVAL),
+ }
+ }
+}
+
+fn describe_exit_status(status: std::process::ExitStatus) -> String {
+ status
+ .code()
+ .map_or_else(|| "a signal".to_owned(), |code| format!("status {code}"))
}
fn spawn_emergency_shell(console: &mut Console) -> Result<libc::pid_t, String> {
@@ -313,6 +562,9 @@
) -> ! {
console.log(&format!("received {action} request"));
stop_shell(console, shell_pid);
+ if let Err(error) = run_openrc(console, "shutdown", SHUTDOWN_TRANSITION_TIMEOUT, true) {
+ console.log(&format!("OpenRC shutdown failed: {error}"));
+ }
// SAFETY: sync has no preconditions and is required before the terminal reboot call.
unsafe {
@@ -349,7 +601,7 @@
return;
}
- let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
+ let deadline = Instant::now() + TERMINATION_GRACE;
while Instant::now() < deadline {
if wait_for_pid(pid) {
console.log(&format!("emergency shell PID {pid} stopped"));
src/policy.rs
Mode → 100644; object → 0a875df53ee3
@@ -1,0 +1,417 @@
+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")
+ );
+ }
+}