use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, Utc};
use runner_manager_domain::model::StartMode;
use runner_manager_domain::path::LocalAbsolutePath;
use serde::{Deserialize, Serialize};
use crate::lock::{HostLock, LockError, LockKind};
use crate::paths::AppPaths;
#[cfg(windows)]
use crate::runner_root_access::RootAdmission;
use crate::runner_root_access::{
Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
};
pub const SERVICE_NAME: &str = "runner-manager";
pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
pub const DESCRIPTION: &str =
"Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
pub const RECORD_FILE: &str = "service.toml";
pub const CONTACT_FILE: &str = "github-contact.toml";
pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
pub const LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
pub const DAEMON_STALLED_SUBJECT: &str = "daemon progress";
pub const HOST_UNFIT_SUBJECT: &str = "host fitness";
pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceIdentity {
name: String,
display_name: String,
description: String,
}
impl ServiceIdentity {
#[must_use]
pub fn product() -> Self {
Self {
name: SERVICE_NAME.to_string(),
display_name: DISPLAY_NAME.to_string(),
description: DESCRIPTION.to_string(),
}
}
#[must_use]
pub fn fixture(tag: &str) -> Self {
let tag: String = tag
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let name = format!("{SERVICE_NAME}-selftest-{tag}");
Self {
display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
description: "Disposable fixture created by runner-manager's own installer tests. \
Safe to remove."
.to_string(),
name,
}
}
#[must_use]
pub fn is_fixture(&self) -> bool {
self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn display_name(&self) -> &str {
&self.display_name
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub fn launchd_label(&self) -> String {
format!(
"{}.{}.{}",
crate::paths::QUALIFIER,
crate::paths::ORGANIZATION,
self.name
)
}
#[must_use]
pub fn systemd_unit(&self) -> String {
format!("{}.service", self.name)
}
#[must_use]
pub fn restart_command(&self, mode: StartMode) -> String {
let name = &self.name;
if cfg!(windows) {
match mode {
StartMode::Boot => {
format!("Restart-Service -Name {name}")
}
StartMode::Login => format!("schtasks /End /TN {name}; schtasks /Run /TN {name}"),
}
} else if cfg!(target_os = "macos") {
let label = self.launchd_label();
match mode {
StartMode::Boot => format!("sudo launchctl kickstart -k system/{label}"),
StartMode::Login => format!("launchctl kickstart -k gui/$(id -u)/{label}"),
}
} else {
let unit = self.systemd_unit();
match mode {
StartMode::Boot => format!("sudo systemctl restart {unit}"),
StartMode::Login => format!("systemctl --user restart {unit}"),
}
}
}
}
impl fmt::Display for ServiceIdentity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RestartPolicy {
delay: Duration,
reset_after: Duration,
}
impl RestartPolicy {
pub const MIN_DELAY: Duration = Duration::from_secs(1);
pub const MAX_DELAY: Duration = Duration::from_secs(300);
pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
return Err(ServiceError::RestartDelay {
requested_secs: delay.as_secs(),
min_secs: Self::MIN_DELAY.as_secs(),
max_secs: Self::MAX_DELAY.as_secs(),
});
}
if reset_after <= delay {
return Err(ServiceError::RestartResetWindow {
reset_secs: reset_after.as_secs(),
delay_secs: delay.as_secs(),
});
}
Ok(Self { delay, reset_after })
}
#[must_use]
pub const fn delay(&self) -> Duration {
self.delay
}
#[must_use]
pub const fn reset_after(&self) -> Duration {
self.reset_after
}
#[must_use]
pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
match kind {
DefinitionKind::WindowsScheduledTask => {
let seconds = self.delay.as_secs();
let minutes = seconds.div_ceil(60);
Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
}
_ => self.delay,
}
}
}
impl Default for RestartPolicy {
fn default() -> Self {
Self {
delay: Self::DEFAULT_DELAY,
reset_after: Self::DEFAULT_RESET_AFTER,
}
}
}
impl fmt::Display for RestartPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"restart after {}s, failure count resets after {}s",
self.delay.as_secs(),
self.reset_after.as_secs()
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceAccount {
LocalSystem,
Root,
InvokingUser,
}
impl ServiceAccount {
#[must_use]
pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
match (kind, mode) {
(DefinitionKind::WindowsService, _) => Self::LocalSystem,
(DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
(_, StartMode::Boot) => Self::Root,
}
}
#[must_use]
pub const fn for_start_mode(mode: StartMode) -> Self {
Self::for_definition(host_definition_kind(mode), mode)
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
Self::Root => "root",
Self::InvokingUser => "the invoking user",
}
}
#[must_use]
pub const fn justification(&self) -> &'static str {
match self {
Self::LocalSystem => {
"the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
NetworkService cannot read it, and widening the DACL to reach them would grant \
every service on this host read access to the one credential this product holds"
}
Self::Root => {
"a boot-time registration runs outside every login session: on macOS the System \
Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
machine-scoped store is a 0600 file under /var/lib that only root can open \
before a session exists"
}
Self::InvokingUser => {
"a login-mode registration reads the user-scoped store, which is deliberately \
readable by exactly one account and needs no elevation at all"
}
}
}
#[must_use]
pub const fn needs_elevation(&self) -> bool {
matches!(self, Self::LocalSystem | Self::Root)
}
}
impl fmt::Display for ServiceAccount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("cannot install the service while an agent is already running on this host: {source}")]
LockHeld {
#[source]
source: Box<LockError>,
},
#[error(
"cannot tell whether an agent is already running on this host, so the service was not \
installed: {source}. Fix the reported problem with the state directory and try again."
)]
LockUnreadable {
#[source]
source: Box<LockError>,
},
#[error(
"this registration would run jobs under the default runner root, and that root could \
not be prepared, so nothing was registered: {source}"
)]
RunnerRoot {
#[source]
source: Box<RootAccessError>,
},
#[error(
"cannot resolve the absolute path of this executable, so there is nothing to register: \
{detail}. Run the installer from the installed binary rather than through a shell \
function or a wrapper that replaces argv[0]."
)]
BinaryPath {
detail: String,
},
#[error(
"{} is not a file, so registering it would create a service that cannot start. Install \
the product first, then run `service install` from the installed binary.",
path.display()
)]
BinaryMissing {
path: PathBuf,
},
#[error(
"a restart delay of {requested_secs}s is outside the supported range \
{min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
value, so `service status` would report a delay that is not the one in force."
)]
RestartDelay {
requested_secs: u64,
min_secs: u64,
max_secs: u64,
},
#[error(
"a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
restart delay, so the count would reset between every pair of restarts and no \
start limit could ever apply."
)]
RestartResetWindow {
reset_secs: u64,
delay_secs: u64,
},
#[error(
"{name} is already registered to start at {existing}, and this asks for {requested}. \
Changing the start mode moves the registration between two different service \
managers, so it is not something an install does by itself: run `service uninstall` \
first, or change the start mode in the terminal UI (`runner-manager tui`), which \
switches it in place and keeps the registration running throughout."
)]
AlreadyInstalled {
name: String,
existing: StartMode,
requested: StartMode,
},
#[error("{name} is not registered on this host, so there is nothing to {operation}.")]
NotInstalled {
name: String,
operation: &'static str,
},
#[error("cannot {operation} the service record {}: {detail}", path.display())]
Record {
operation: &'static str,
path: PathBuf,
detail: String,
},
#[error(
"the service record {} was not written by this product, or was written by a version \
this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
neither touches configuration, secrets, or the cache.",
path.display()
)]
RecordUnreadable {
path: PathBuf,
detail: String,
},
#[error(
"the service record {} is there and this account may not read it: {detail}. It was \
written by whichever account installed the service -- on a boot-mode host, `sudo \
service install`. Running `service install` again rewrites it readable; until then \
only what the service manager itself reports is available.",
path.display()
)]
RecordNotPermitted {
path: PathBuf,
detail: String,
},
#[error("cannot prepare this host's application-data directories: {source}")]
Paths {
#[source]
source: Box<crate::paths::PathsError>,
},
#[error("cannot {operation} {name} through {manager}: {detail}")]
Control {
operation: &'static str,
name: String,
manager: &'static str,
detail: String,
},
#[error(
"cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
Inspect `service status` before retrying."
)]
Rollback {
operation: &'static str,
name: String,
cause: String,
rollback: String,
},
#[error(
"this machine's {name} service was installed from {installed_by}, not from this \
account's data root, so this account does not {operation} it: doing so would \
re-register or remove that installation's service with this account's directories. \
Do it {act_as}. If that account no longer exists, remove the registration with the \
service manager itself first."
)]
ForeignInstallation {
name: String,
operation: &'static str,
installed_by: String,
act_as: String,
},
#[error("this account's local configuration is in use, so nothing was archived: {detail}.")]
LocalConfigurationInUse {
detail: String,
},
#[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
NeedsElevation {
operation: &'static str,
name: String,
detail: String,
remedy: &'static str,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceDirectories {
pub config: PathBuf,
pub state: PathBuf,
pub runtime: PathBuf,
pub logs: PathBuf,
}
impl ServiceDirectories {
#[must_use]
pub fn of(paths: &AppPaths) -> Self {
Self {
config: paths.config_dir().to_path_buf(),
state: paths.state_dir().to_path_buf(),
runtime: paths.runtime_dir().to_path_buf(),
logs: paths.logs_dir().to_path_buf(),
}
}
#[must_use]
pub fn all(&self) -> [&Path; 4] {
[
self.config.as_path(),
self.state.as_path(),
self.runtime.as_path(),
self.logs.as_path(),
]
}
#[must_use]
pub fn log_file(&self) -> PathBuf {
self.logs.join(LOG_FILE_STEM)
}
#[must_use]
pub fn arguments(&self) -> [OsString; 8] {
let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS.map(OsString::from);
[
config,
self.config.clone().into_os_string(),
state,
self.state.clone().into_os_string(),
runtime,
self.runtime.clone().into_os_string(),
logs,
self.logs.clone().into_os_string(),
]
}
#[must_use]
pub fn from_command_line(command_line: &str) -> Option<Self> {
let arguments = split_command_line(command_line);
let value = |option: &str| {
let at = arguments.iter().position(|argument| argument == option)?;
arguments.get(at + 1).map(PathBuf::from)
};
let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS;
Some(Self {
config: value(config)?,
state: value(state)?,
runtime: value(runtime)?,
logs: value(logs)?,
})
}
#[must_use]
pub fn root(&self) -> PathBuf {
let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
for other in [&self.state, &self.runtime, &self.logs] {
let common = shared
.iter()
.zip(other.components())
.take_while(|(left, right)| **left == *right)
.count();
shared.truncate(common);
}
if shared.is_empty() {
self.config.clone()
} else {
shared.iter().collect()
}
}
#[must_use]
pub fn paths(&self) -> AppPaths {
AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
}
}
pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
"--service-config-dir",
"--service-state-dir",
"--service-runtime-dir",
"--service-logs-dir",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallRequest {
start_mode: StartMode,
binary: Option<PathBuf>,
source_binary: Option<PathBuf>,
arguments: Vec<OsString>,
restart: RestartPolicy,
on_demand: bool,
}
impl InstallRequest {
#[must_use]
pub fn new(start_mode: StartMode) -> Self {
Self {
start_mode,
binary: None,
source_binary: None,
arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
restart: RestartPolicy::default(),
on_demand: false,
}
}
#[must_use]
pub const fn started_on_demand(mut self) -> Self {
self.on_demand = true;
self
}
#[must_use]
pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
self.binary = Some(binary.into());
self
}
#[must_use]
pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
self.source_binary = Some(source.into());
self
}
#[must_use]
pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.arguments = arguments.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
self.restart = restart;
self
}
#[must_use]
pub const fn start_mode(&self) -> StartMode {
self.start_mode
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallPlan {
identity: ServiceIdentity,
start_mode: StartMode,
binary: PathBuf,
source_binary: Option<PathBuf>,
arguments: Vec<OsString>,
account: ServiceAccount,
restart: RestartPolicy,
directories: ServiceDirectories,
secret_guard: Option<PathBuf>,
on_demand: bool,
}
impl InstallPlan {
pub fn resolve(
identity: ServiceIdentity,
request: &InstallRequest,
directories: ServiceDirectories,
) -> Result<Self, ServiceError> {
let binary = match &request.binary {
Some(named) => absolute(named)?,
None => running_executable()?,
};
if !binary.is_file() {
return Err(ServiceError::BinaryMissing { path: binary });
}
let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
.ok()
.map(|store| store.guard());
Ok(Self {
identity,
start_mode: request.start_mode,
binary,
source_binary: request.source_binary.clone(),
arguments: request.arguments.clone(),
account: ServiceAccount::for_start_mode(request.start_mode),
restart: request.restart,
directories,
secret_guard,
on_demand: request.on_demand,
})
}
#[must_use]
pub fn unchecked(
identity: ServiceIdentity,
start_mode: StartMode,
binary: impl Into<PathBuf>,
directories: ServiceDirectories,
) -> Self {
Self {
identity,
start_mode,
binary: binary.into(),
source_binary: None,
arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
account: ServiceAccount::for_start_mode(start_mode),
restart: RestartPolicy::default(),
directories,
secret_guard: None,
on_demand: false,
}
}
#[must_use]
pub const fn started_on_demand(mut self) -> Self {
self.on_demand = true;
self
}
#[must_use]
pub const fn is_on_demand(&self) -> bool {
self.on_demand
}
#[must_use]
pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
self.secret_guard = Some(guard.into());
self
}
#[must_use]
pub fn secret_guard(&self) -> Option<&Path> {
self.secret_guard.as_deref()
}
#[must_use]
pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
self.restart = restart;
self
}
#[must_use]
pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.arguments = arguments.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub const fn identity(&self) -> &ServiceIdentity {
&self.identity
}
#[must_use]
pub const fn start_mode(&self) -> StartMode {
self.start_mode
}
#[must_use]
pub fn binary(&self) -> &Path {
&self.binary
}
#[must_use]
pub fn source_binary(&self) -> Option<&Path> {
self.source_binary.as_deref()
}
#[must_use]
pub fn arguments(&self) -> &[OsString] {
&self.arguments
}
#[must_use]
pub const fn account(&self) -> &ServiceAccount {
&self.account
}
#[must_use]
pub const fn restart(&self) -> RestartPolicy {
self.restart
}
#[must_use]
pub const fn directories(&self) -> &ServiceDirectories {
&self.directories
}
#[must_use]
pub fn command_line(&self) -> String {
let mut out = quote_argument(&self.binary.to_string_lossy());
for argument in &self.arguments {
out.push(' ');
out.push_str("e_argument(&argument.to_string_lossy()));
}
out
}
}
fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
let reversal = change.revert();
matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
}
fn rolled_back<T>(
retained: Option<String>,
rollback: Result<T, ServiceError>,
operation: &'static str,
identity: &ServiceIdentity,
cause: ServiceError,
) -> ServiceError {
let left_behind = match (rollback.err(), retained) {
(Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
(Some(rollback), None) => Some(rollback.to_string()),
(None, retained) => retained,
};
match left_behind {
Some(rollback) => ServiceError::Rollback {
operation,
name: identity.name().to_string(),
cause: cause.to_string(),
rollback,
},
None => cause,
}
}
fn undo_runner_root(
change: &RootAccessChange,
operation: &'static str,
identity: &ServiceIdentity,
cause: ServiceError,
) -> ServiceError {
rolled_back(
retained_runner_root(change),
Ok(()),
operation,
identity,
cause,
)
}
fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
detail: format!("{} could not be made absolute: {error}", path.display()),
})
}
fn running_executable() -> Result<PathBuf, ServiceError> {
let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
detail: error.to_string(),
})?;
absolute(&raw)
}
pub(crate) fn quote_argument(argument: &str) -> String {
if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
return argument.to_string();
}
let mut out = String::with_capacity(argument.len() + 2);
out.push('"');
let mut backslashes = 0usize;
for c in argument.chars() {
match c {
'\\' => {
backslashes += 1;
out.push('\\');
}
'"' => {
for _ in 0..=backslashes {
out.push('\\');
}
out.push('"');
backslashes = 0;
}
other => {
backslashes = 0;
out.push(other);
}
}
}
for _ in 0..backslashes {
out.push('\\');
}
out.push('"');
out
}
#[must_use]
pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
split_command_line(command_line)
.into_iter()
.next()
.filter(|executable| !executable.is_empty())
.map(PathBuf::from)
}
#[must_use]
pub fn split_command_line(command_line: &str) -> Vec<String> {
let mut arguments = Vec::new();
let mut current = String::new();
let mut started = false;
let mut quoted = false;
let mut backslashes = 0usize;
for c in command_line.chars() {
match c {
'\\' => {
backslashes += 1;
started = true;
}
'"' => {
current.extend(std::iter::repeat_n('\\', backslashes / 2));
if backslashes.is_multiple_of(2) {
quoted = !quoted;
} else {
current.push('"');
}
backslashes = 0;
started = true;
}
' ' | '\t' if !quoted => {
current.extend(std::iter::repeat_n('\\', backslashes));
backslashes = 0;
if started {
arguments.push(std::mem::take(&mut current));
started = false;
}
}
other => {
current.extend(std::iter::repeat_n('\\', backslashes));
backslashes = 0;
current.push(other);
started = true;
}
}
}
current.extend(std::iter::repeat_n('\\', backslashes));
if started {
arguments.push(current);
}
arguments
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefinitionKind {
WindowsService,
WindowsScheduledTask,
LaunchdPlist,
SystemdUnit,
}
impl DefinitionKind {
#[must_use]
pub const fn manager(self) -> &'static str {
match self {
Self::WindowsService => "the Windows Service Control Manager",
Self::WindowsScheduledTask => "Windows Task Scheduler",
Self::LaunchdPlist => "launchd",
Self::SystemdUnit => "systemd",
}
}
}
impl fmt::Display for DefinitionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.manager())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceDefinition {
kind: DefinitionKind,
text: String,
install_path: Option<PathBuf>,
}
impl ServiceDefinition {
#[must_use]
pub const fn kind(&self) -> DefinitionKind {
self.kind
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn install_path(&self) -> Option<&Path> {
self.install_path.as_deref()
}
pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
Ok(match host_definition_kind(plan.start_mode()) {
DefinitionKind::WindowsService => Self::windows_service(plan),
DefinitionKind::WindowsScheduledTask => {
Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
}
DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
})
}
#[must_use]
pub fn windows_service(plan: &InstallPlan) -> Self {
Self {
kind: DefinitionKind::WindowsService,
text: windows_service_descriptor(plan),
install_path: None,
}
}
#[must_use]
pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
Self {
kind: DefinitionKind::WindowsScheduledTask,
text: windows_scheduled_task_xml(plan, principal),
install_path: None,
}
}
#[must_use]
pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
let file = format!("{}.plist", plan.identity().launchd_label());
let install_path = match plan.start_mode() {
StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
};
Self {
kind: DefinitionKind::LaunchdPlist,
text: launchd_plist(plan),
install_path,
}
}
#[must_use]
pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
Self {
kind,
text: text.into(),
install_path: None,
}
}
#[must_use]
pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
let file = plan.identity().systemd_unit();
let install_path = match plan.start_mode() {
StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
};
Self {
kind: DefinitionKind::SystemdUnit,
text: systemd_unit(plan),
install_path,
}
}
}
pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
pub const START_LIMIT_BURST: u32 = 5;
#[must_use]
pub fn systemd_unit(plan: &InstallPlan) -> String {
let identity = plan.identity();
let restart = plan.restart();
let directories = plan.directories();
let mut out = String::new();
out.push_str("[Unit]\n");
out.push_str(&format!("Description={}\n", identity.display_name()));
out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
out.push_str("After=network-online.target\n");
out.push_str("Wants=network-online.target\n");
out.push_str(&format!(
"StartLimitIntervalSec={}\n",
restart.reset_after().as_secs()
));
out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
out.push_str("\n[Service]\n");
out.push_str("Type=simple\n");
out.push_str("KillMode=process\n");
out.push_str(&format!("ExecStart={}\n", plan.command_line()));
out.push_str(&format!(
"WorkingDirectory={}\n",
directories.state.display()
));
out.push_str(&format!("SyslogIdentifier={identity}\n"));
out.push_str("Restart=on-failure\n");
out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
out.push_str("\n# Least privilege. See docs/service-account.md.\n");
for directive in SYSTEMD_HARDENING {
out.push_str(directive);
out.push('\n');
}
let mut writable = directories.all().to_vec();
if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
writable.push(secret_directory);
}
out.push_str(&format!(
"ReadWritePaths={}\n",
writable
.iter()
.map(|path| quote_argument(&path.to_string_lossy()))
.collect::<Vec<_>>()
.join(" ")
));
out.push_str("\n[Install]\n");
out.push_str(match plan.start_mode() {
StartMode::Boot => "WantedBy=multi-user.target\n",
StartMode::Login => "WantedBy=default.target\n",
});
out
}
pub const SYSTEMD_HARDENING: [&str; 13] = [
"NoNewPrivileges=yes",
"CapabilityBoundingSet=",
"AmbientCapabilities=",
"PrivateTmp=yes",
"PrivateDevices=yes",
"ProtectSystem=strict",
"ProtectKernelTunables=yes",
"ProtectKernelModules=yes",
"ProtectControlGroups=yes",
"RestrictNamespaces=yes",
"RestrictRealtime=yes",
"RestrictSUIDSGID=yes",
"RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
];
pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
#[must_use]
pub fn launchd_plist(plan: &InstallPlan) -> String {
let identity = plan.identity();
let directories = plan.directories();
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
);
out.push_str("<plist version=\"1.0\">\n<dict>\n");
out.push_str(&plist_string("Label", &identity.launchd_label()));
out.push_str(" <key>ProgramArguments</key>\n <array>\n");
out.push_str(&format!(
" <string>{}</string>\n",
xml_escape(&plan.binary().to_string_lossy())
));
for argument in plan.arguments() {
out.push_str(&format!(
" <string>{}</string>\n",
xml_escape(&argument.to_string_lossy())
));
}
out.push_str(" </array>\n");
out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
out.push_str(" <key>KeepAlive</key>\n <dict>\n");
out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
out.push_str(" </dict>\n");
out.push_str(&format!(
" <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
plan.restart().delay().as_secs()
));
out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
out.push_str(&plist_string(
"WorkingDirectory",
&directories.state.to_string_lossy(),
));
out.push_str(&plist_string(
"StandardOutPath",
&directories
.logs
.join("runner-manager.launchd.out.log")
.to_string_lossy(),
));
out.push_str(&plist_string(
"StandardErrorPath",
&directories
.logs
.join("runner-manager.launchd.err.log")
.to_string_lossy(),
));
match plan.start_mode() {
StartMode::Boot => {
out.push_str(&plist_string(
"UserName",
ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
.as_str(),
));
out.push_str(" <key>SessionCreate</key>\n <false/>\n");
}
StartMode::Login => {
}
}
out.push_str("</dict>\n</plist>\n");
out
}
fn plist_string(key: &str, value: &str) -> String {
format!(
" <key>{}</key>\n <string>{}</string>\n",
xml_escape(key),
xml_escape(value)
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskPrincipal {
user_id: String,
}
impl TaskPrincipal {
pub fn current() -> Result<Self, ServiceError> {
let user = std::env::var("USERNAME")
.ok()
.filter(|value| !value.trim().is_empty());
let Some(user) = user else {
return Err(ServiceError::Control {
operation: "identify the account for",
name: SERVICE_NAME.to_string(),
manager: "Windows Task Scheduler",
detail: "this session reports no %USERNAME%, so there is no principal to \
register a logon-triggered task for"
.to_string(),
});
};
let domain = std::env::var("USERDOMAIN")
.ok()
.filter(|value| !value.trim().is_empty());
Ok(Self {
user_id: match domain {
Some(domain) => format!("{domain}\\{user}"),
None => user,
},
})
}
#[must_use]
pub fn named(user_id: impl Into<String>) -> Self {
Self {
user_id: user_id.into(),
}
}
#[must_use]
pub fn user_id(&self) -> &str {
&self.user_id
}
}
#[must_use]
pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
let identity = plan.identity();
let user = xml_escape(principal.user_id());
let arguments = plan
.arguments()
.iter()
.map(|argument| quote_argument(&argument.to_string_lossy()))
.collect::<Vec<_>>()
.join(" ");
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
out.push_str(
"<Task version=\"1.4\" \
xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
);
out.push_str(" <RegistrationInfo>\n");
out.push_str(&format!(
" <Description>{}</Description>\n",
xml_escape(identity.description())
));
out.push_str(&format!(
" <URI>\\{}</URI>\n",
xml_escape(identity.name())
));
out.push_str(" </RegistrationInfo>\n");
out.push_str(" <Triggers>\n <LogonTrigger>\n");
out.push_str(" <Enabled>true</Enabled>\n");
out.push_str(&format!(" <UserId>{user}</UserId>\n"));
out.push_str(" </LogonTrigger>\n </Triggers>\n");
out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
out.push_str(&format!(" <UserId>{user}</UserId>\n"));
out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
out.push_str(" </Principal>\n </Principals>\n");
out.push_str(" <Settings>\n");
out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
out.push_str(" <IdleSettings>\n");
out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
out.push_str(" </IdleSettings>\n");
out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
out.push_str(" <Enabled>true</Enabled>\n");
out.push_str(" <Hidden>false</Hidden>\n");
out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
out.push_str(" <WakeToRun>false</WakeToRun>\n");
out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
out.push_str(" <Priority>7</Priority>\n");
out.push_str(" <RestartOnFailure>\n");
out.push_str(&format!(
" <Interval>{}</Interval>\n",
iso8601_minutes(
plan.restart()
.effective_delay(DefinitionKind::WindowsScheduledTask)
)
));
out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
out.push_str(" </RestartOnFailure>\n");
out.push_str(" </Settings>\n");
out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
out.push_str(&format!(
" <Command>{}</Command>\n",
xml_escape(&plan.binary().to_string_lossy())
));
if !arguments.is_empty() {
out.push_str(&format!(
" <Arguments>{}</Arguments>\n",
xml_escape(&arguments)
));
}
out.push_str(&format!(
" <WorkingDirectory>{}</WorkingDirectory>\n",
xml_escape(&plan.directories().state.to_string_lossy())
));
out.push_str(" </Exec>\n </Actions>\n");
out.push_str("</Task>\n");
out
}
fn iso8601_minutes(duration: Duration) -> String {
format!("PT{}M", duration.as_secs() / 60)
}
pub(crate) fn xml_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
fn xml_unescape(value: &str) -> String {
value
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowsServiceSpec {
pub name: String,
pub display_name: String,
pub description: String,
pub automatic_start: bool,
pub account: Option<String>,
pub command_line: String,
pub restart: RestartPolicy,
}
#[must_use]
pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
WindowsServiceSpec {
name: plan.identity().name().to_string(),
display_name: plan.identity().display_name().to_string(),
description: plan.identity().description().to_string(),
automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
account: match ServiceAccount::for_definition(
DefinitionKind::WindowsService,
plan.start_mode(),
) {
ServiceAccount::LocalSystem => None,
other => Some(other.as_str().to_string()),
},
command_line: plan.command_line(),
restart: plan.restart(),
}
}
#[must_use]
fn windows_service_descriptor(plan: &InstallPlan) -> String {
let spec = windows_service_spec(plan);
let mut out = String::new();
out.push_str("[windows-service]\n");
out.push_str(&format!("Name={}\n", spec.name));
out.push_str(&format!("DisplayName={}\n", spec.display_name));
out.push_str(&format!("Description={}\n", spec.description));
out.push_str("ServiceType=OWN_PROCESS\n");
out.push_str(&format!(
"StartType={}\n",
if spec.automatic_start {
"AutoStart"
} else {
"OnDemand"
}
));
out.push_str("ErrorControl=Normal\n");
out.push_str(&format!(
"Account={}\n",
spec.account
.as_deref()
.unwrap_or(ServiceAccount::LocalSystem.as_str())
));
out.push_str(&format!("CommandLine={}\n", spec.command_line));
out.push_str(&format!(
"FailureActionRestartDelaySecs={}\n",
spec.restart.delay().as_secs()
));
out.push_str(&format!(
"FailureActionsResetPeriodSecs={}\n",
spec.restart.reset_after().as_secs()
));
out.push_str("FailureActionsOnNonCrashFailures=true\n");
out.push_str(&format!(
"ReadWritePaths={}\n",
plan.directories()
.all()
.iter()
.map(|path| quote_argument(&path.to_string_lossy()))
.collect::<Vec<_>>()
.join(" ")
));
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingKind {
Excess,
Shortfall,
}
impl fmt::Display for FindingKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Excess => "excess",
Self::Shortfall => "shortfall",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrivilegeFinding {
pub kind: FindingKind,
pub subject: String,
pub detail: String,
}
impl fmt::Display for PrivilegeFinding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrivilegeReview {
kind: DefinitionKind,
account: ServiceAccount,
controls: Vec<String>,
findings: Vec<PrivilegeFinding>,
}
impl PrivilegeReview {
#[must_use]
pub fn is_least_privilege(&self) -> bool {
!self
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Excess)
}
#[must_use]
pub fn findings(&self) -> &[PrivilegeFinding] {
&self.findings
}
#[must_use]
pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
self.findings
.iter()
.filter(|finding| finding.kind == FindingKind::Excess)
.collect()
}
#[must_use]
pub fn controls(&self) -> &[String] {
&self.controls
}
#[must_use]
pub const fn account(&self) -> &ServiceAccount {
&self.account
}
#[must_use]
pub const fn kind(&self) -> DefinitionKind {
self.kind
}
}
impl fmt::Display for PrivilegeReview {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"{} runs as {} ({})",
self.kind,
self.account,
self.account.justification()
)?;
for control in &self.controls {
writeln!(f, " confirmed {control}")?;
}
for finding in &self.findings {
writeln!(f, " {finding}")?;
}
if self.is_least_privilege() {
write!(f, " verdict least privilege")
} else {
write!(
f,
" verdict NOT least privilege: {} excess(es)",
self.excesses().len()
)
}
}
}
#[must_use]
pub fn review_least_privilege(
definition: &ServiceDefinition,
plan: &InstallPlan,
) -> PrivilegeReview {
let mut controls = Vec::new();
let mut findings = Vec::new();
match definition.kind() {
DefinitionKind::SystemdUnit => {
review_systemd(definition.text(), plan, &mut controls, &mut findings);
}
DefinitionKind::LaunchdPlist => {
review_launchd(definition.text(), plan, &mut controls, &mut findings);
}
DefinitionKind::WindowsScheduledTask => {
review_scheduled_task(definition.text(), &mut controls, &mut findings);
}
DefinitionKind::WindowsService => {
review_windows_service(definition.text(), plan, &mut controls, &mut findings);
}
}
PrivilegeReview {
kind: definition.kind(),
account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
controls,
findings,
}
}
fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
let mut permitted = plan
.directories()
.all()
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
if kind == DefinitionKind::SystemdUnit
&& let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
{
permitted.push(secret_directory.to_string_lossy().into_owned());
}
permitted
}
fn same_path_text(left: &str, right: &str) -> bool {
if cfg!(windows) {
left.eq_ignore_ascii_case(right)
} else {
left == right
}
}
fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
match kind {
DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
left.eq_ignore_ascii_case(right)
}
DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
}
}
fn review_writable_paths(
kind: DefinitionKind,
subject: &str,
listed: &[String],
plan: &InstallPlan,
controls: &mut Vec<String>,
findings: &mut Vec<PrivilegeFinding>,
) {
let permitted = permitted_paths(kind, plan);
for entry in listed {
if !permitted
.iter()
.any(|allowed| same_path_for(kind, allowed, entry))
{
findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: subject.to_string(),
detail: format!(
"{entry} is writable but is not one of this registration's required paths"
),
});
}
}
for allowed in &permitted {
if !listed
.iter()
.any(|entry| same_path_for(kind, allowed, entry))
{
findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: subject.to_string(),
detail: format!(
"{allowed} is one of this registration's directories but is not writable, \
so the daemon cannot use it"
),
});
}
}
if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
controls.push(format!(
"{subject} names exactly the required application-data and credential paths"
));
}
}
fn review_inbound_surface(
text: &str,
markers: &[(&str, &str)],
controls: &mut Vec<String>,
findings: &mut Vec<PrivilegeFinding>,
) {
let mut clean = true;
for (marker, detail) in markers {
if text.contains(marker) {
clean = false;
findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: (*marker).to_string(),
detail: (*detail).to_string(),
});
}
}
if clean {
controls.push(
"no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
);
}
}
fn review_systemd(
text: &str,
plan: &InstallPlan,
controls: &mut Vec<String>,
findings: &mut Vec<PrivilegeFinding>,
) {
let directives = ini_directives(text, "Service");
for expected in SYSTEMD_HARDENING {
let (key, value) = expected
.split_once('=')
.expect("every hardening directive is written as key=value");
match directives.get(key) {
Some(actual) if actual == value => controls.push((*expected).to_string()),
Some(actual) => findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: key.to_string(),
detail: format!(
"is `{actual}`, not `{value}`, so the unit keeps authority the \
requirement does not ask for"
),
}),
None => findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: key.to_string(),
detail: format!(
"is absent, so the unit inherits systemd's default rather than `{value}`"
),
}),
}
}
match directives.get("ReadWritePaths") {
Some(value) => {
let listed = split_quoted(value);
review_writable_paths(
DefinitionKind::SystemdUnit,
"ReadWritePaths",
&listed,
plan,
controls,
findings,
);
}
None => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "ReadWritePaths".to_string(),
detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
.to_string(),
}),
}
if directives.contains_key("PrivateUsers")
&& directives.get("PrivateUsers") == Some(&"no".to_string())
{
findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: "PrivateUsers".to_string(),
detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
.to_string(),
});
}
review_inbound_surface(
text,
&[
(
"ListenStream=",
"asks systemd to open a listening socket for this service, which \
07-security.md rule 2 forbids the product to have",
),
(
"ListenDatagram=",
"asks systemd to open a listening socket for this service, which \
07-security.md rule 2 forbids the product to have",
),
],
controls,
findings,
);
}
fn review_launchd(
text: &str,
plan: &InstallPlan,
controls: &mut Vec<String>,
findings: &mut Vec<PrivilegeFinding>,
) {
match plist_string_value(text, "ProcessType") {
Some(value) if value == LAUNCHD_PROCESS_TYPE => {
controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
}
found => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "ProcessType".to_string(),
detail: format!(
"is {}, and launchd applies it to every runner the daemon starts, so their jobs \
run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
when it next starts with no runner; `service status` says how to restart it",
found.map_or_else(
|| "absent (launchd's `Standard`)".to_string(),
|value| format!("`{value}`")
)
),
}),
}
match plan.start_mode() {
StartMode::Boot => {
if plist_bool_value(text, "SessionCreate") == Some(true) {
findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: "SessionCreate".to_string(),
detail: "asks launchd to create a security session for a job that runs \
outside every login session and has no use for one"
.to_string(),
});
} else {
controls.push("SessionCreate is not requested".to_string());
}
match plist_string_value(text, "UserName").as_deref() {
Some("root") => {
controls.push("UserName=root, stated rather than inherited".to_string())
}
Some(other) => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "UserName".to_string(),
detail: format!(
"is `{other}`, which cannot unlock the System Keychain: \
/var/db/SystemKey is root-only, so the daemon would start and then \
find no credential"
),
}),
None => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "UserName".to_string(),
detail: "is absent, so the account is launchd's implicit default and this \
review cannot confirm it"
.to_string(),
}),
}
}
StartMode::Login => {
if let Some(named) = plist_string_value(text, "UserName") {
findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: "UserName".to_string(),
detail: format!(
"names `{named}` in a LaunchAgent, which already runs as the operator; \
naming an account here asks launchd for a switch a login-mode \
registration has no reason to want"
),
});
} else {
controls
.push("no UserName: the agent runs as the operator and no other".to_string());
}
}
}
review_inbound_surface(
text,
&[
(
"<key>Sockets</key>",
"asks launchd to open a socket for this job, which 07-security.md rule 2 \
forbids the product to have",
),
(
"<key>MachServices</key>",
"publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
forbids the product to have",
),
],
controls,
findings,
);
}
fn review_scheduled_task(
text: &str,
controls: &mut Vec<String>,
findings: &mut Vec<PrivilegeFinding>,
) {
match xml_value(text, "RunLevel").as_deref() {
Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
Some(other) => findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: "RunLevel".to_string(),
detail: format!(
"is `{other}`, so the task runs with an elevated token whenever the operator is \
an administrator"
),
}),
None => findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: "RunLevel".to_string(),
detail: "is absent, so Task Scheduler decides the token rather than the definition"
.to_string(),
}),
}
match xml_value(text, "LogonType").as_deref() {
Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
Some(other) => findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: "LogonType".to_string(),
detail: format!(
"is `{other}`, which asks Windows to store or synthesise a credential for this \
task; an interactive token needs neither"
),
}),
None => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "LogonType".to_string(),
detail: "is absent, so this review cannot confirm that no credential is stored"
.to_string(),
}),
}
}
fn review_windows_service(
text: &str,
plan: &InstallPlan,
controls: &mut Vec<String>,
findings: &mut Vec<PrivilegeFinding>,
) {
let directives = ini_directives(text, "windows-service");
match directives.get("ServiceType").map(String::as_str) {
Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
Some(other) => findings.push(PrivilegeFinding {
kind: FindingKind::Excess,
subject: "ServiceType".to_string(),
detail: format!(
"is `{other}`; an interactive or shared-process service reaches further than a \
daemon that only talks to GitHub over HTTPS"
),
}),
None => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "ServiceType".to_string(),
detail: "is absent, so this review cannot confirm the service is not interactive"
.to_string(),
}),
}
match directives.get("Account").map(String::as_str) {
Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
controls.push(format!(
"Account={account}: the only stock account the machine-scoped store's DACL \
(SY, BA, OW) admits"
));
}
Some(other) => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "Account".to_string(),
detail: format!(
"is `{other}`, which the machine-scoped store's DACL does not name, so the \
daemon would start and then find no credential. Widening that DACL is not this \
registration's to do: an ACE reaching `{other}` would also reach every other \
service running under it"
),
}),
None => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "Account".to_string(),
detail: "is absent, so this review cannot confirm which account was registered"
.to_string(),
}),
}
match directives.get("ReadWritePaths") {
Some(value) => {
let listed = split_quoted(value);
review_writable_paths(
DefinitionKind::WindowsService,
"ReadWritePaths",
&listed,
plan,
controls,
findings,
);
}
None => findings.push(PrivilegeFinding {
kind: FindingKind::Shortfall,
subject: "ReadWritePaths".to_string(),
detail: "is absent, so the directories the service was installed against are not \
recorded"
.to_string(),
}),
}
}
fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut inside = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
inside = &line[1..line.len() - 1] == section;
continue;
}
if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
out.insert(key.trim().to_string(), value.trim().to_string());
}
}
out
}
fn split_quoted(value: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = value.trim();
while !rest.is_empty() {
if rest.starts_with('"') {
if let Some(parsed) = executable_from_command_line(rest) {
out.push(parsed.to_string_lossy().into_owned());
}
let mut depth = 0usize;
let mut end = rest.len();
for (index, c) in rest.char_indices() {
match c {
'\\' => depth += 1,
'"' => {
if depth.is_multiple_of(2) && index > 0 {
end = index + 1;
break;
}
depth = 0;
}
_ => depth = 0,
}
}
rest = rest[end..].trim_start();
} else {
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
out.push(rest[..end].to_string());
rest = rest[end..].trim_start();
}
}
out
}
pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
Some(xml_unescape(text[start..end].trim()))
}
#[cfg(any(windows, test))]
fn windows_login_task_starts_automatically(document: &str) -> bool {
document.contains("<LogonTrigger>")
&& xml_value(document, "Enabled").as_deref() != Some("false")
}
fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
let marker = format!("<key>{key}</key>");
let start = text.find(&marker)? + marker.len();
Some(text[start..].trim_start())
}
pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
let rest = plist_value_after_key(text, key)?;
if !rest.starts_with("<string>") {
return None;
}
xml_value(rest, "string")
}
fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
let rest = plist_value_after_key(text, key)?;
if rest.starts_with("<true/>") {
Some(true)
} else if rest.starts_with("<false/>") {
Some(false)
} else {
None
}
}
pub const RECORD_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallRecord {
pub schema_version: u32,
pub service_name: String,
pub manager: String,
pub start_mode: StartMode,
pub account: ServiceAccount,
pub binary: PathBuf,
#[serde(default)]
pub source_binary: Option<PathBuf>,
pub arguments: Vec<String>,
pub restart_delay_secs: u64,
pub restart_reset_secs: u64,
pub log_file: PathBuf,
#[serde(default)]
pub starts_on_demand: bool,
pub definition_path: Option<PathBuf>,
pub installed_at: DateTime<Utc>,
pub installed_by_version: String,
pub directories: ServiceDirectories,
}
impl InstallRecord {
#[must_use]
pub fn path(paths: &AppPaths) -> PathBuf {
paths.config_dir().join(RECORD_FILE)
}
#[must_use]
pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
Self {
schema_version: RECORD_SCHEMA_VERSION,
service_name: plan.identity().name().to_string(),
manager: definition.kind().manager().to_string(),
start_mode: plan.start_mode(),
account: plan.account().clone(),
binary: plan.binary().to_path_buf(),
arguments: plan
.arguments()
.iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect(),
restart_delay_secs: plan.restart().delay().as_secs(),
restart_reset_secs: plan.restart().reset_after().as_secs(),
starts_on_demand: plan.is_on_demand(),
source_binary: plan.source_binary().map(Path::to_path_buf),
log_file: plan.directories().log_file(),
definition_path: definition.install_path().map(Path::to_path_buf),
installed_at: at,
installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
directories: plan.directories().clone(),
}
}
#[must_use]
pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
let plan = InstallPlan::unchecked(
identity,
self.start_mode,
self.binary.clone(),
self.directories.clone(),
)
.with_arguments(self.arguments.clone())
.with_restart(self.restart());
let plan = if self.starts_on_demand {
plan.started_on_demand()
} else {
plan
};
match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
Ok(store) => plan.with_secret_guard(store.guard()),
Err(_) => plan,
}
}
#[must_use]
pub fn restart(&self) -> RestartPolicy {
RestartPolicy::new(
Duration::from_secs(self.restart_delay_secs),
Duration::from_secs(self.restart_reset_secs),
)
.unwrap_or_default()
}
pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
let path = Self::path(paths);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(ServiceError::RecordNotPermitted {
path,
detail: error.to_string(),
});
}
Err(error) => {
return Err(ServiceError::Record {
operation: "read",
path,
detail: error.to_string(),
});
}
};
let record: Self =
toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
path: path.clone(),
detail: error.to_string(),
})?;
if record.schema_version != RECORD_SCHEMA_VERSION {
return Err(ServiceError::RecordUnreadable {
path,
detail: format!(
"it declares schema version {} and this build reads version {}",
record.schema_version, RECORD_SCHEMA_VERSION
),
});
}
Ok(Some(record))
}
pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
use std::io::Write as _;
let path = Self::path(paths);
let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
operation: "encode",
path: path.clone(),
detail: error.to_string(),
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
operation: "write",
path: path.clone(),
detail: error.to_string(),
})?;
}
let parent = path.parent().ok_or_else(|| ServiceError::Record {
operation: "write",
path: path.clone(),
detail: "the record path has no parent directory".to_string(),
})?;
let mut temporary =
tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
operation: "write",
path: path.clone(),
detail: error.to_string(),
})?;
temporary
.write_all(text.as_bytes())
.and_then(|()| temporary.as_file().sync_all())
.map_err(|error| ServiceError::Record {
operation: "write",
path: path.clone(),
detail: error.to_string(),
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
temporary
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o644))
.map_err(|error| ServiceError::Record {
operation: "write",
path: path.clone(),
detail: error.to_string(),
})?;
}
temporary
.persist(&path)
.map(|_| ())
.map_err(|error| ServiceError::Record {
operation: "write",
path,
detail: error.error.to_string(),
})
}
pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
let path = Self::path(paths);
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(ServiceError::Record {
operation: "remove",
path,
detail: error.to_string(),
}),
}
}
}
const CONTACT_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ContactRecord {
schema_version: u32,
last_success: DateTime<Utc>,
}
pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
write_state_record(
&contact_path(paths),
&ContactRecord {
schema_version: CONTACT_SCHEMA_VERSION,
last_success: at,
},
)
}
pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
}
#[must_use]
pub fn contact_path(paths: &AppPaths) -> PathBuf {
paths.state_dir().join(CONTACT_FILE)
}
pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
let failed = |detail: String| ServiceError::Record {
operation: "write",
path: path.to_path_buf(),
detail,
};
let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
let directory = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
let temporary = path.with_extension("toml.new");
std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
}
pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
path: &Path,
) -> Result<Option<T>, ServiceError> {
let failed = |detail: String| ServiceError::Record {
operation: "read",
path: path.to_path_buf(),
detail,
};
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(failed(error.to_string())),
};
toml::from_str(&text)
.map(Some)
.map_err(|error| failed(error.to_string()))
}
pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(ServiceError::Record {
operation: "remove",
path: path.to_path_buf(),
detail: error.to_string(),
}),
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CredentialRejectionRecord {
schema_version: u32,
rejected_since: DateTime<Utc>,
}
pub fn record_github_credential_rejected(
paths: &AppPaths,
at: DateTime<Utc>,
) -> Result<(), ServiceError> {
if github_credential_rejected_since(paths)
.ok()
.flatten()
.is_some()
{
return Ok(());
}
write_state_record(
&credential_rejection_path(paths),
&CredentialRejectionRecord {
schema_version: CONTACT_SCHEMA_VERSION,
rejected_since: at,
},
)
}
pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
remove_state_record(&credential_rejection_path(paths))
}
pub fn github_credential_rejected_since(
paths: &AppPaths,
) -> Result<Option<DateTime<Utc>>, ServiceError> {
Ok(
read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
.map(|record| record.rejected_since),
)
}
#[must_use]
pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CredentialUnreadableRecord {
schema_version: u32,
unreadable_since: DateTime<Utc>,
}
pub fn record_credential_unreadable(
paths: &AppPaths,
at: DateTime<Utc>,
) -> Result<(), ServiceError> {
if credential_unreadable_since(paths).ok().flatten().is_some() {
return Ok(());
}
write_state_record(
&credential_unreadable_path(paths),
&CredentialUnreadableRecord {
schema_version: CONTACT_SCHEMA_VERSION,
unreadable_since: at,
},
)
}
pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
remove_state_record(&credential_unreadable_path(paths))
}
pub fn credential_unreadable_since(
paths: &AppPaths,
) -> Result<Option<DateTime<Utc>>, ServiceError> {
Ok(
read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
.map(|record| record.unreadable_since),
)
}
#[must_use]
pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
}
#[must_use]
pub fn sign_in_instruction(mode: StartMode) -> String {
let (command, place) = match mode {
StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
StartMode::Boot => (
"runner-manager auth login --start-at boot",
" from an elevated terminal",
),
StartMode::Login => (
"runner-manager auth login --start-at login",
MACOS_LOGIN_PLACE,
),
};
format!("`{command}`{place}")
}
pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
" in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
keychain)"
} else {
""
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefinitionDrift {
pub path: PathBuf,
pub installed_by_version: String,
pub rendered: String,
pub start_mode: StartMode,
pub manager: DefinitionKind,
}
fn definition_drift(
controls: &dyn ControlFactory,
identity: &ServiceIdentity,
record: &InstallRecord,
) -> Option<DefinitionDrift> {
let compare = match host_definition_kind(record.start_mode) {
DefinitionKind::SystemdUnit => systemd_definition_drift,
DefinitionKind::LaunchdPlist => launchd_definition_drift,
DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
};
let path = record.definition_path.clone()?;
let installed = controls.installed_definition(&path)?;
compare(identity, record, path, &installed)
}
fn launchd_definition_drift(
identity: &ServiceIdentity,
record: &InstallRecord,
path: PathBuf,
installed: &str,
) -> Option<DefinitionDrift> {
(plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
|| DefinitionDrift {
path,
installed_by_version: record.installed_by_version.clone(),
rendered: launchd_plist(&record.plan(identity.clone())),
start_mode: record.start_mode,
manager: DefinitionKind::LaunchdPlist,
},
)
}
fn systemd_definition_drift(
identity: &ServiceIdentity,
record: &InstallRecord,
path: PathBuf,
installed: &str,
) -> Option<DefinitionDrift> {
let rendered = systemd_unit(&record.plan(identity.clone()));
(installed != rendered).then(|| DefinitionDrift {
path,
installed_by_version: record.installed_by_version.clone(),
rendered,
start_mode: record.start_mode,
manager: DefinitionKind::SystemdUnit,
})
}
const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RootRefusalFile {
schema_version: u32,
#[serde(default)]
refusals: BTreeMap<String, RootRefusalEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RootRefusalEntry {
at: DateTime<Utc>,
kind: String,
root: String,
detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunnerRootRefusal {
pub policy: String,
pub at: DateTime<Utc>,
pub kind: String,
pub root: String,
pub detail: String,
}
pub fn record_runner_root_refusal(
paths: &AppPaths,
policy: &str,
at: DateTime<Utc>,
kind: &str,
root: &str,
detail: &str,
) -> Result<(), ServiceError> {
let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
refusals: BTreeMap::new(),
});
file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
file.refusals.insert(
policy.to_owned(),
RootRefusalEntry {
at,
kind: kind.to_owned(),
root: root.to_owned(),
detail: detail.to_owned(),
},
);
write_refusal_file(paths, &file)
}
pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
let Some(mut file) = read_refusal_file(paths)? else {
return Ok(());
};
if file.refusals.remove(policy).is_none() {
return Ok(());
}
if file.refusals.is_empty() {
let path = root_refusal_path(paths);
return match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(ServiceError::Record {
operation: "remove",
path,
detail: error.to_string(),
}),
};
}
write_refusal_file(paths, &file)
}
pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
Ok(read_refusal_file(paths)?
.map(|file| {
file.refusals
.into_iter()
.map(|(policy, entry)| RunnerRootRefusal {
policy,
at: entry.at,
kind: entry.kind,
root: entry.root,
detail: entry.detail,
})
.collect()
})
.unwrap_or_default())
}
fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
read_state_record(&root_refusal_path(paths))
}
fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
write_state_record(&root_refusal_path(paths), file)
}
#[must_use]
pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
paths.state_dir().join(ROOT_REFUSAL_FILE)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BinaryPath {
Current {
path: PathBuf,
},
Missing {
recorded: PathBuf,
},
NotExecutable {
recorded: PathBuf,
detail: String,
},
Diverged {
recorded: PathBuf,
registered: PathBuf,
},
}
impl BinaryPath {
#[must_use]
pub const fn is_error(&self) -> bool {
!matches!(self, Self::Current { .. })
}
#[must_use]
pub fn recorded(&self) -> &Path {
match self {
Self::Current { path } => path,
Self::Missing { recorded }
| Self::NotExecutable { recorded, .. }
| Self::Diverged { recorded, .. } => recorded,
}
}
}
impl fmt::Display for BinaryPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Current { path } => write!(f, "{}", path.display()),
Self::Missing { recorded } => write!(
f,
"{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
package manager that moved the binary is the usual cause; an `npm i -g` \
installation moves with the active Node version. Run `service install` again \
from the binary that is now installed.",
recorded.display()
),
Self::NotExecutable { recorded, detail } => write!(
f,
"{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
from the installed binary.",
recorded.display()
),
Self::Diverged {
recorded,
registered,
} => write!(
f,
"{} -- STALE: the service manager is registered to start {} instead. Something \
has edited the registration since it was installed. Run `service uninstall` and \
`service install`; neither touches configuration, secrets, or the cache.",
recorded.display(),
registered.display()
),
}
}
}
#[must_use]
pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
match std::fs::metadata(recorded) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return BinaryPath::Missing {
recorded: recorded.to_path_buf(),
};
}
Err(error) => {
return BinaryPath::NotExecutable {
recorded: recorded.to_path_buf(),
detail: format!("it cannot be inspected ({error})"),
};
}
Ok(metadata) if !metadata.is_file() => {
return BinaryPath::NotExecutable {
recorded: recorded.to_path_buf(),
detail: "what is there is not a file".to_string(),
};
}
Ok(_) => {}
}
if let Some(registered) = registered
&& !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
{
return BinaryPath::Diverged {
recorded: recorded.to_path_buf(),
registered: registered.to_path_buf(),
};
}
BinaryPath::Current {
path: recorded.to_path_buf(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Registration {
pub manager: DefinitionKind,
pub start_mode: StartMode,
pub command_line: String,
pub account: Option<String>,
pub running: bool,
pub starts_automatically: bool,
pub restart_delay: Option<Duration>,
}
impl Registration {
#[must_use]
pub fn binary(&self) -> Option<PathBuf> {
executable_from_command_line(&self.command_line)
}
}
pub trait ServiceControl: fmt::Debug {
fn manager(&self) -> DefinitionKind;
fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
}
pub trait ControlFactory: fmt::Debug + Send + Sync {
fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
fn installed_definition(&self, _path: &Path) -> Option<String> {
None
}
fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
None
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HostControls;
#[derive(Debug, Clone)]
pub struct Installed {
pub plan: InstallPlan,
pub definition: ServiceDefinition,
pub record: InstallRecord,
pub review: PrivilegeReview,
pub runner_root: RootAccessSummary,
pub replaced_existing: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Uninstalled {
pub removed_registration: bool,
pub removed_record: bool,
pub removed_definition: Option<PathBuf>,
pub preserved: Vec<PathBuf>,
}
impl fmt::Display for Uninstalled {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.removed_registration {
writeln!(f, "The service registration was removed.")?;
} else {
writeln!(f, "There was no service registration to remove.")?;
}
writeln!(f, "Nothing else was deleted. These are untouched:")?;
for path in &self.preserved {
writeln!(f, " {}", path.display())?;
}
write!(
f,
"The stored GitHub token is untouched too; `auth logout` is what purges it."
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartModeChange {
pub from: StartMode,
pub to: StartMode,
pub changed: bool,
pub store_scope: crate::secrets::SecretScope,
pub runner_root: RootAccessSummary,
}
impl fmt::Display for StartModeChange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if !self.changed {
return write!(f, "The service already starts at {}.", self.to);
}
write!(
f,
"The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
if the token was stored under the other scope, run `auth login` again. {}",
self.to, self.from, self.store_scope, self.runner_root
)
}
}
#[derive(Debug, Clone)]
pub struct ServiceOperations {
paths: AppPaths,
identity: ServiceIdentity,
controls: std::sync::Arc<dyn ControlFactory>,
runner_root: Option<LocalAbsolutePath>,
}
impl ServiceOperations {
#[must_use]
pub fn on_this_host(paths: AppPaths) -> Self {
Self::with_controls(
paths,
ServiceIdentity::product(),
std::sync::Arc::new(HostControls),
)
}
#[must_use]
pub fn with_controls(
paths: AppPaths,
identity: ServiceIdentity,
controls: std::sync::Arc<dyn ControlFactory>,
) -> Self {
Self {
paths,
identity,
controls,
runner_root: None,
}
}
#[must_use]
pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
self.runner_root = Some(root);
self
}
#[must_use]
pub const fn paths(&self) -> &AppPaths {
&self.paths
}
#[must_use]
pub const fn identity(&self) -> &ServiceIdentity {
&self.identity
}
pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
self.paths
.create_all()
.map_err(|source| ServiceError::Paths {
source: Box::new(source),
})?;
let _guard = self.refuse_while_an_agent_runs()?;
self.refuse_foreign("replace")?;
let (replacing, previous_was_running) = match self.find_registration()? {
Some((existing, registration)) if existing == request.start_mode() => {
(true, registration.running)
}
Some((existing, _)) => {
return Err(ServiceError::AlreadyInstalled {
name: self.identity.name().to_string(),
existing,
requested: request.start_mode(),
});
}
None => (false, false),
};
let plan = InstallPlan::resolve(
self.identity.clone(),
request,
ServiceDirectories::of(&self.paths),
)?;
let control = self.controls.control(plan.start_mode())?;
let root = self.prepare_runner_root(plan.start_mode())?;
let previous = if replacing {
InstallRecord::read(&self.paths).ok().flatten()
} else {
None
};
if replacing && let Err(cause) = control.uninstall(&self.identity) {
return Err(undo_runner_root(&root, "install", &self.identity, cause));
}
let definition = match control.install(&plan) {
Ok(definition) => definition,
Err(cause) => {
let restored =
self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
return Err(rolled_back(
retained_runner_root(&root),
restored,
"install",
&self.identity,
cause,
));
}
};
let review = review_least_privilege(&definition, &plan);
let record = InstallRecord::of(&plan, &definition, Utc::now());
if let Err(cause) = record.write(&self.paths) {
let rollback = control
.uninstall(&self.identity)
.map(|_| ())
.and_then(|()| {
self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
});
return Err(rolled_back(
retained_runner_root(&root),
rollback,
"install",
&self.identity,
cause,
));
}
if let Err(cause) = control.start(&self.identity) {
let remove_new = control.uninstall(&self.identity).map(|_| ());
let restore_registration = remove_new.and_then(|()| {
self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
});
let restore_record = restore_registration.and_then(|()| match &previous {
Some(previous) => previous.write(&self.paths),
None => InstallRecord::remove(&self.paths).map(|_| ()),
});
return Err(rolled_back(
retained_runner_root(&root),
restore_record,
"install and start",
&self.identity,
cause,
));
}
Ok(Installed {
plan,
definition,
record,
review,
runner_root: root.summary().clone(),
replaced_existing: replacing,
})
}
fn reinstate(
&self,
control: &dyn ServiceControl,
previous: Option<&InstallRecord>,
was_running: bool,
) -> Result<(), ServiceError> {
let Some(record) = previous else {
return Ok(());
};
control.install(&record.plan(self.identity.clone()))?;
if was_running {
control.start(&self.identity)?;
}
Ok(())
}
pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
let record = InstallRecord::read(&self.paths).ok().flatten();
let removed_definition = record
.as_ref()
.and_then(|record| record.definition_path.clone());
let mut removed_registration = false;
let mut foreign = None;
for mode in [StartMode::Boot, StartMode::Login] {
let control = self.controls.control(mode)?;
if let Some(directories) = control
.query(&self.identity)
.ok()
.flatten()
.and_then(|registration| self.foreign_directories(®istration))
{
foreign = Some(directories);
continue;
}
if control.uninstall(&self.identity)? {
removed_registration = true;
}
}
if !removed_registration && let Some(directories) = foreign {
return Err(self.foreign_refusal("uninstall", directories));
}
let removed_record = InstallRecord::remove(&self.paths)?;
Ok(Uninstalled {
removed_registration,
removed_record,
removed_definition: removed_definition.filter(|_| removed_registration),
preserved: self
.paths
.all()
.iter()
.map(|(_, path)| (*path).to_path_buf())
.collect(),
})
}
pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
let Some(record) = InstallRecord::read(&self.paths)? else {
return Err(ServiceError::NotInstalled {
name: self.identity.name().to_string(),
operation: "switch the start mode of",
});
};
let from = record.start_mode;
if from == to {
return Ok(StartModeChange {
from,
to,
changed: false,
store_scope: crate::secrets::SecretScope::for_start_mode(to),
runner_root: RootAccessSummary::NotApplicable,
});
}
self.refuse_foreign("switch the start mode of")?;
#[cfg(windows)]
let arguments = {
let mut arguments = record.arguments.clone();
arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
if to == StartMode::Boot {
arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
}
arguments
};
#[cfg(not(windows))]
let arguments = record.arguments.clone();
let plan = InstallPlan::unchecked(
self.identity.clone(),
to,
record.binary.clone(),
record.directories.clone(),
)
.with_arguments(arguments)
.with_restart(record.restart());
let plan = if record.starts_on_demand {
plan.started_on_demand()
} else {
plan
};
let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
Ok(store) => plan.with_secret_guard(store.guard()),
Err(_) => plan,
};
let target = self.controls.control(to)?;
let previous = self.controls.control(from)?;
let root = self.prepare_runner_root(to)?;
let definition = match target.install(&plan) {
Ok(definition) => definition,
Err(cause) => {
return Err(undo_runner_root(
&root,
"switch start mode",
&self.identity,
cause,
));
}
};
let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
if let Err(cause) = next_record.write(&self.paths) {
return Err(rolled_back(
retained_runner_root(&root),
target.uninstall(&self.identity),
"switch start mode",
&self.identity,
cause,
));
}
if let Err(cause) = previous.uninstall(&self.identity) {
let target_rollback = target.uninstall(&self.identity);
let record_rollback = record.write(&self.paths);
return Err(rolled_back(
retained_runner_root(&root),
target_rollback.and(record_rollback),
"switch start mode",
&self.identity,
cause,
));
}
Ok(StartModeChange {
from,
to,
changed: true,
store_scope: crate::secrets::SecretScope::for_start_mode(to),
runner_root: root.summary().clone(),
})
}
pub fn start(&self) -> Result<(), ServiceError> {
let mode = self.own_registration_mode("start")?;
self.controls.control(mode)?.start(&self.identity)
}
fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
match self.registration_for_status()? {
None => Err(ServiceError::NotInstalled {
name: self.identity.name().to_string(),
operation,
}),
Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
Some((registration, None)) => Ok(registration.start_mode),
}
}
pub fn stop(&self) -> Result<bool, ServiceError> {
let mode = self.own_registration_mode("stop")?;
self.controls.control(mode)?.stop(&self.identity)
}
pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
match self.registration_for_status()? {
Some((registration, Some(directories))) => {
Ok(self.foreign_status(registration, directories))
}
found => {
let (record, record_refused) = match InstallRecord::read(&self.paths) {
Ok(record) => (record, None),
Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
Err(error) => return Err(error),
};
self.status_at(
&self.paths,
record,
record_refused.as_ref(),
found.map(|(registration, _)| registration),
)
}
}
}
fn status_at(
&self,
paths: &AppPaths,
record: Option<InstallRecord>,
record_refused: Option<&ServiceError>,
found: Option<Registration>,
) -> Result<ServiceStatus, ServiceError> {
let last_github_contact = last_github_contact(paths)?;
let drift = record
.as_ref()
.and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
let probed = self.controls.unattended_login();
let unattended = record.as_ref().and_then(|record| {
let account = probed
.as_ref()
.map_or_else(String::new, |_| service_account_name(&record.account));
unattended_gap(record.start_mode, &account, probed.as_ref())
});
let now = Utc::now();
let installed = record.is_some() || found.is_some();
let status = ServiceStatus::compose(
self.identity.clone(),
record,
record_refused,
found,
last_github_contact,
paths,
)
.with_definition_drift(drift)
.with_credential_rejection(credential_rejected_since)
.with_credential_unreadable(credential_unreadable_since)
.with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
.with_unattended_gap(unattended)
.with_host_unfit(
crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
);
let restart = status.restart_command();
Ok(status.with_stall(
crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
))
}
pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
Ok(InstallRecord::read(&self.paths)?
.and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
}
#[cfg(target_os = "macos")]
pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
let root = unsafe { libc::getuid() } == 0;
if root != (drift.start_mode == StartMode::Boot) {
return Err(ServiceError::Control {
operation: "rewrite",
name: self.identity.name().to_string(),
manager: "launchd",
detail: format!(
"the install record says --start-at {}, but this daemon runs as {}; the plist \
is left as it is",
drift.start_mode,
if root { "root" } else { "the operator" }
),
});
}
replace_definition(self.identity.name(), drift)?;
if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
let _ = record.write(&self.paths);
}
sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
}
fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
#[cfg(not(windows))]
{
let _ = (mode, &self.runner_root);
Ok(RootAccessChange::not_applicable())
}
#[cfg(windows)]
{
let wrap = |source| ServiceError::RunnerRoot {
source: Box::new(source),
};
if let Some(root) = self
.runner_root
.as_ref()
.filter(|_| self.identity.is_fixture() || cfg!(test))
{
let admission = RootAdmission::of_this_account().map_err(wrap)?;
return crate::runner_root_access::reconcile(&self.paths, root, &admission)
.map_err(wrap);
}
let admission = match ServiceAccount::for_start_mode(mode) {
ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
ServiceAccount::InvokingUser | ServiceAccount::Root => {
RootAdmission::of_this_account().map_err(wrap)?
}
};
crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
}
}
fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
|source| match source {
held @ LockError::Held { .. } => ServiceError::LockHeld {
source: Box::new(held),
},
other => ServiceError::LockUnreadable {
source: Box::new(other),
},
},
)
}
fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
for mode in [StartMode::Boot, StartMode::Login] {
let control = self.controls.control(mode)?;
if let Some(registration) = control.query(&self.identity)? {
return Ok(Some((registration.start_mode, registration)));
}
}
Ok(None)
}
fn registration_for_status(
&self,
) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
let Some((mode, found)) = self.find_registration()? else {
return Ok(None);
};
let Some(directories) = self.foreign_directories(&found) else {
return Ok(Some((found, None)));
};
let other = match mode {
StartMode::Boot => StartMode::Login,
StartMode::Login => StartMode::Boot,
};
let own = self
.controls
.control(other)
.ok()
.and_then(|control| control.query(&self.identity).ok().flatten())
.filter(|registration| self.foreign_directories(registration).is_none());
Ok(Some(match own {
Some(own) => (own, None),
None => (found, Some(directories)),
}))
}
fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
match self.registration_for_status()? {
Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
_ => Ok(()),
}
}
fn foreign_refusal(
&self,
operation: &'static str,
directories: ServiceDirectories,
) -> ServiceError {
let foreign = self.foreign_service_at(directories);
ServiceError::ForeignInstallation {
name: self.identity.name().to_string(),
operation,
installed_by: foreign.installed_by(),
act_as: foreign.act_as(),
}
}
fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
ServiceDirectories::from_command_line(®istration.command_line)
.filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
}
#[must_use]
pub fn foreign_service(&self) -> Option<ForeignService> {
let (_, directories) = self.registration_for_status().ok()??;
Some(self.foreign_service_at(directories?))
}
fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
let root = directories.root();
let home = host_home();
let this_account = home
.as_deref()
.filter(|home| contains_directory(home, &root))
.and_then(|_| {
if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
Some(format!(
"with `--data-dir {}`",
quote_argument(&root.to_string_lossy())
))
} else {
AppPaths::discover()
.ok()
.filter(|standard| directories == ServiceDirectories::of(standard))
.map(|_| "without `--data-dir`".to_owned())
}
});
ForeignService {
owner: data_root_owner(&root, home.as_deref()),
this_account,
root,
directories,
local_root: ServiceDirectories::of(&self.paths).root(),
local_record: InstallRecord::path(&self.paths),
unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
unreadable: None,
}
}
fn foreign_status(
&self,
registration: Registration,
directories: ServiceDirectories,
) -> ServiceStatus {
let service_paths = directories.paths();
let mut foreign = self.foreign_service_at(directories);
let unreadable = match InstallRecord::read(&service_paths) {
Ok(Some(record)) => {
match self.status_at(
&service_paths,
Some(record),
None,
Some(registration.clone()),
) {
Ok(status) => return status.with_foreign(foreign),
Err(error) => error.to_string(),
}
}
Ok(None) => format!(
"there is no install record at {}",
InstallRecord::path(&service_paths).display()
),
Err(
ServiceError::RecordNotPermitted { path, detail }
| ServiceError::RecordUnreadable { path, detail }
| ServiceError::Record { path, detail, .. },
) => format!("this account cannot read {} ({detail})", path.display()),
Err(error) => error.to_string(),
};
foreign.unreadable = Some(unreadable);
ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
}
pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
let config = self.paths.config_dir();
let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
let mut service_root = None;
for mode in [StartMode::Boot, StartMode::Login] {
let registration = self
.controls
.control(mode)
.and_then(|control| control.query(&self.identity))
.map_err(|error| {
in_use(format!(
"whether a {mode} registration runs against it could not be read ({error})"
))
})?;
let Some(registration) = registration else {
continue;
};
let Some(directories) = self.foreign_directories(®istration) else {
return Err(in_use(format!(
"{} holds a registration that runs against {}. `service status` describes it",
registration.manager,
config.display()
)));
};
if let Some(overlap) = directories.all().into_iter().find(|directory| {
contains_directory(directory, config) || contains_directory(config, directory)
}) {
return Err(in_use(format!(
"the machine's service runs against {}, which overlaps {}",
overlap.display(),
config.display()
)));
}
service_root.get_or_insert_with(|| directories.root());
}
let _lock =
HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
in_use(format!(
"an agent may be running against these directories: {source}"
))
})?;
let failed = |operation: &'static str, path: &Path| {
let path = path.to_path_buf();
move |error: std::io::Error| ServiceError::Record {
operation,
path,
detail: error.to_string(),
}
};
let kept = [
self.paths.state_dir(),
self.paths.runtime_dir(),
self.paths.logs_dir(),
];
let mut entries = Vec::new();
for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
let entry = entry.map_err(failed("list", config))?.path();
let archived = entry
.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
entries.push(entry);
}
}
if entries.is_empty() {
return Ok(ForgottenLocal {
archive: None,
moved: entries,
service_root,
});
}
entries.sort();
let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
for (index, entry) in entries.iter().enumerate() {
if let Err(error) = std::fs::rename(entry, inside(entry)) {
let stranded: Vec<String> = entries[..index]
.iter()
.rev()
.filter(|done| std::fs::rename(inside(done), done).is_err())
.map(|done| inside(done).display().to_string())
.collect();
let _ = std::fs::remove_dir(&archive);
let mut detail = error.to_string();
if !stranded.is_empty() {
detail.push_str(&format!(
"; these could not be put back and are still in the archive: {}",
stranded.join(", ")
));
}
return Err(ServiceError::Record {
operation: "archive",
path: entry.clone(),
detail,
});
}
}
Ok(ForgottenLocal {
archive: Some(archive),
moved: entries,
service_root,
})
}
}
pub const FORGOTTEN_PREFIX: &str = "forgotten-";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForgottenLocal {
pub archive: Option<PathBuf>,
pub moved: Vec<PathBuf>,
pub service_root: Option<PathBuf>,
}
impl fmt::Display for ForgottenLocal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some(archive) = &self.archive else {
return write!(
f,
"Nothing to archive: this account's configuration directory is empty."
);
};
writeln!(f, "Archived this account's unused local configuration")?;
writeln!(f, " archive {}", archive.display())?;
for path in &self.moved {
writeln!(f, " moved {}", path.display())?;
}
if let Some(root) = &self.service_root {
writeln!(
f,
" service data root {} (untouched)",
root.display()
)?;
}
write!(
f,
" restore move everything in the archive back into its parent \
directory"
)
}
}
fn same_directory(left: &Path, right: &Path) -> bool {
let text = |path: &Path| {
path.to_string_lossy()
.trim_end_matches(['/', '\\'])
.to_owned()
};
same_path_text(&text(left), &text(right))
|| matches!(
(std::fs::canonicalize(left), std::fs::canonicalize(right)),
(Ok(left), Ok(right)) if left == right
)
}
const REMEDY_OPENINGS: [&str; 8] = [
". Run ",
". If nothing is waiting",
". Re-register",
". Switch the start mode",
" Sign in again",
". Fix: ",
"; to do it now",
"; if the service manager denies",
];
fn diagnosis_only(detail: &str) -> String {
let cut = REMEDY_OPENINGS
.iter()
.filter_map(|opening| detail.find(opening))
.min()
.unwrap_or(detail.len());
let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
let names_a_change = |text: &str| {
[
"service install",
"service uninstall",
"auth login",
"launchctl",
"systemctl",
]
.iter()
.any(|command| text.contains(command))
};
if !names_a_change(diagnosis) {
return diagnosis.to_owned();
}
let end = [", ", "; ", ". "]
.iter()
.filter_map(|separator| diagnosis.find(separator))
.min()
.unwrap_or(diagnosis.len());
let first = &diagnosis[..end];
if names_a_change(first) {
"the installation's own records report this".to_owned()
} else {
first.to_owned()
}
}
fn path_key(path: &Path) -> Vec<String> {
path.components()
.map(|component| {
let text = component.as_os_str().to_string_lossy();
if cfg!(windows) {
text.to_lowercase()
} else {
text.into_owned()
}
})
.collect()
}
fn contains_directory(outer: &Path, inner: &Path) -> bool {
path_key(inner).starts_with(&path_key(outer))
|| matches!(
(std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
(Ok(outer), Ok(inner)) if inner.starts_with(&outer)
)
}
#[must_use]
pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
let target = path_key(root);
if cfg!(windows) {
if target
.windows(3)
.any(|run| run == ["system32", "config", "systemprofile"])
{
return Some("LocalSystem".to_owned());
}
} else if ["/root", "/var/root", "/private/var/root"]
.iter()
.any(|home| root.starts_with(home))
{
return Some("root".to_owned());
}
let users = path_key(home?.parent()?);
if users.len() < 2 {
return None;
}
if target.len() > users.len() && target[..users.len()] == users[..] {
root.components()
.nth(users.len())
.map(|name| name.as_os_str().to_string_lossy().into_owned())
} else {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForeignService {
pub directories: ServiceDirectories,
pub root: PathBuf,
pub owner: Option<String>,
pub this_account: Option<String>,
pub local_root: PathBuf,
pub local_record: PathBuf,
pub unused_local_record: Option<InstallRecord>,
pub unreadable: Option<String>,
}
impl ForeignService {
#[must_use]
pub fn where_to_look(&self) -> String {
if let Some(reach) = &self.this_account {
return reach.clone();
}
let elevated = if cfg!(windows) {
"from an elevated terminal (Run as administrator)"
} else {
"with sudo"
};
match &self.owner {
Some(owner) if owner != "LocalSystem" && owner != "root" => {
format!("{elevated}, or signed in as {owner}")
}
_ => elevated.to_owned(),
}
}
#[must_use]
pub fn act_as(&self) -> String {
if let Some(reach) = &self.this_account {
return reach.clone();
}
match &self.owner {
Some(owner) if owner != "LocalSystem" && owner != "root" => {
format!("signed in as {owner}")
}
_ => format!(
"from the account whose data root {} is",
self.root.display()
),
}
}
#[must_use]
pub const fn is_unread(&self) -> bool {
self.unreadable.is_some()
}
#[must_use]
pub fn installed_by(&self) -> String {
match &self.owner {
Some(owner) => format!("{owner}'s data root {}", self.root.display()),
None => format!("the data root {}", self.root.display()),
}
}
#[must_use]
pub fn label(&self) -> String {
match &self.owner {
Some(owner) => format!("{} ({owner})", self.root.display()),
None => self.root.display().to_string(),
}
}
#[must_use]
pub fn unused_local_note(&self) -> Option<String> {
let record = self.unused_local_record.as_ref()?;
Some(format!(
"unused local configuration from an earlier install: {} records a {} registration \
made by runner-manager {} on {}, and no service on this machine runs against it. \
`runner-manager host forget-local` moves it aside; it never touches the service's \
own directories or any secret.",
self.local_record.display(),
record.start_mode,
record.installed_by_version,
record.installed_at.format("%Y-%m-%d"),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusProblem {
pub subject: &'static str,
pub detail: String,
}
impl fmt::Display for StatusProblem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.subject, self.detail)
}
}
#[derive(Debug, Clone)]
pub struct ServiceStatus {
identity: ServiceIdentity,
record: Option<InstallRecord>,
registration: Option<Registration>,
binary: Option<BinaryPath>,
log_file: PathBuf,
store: Option<crate::secrets::ActiveStore>,
last_github_contact: Option<DateTime<Utc>>,
runner_root: Option<(PathBuf, RootAccessReport)>,
credential_rejected_since: Option<DateTime<Utc>>,
launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
unattended_gap: Option<UnattendedGap>,
foreign: Option<ForeignService>,
problems: Vec<StatusProblem>,
notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnattendedGap {
pub reason: String,
pub remedy: String,
}
#[must_use]
pub fn service_account_name(account: &ServiceAccount) -> String {
match account {
ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
.ok()
.filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
.or_else(crate::unattended_login::current_account)
.unwrap_or_else(|| "the operator".to_owned()),
other => other.as_str().to_owned(),
}
}
#[must_use]
pub fn unattended_gap(
mode: StartMode,
account: &str,
found: Option<&crate::unattended_login::UnattendedLogin>,
) -> Option<UnattendedGap> {
let Some(found) = found else {
return (mode == StartMode::Login).then(|| UnattendedGap {
reason: "This registration starts at login, so the agent does not run until the \
operator signs in; this host does not resume work after an unattended \
reboot."
.to_owned(),
remedy: "For machine-on operation, move the service and credential to `--start-at \
boot` from an elevated terminal."
.to_owned(),
});
};
let resume = crate::unattended_login::resume(found, mode, account);
if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
return None;
}
Some(UnattendedGap {
reason: resume.explain(account)?,
remedy: resume.remedy().unwrap_or_else(|| {
"Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
FileVault can be read."
.to_owned()
}),
})
}
impl ServiceStatus {
fn compose(
identity: ServiceIdentity,
record: Option<InstallRecord>,
record_refused: Option<&ServiceError>,
registration: Option<Registration>,
last_github_contact: Option<DateTime<Utc>>,
paths: &AppPaths,
) -> Self {
let mut problems = Vec::new();
let mut notes = Vec::new();
if let Some(refusal) = record_refused {
problems.push(StatusProblem {
subject: "install record",
detail: refusal.to_string(),
});
}
let log_file = record.as_ref().map_or_else(
|| ServiceDirectories::of(paths).log_file(),
|record| record.log_file.clone(),
);
let binary = record.as_ref().map(|record| {
inspect_binary(
&record.binary,
registration
.as_ref()
.and_then(Registration::binary)
.as_deref(),
)
});
if let Some(state) = &binary
&& state.is_error()
{
problems.push(StatusProblem {
subject: "binary",
detail: state.to_string(),
});
}
match (&record, ®istration) {
(Some(_), None) => problems.push(StatusProblem {
subject: "registration",
detail: "this host has a service record but no service manager knows the \
registration. Run `service install` again; it deletes nothing."
.to_string(),
}),
(None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
subject: "record",
detail: format!(
"{} holds a registration for this service but there is no install record, so \
the path it was installed from and the directories it was installed against \
are unknown. Run `service uninstall` and `service install`.",
found.manager
),
}),
(None, Some(_)) => {}
(Some(record), Some(found)) => {
if record.start_mode != found.start_mode {
problems.push(StatusProblem {
subject: "start mode",
detail: format!(
"the record says {} and {} holds a {} registration. Switch the start \
mode again to make them agree.",
record.start_mode, found.manager, found.start_mode
),
});
}
if record.start_mode == StartMode::Boot && !found.starts_automatically {
problems.push(StatusProblem {
subject: "start mode",
detail: format!(
"{} holds the registration but will not start it by itself, so this \
host does not resume work after a reboot.",
found.manager
),
});
}
if let Some(actual) = found.restart_delay {
let expected = record.restart().effective_delay(found.manager);
if actual != expected {
problems.push(StatusProblem {
subject: "restart policy",
detail: format!(
"the record says the service restarts after {}s and {} reports \
{}s. Something has edited the registration since it was \
installed.",
expected.as_secs(),
found.manager,
actual.as_secs()
),
});
} else if expected != record.restart().delay() {
notes.push(format!(
"{} expresses the restart delay in whole minutes, so the {}s asked \
for is enforced as {}s. The service therefore never restarts faster \
than the configured bound.",
found.manager,
record.restart().delay().as_secs(),
expected.as_secs()
));
}
}
if found.starts_automatically && !found.running {
problems.push(StatusProblem {
subject: "runtime",
detail: format!(
"{} holds an automatic registration, but the daemon is stopped. Run \
`runner-manager service install --start-at {}`; if the service \
manager denies replacement, retry from an elevated terminal. If it \
stops again, inspect {}.",
found.manager,
found.start_mode,
log_file.display()
),
});
}
}
(None, None) => {}
}
let store = record.as_ref().and_then(|record| {
let registered_mode = registration
.as_ref()
.map_or(record.start_mode, |found| found.start_mode);
crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
.ok()
.map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
});
if let Some(store) = &store
&& !store.agrees_with_start_mode()
{
problems.push(StatusProblem {
subject: "secret store",
detail: format!(
"{store}. Run `auth login` again so the token is stored where the registered \
start mode can read it."
),
});
}
if last_github_contact.is_none() {
notes.push(
"GitHub has not been reached successfully since this host's state directory was \
created."
.to_string(),
);
}
let runner_root = crate::runner_root::default_runner_root(paths)
.ok()
.map(|root| {
let path = root.as_path().to_path_buf();
let report = crate::runner_root_access::report(&path);
(path, report)
});
if let Some((
path,
RootAccessReport::Present {
broad_write: true, ..
},
)) = &runner_root
{
notes.push(format!(
"the platform default runner root {} can be written by ordinary local users, so \
it is not a safe place to run jobs. `service install` refuses it rather than \
tightening it, because the contents of a directory anybody could write cannot \
be trusted: remove or empty it, or choose another root with `runner-manager \
host set-runtime-root --path <PATH>`.",
path.display()
));
}
match runner_root_refusals(paths) {
Ok(refusals) => {
for refusal in refusals {
notes.push(format!(
"policy {} started no runner: its runner root {} refused the launch \
({}), last at {}. {} This clears when that policy next places a \
runner.",
refusal.policy,
refusal.root,
refusal.kind,
refusal.at.to_rfc3339(),
refusal.detail,
));
}
}
Err(error) => notes.push(format!(
"whether the agent could use its runner roots could not be read: {error}"
)),
}
Self {
identity,
record,
registration,
binary,
log_file,
store,
last_github_contact,
runner_root,
credential_rejected_since: None,
launches_blocked: None,
unattended_gap: None,
foreign: None,
problems,
notes,
}
}
fn with_foreign(mut self, foreign: ForeignService) -> Self {
let act_as = foreign.act_as();
for problem in &mut self.problems {
problem.detail = format!(
"{}. Act on it {act_as}: a remedy run from this account acts on this account's \
own directories, not the service's.",
diagnosis_only(&problem.detail)
);
}
if let Some(note) = foreign.unused_local_note() {
self.notes.push(note);
}
self.foreign = Some(foreign);
self
}
fn foreign_unread(
identity: ServiceIdentity,
registration: Registration,
foreign: ForeignService,
) -> Self {
let mut problems = Vec::new();
let look = foreign.where_to_look();
if registration.starts_automatically && !registration.running {
problems.push(StatusProblem {
subject: "runtime",
detail: format!(
"{} holds an automatic registration for the service installed from {}, but \
it is stopped. Run `runner-manager service status` {look} to see why; do not \
reinstall it from this account.",
registration.manager,
foreign.installed_by(),
),
});
}
if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
problems.push(StatusProblem {
subject: "start mode",
detail: format!(
"{} holds the boot registration installed from {} but will not start it by \
itself. Run `runner-manager service status` {look}.",
registration.manager,
foreign.installed_by(),
),
});
}
let mut notes = Vec::new();
if let Some(reason) = &foreign.unreadable {
notes.push(format!(
"its health is not known here: {reason}. Run `runner-manager service status` \
{look} to see it."
));
}
notes.extend(foreign.unused_local_note());
Self {
identity,
record: None,
log_file: foreign.directories.log_file(),
registration: Some(registration),
binary: None,
store: None,
last_github_contact: None,
runner_root: None,
credential_rejected_since: None,
launches_blocked: None,
unattended_gap: None,
foreign: Some(foreign),
problems,
notes,
}
}
#[must_use]
pub const fn foreign(&self) -> Option<&ForeignService> {
self.foreign.as_ref()
}
#[must_use]
pub fn is_unread(&self) -> bool {
self.foreign.as_ref().is_some_and(ForeignService::is_unread)
}
#[must_use]
pub fn verdict(&self) -> String {
if !self.is_healthy() {
return "NOT healthy".to_owned();
}
match &self.foreign {
Some(foreign) if foreign.is_unread() => format!(
"{}; installed from {}, so its health is read {}",
if self.is_running() {
"running"
} else {
"not running"
},
foreign.installed_by(),
foreign.where_to_look()
),
_ => "healthy".to_owned(),
}
}
#[must_use]
pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
if let Some(drift) = drift {
if drift.manager == DefinitionKind::LaunchdPlist {
let restart = match drift.start_mode {
StartMode::Boot => format!(
"sudo launchctl kickstart -k system/{}",
self.identity.launchd_label()
),
StartMode::Login => format!(
"launchctl kickstart -k gui/$(id -u)/{}",
self.identity.launchd_label()
),
};
self.problems.push(StatusProblem {
subject: "definition",
detail: format!(
"{} was written by runner-manager {} with a `ProcessType` other than \
`{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
daemon starts, so their jobs run below normal priority. The daemon \
rewrites it and reloads itself when it starts with no runner, which it \
does after every upgrade; to do it now, restart the service while no \
job is running: `{restart}`. Configuration and credentials are kept.",
drift.path.display(),
drift.installed_by_version,
),
});
return self;
}
let repair = match drift.start_mode {
StartMode::Boot => {
"`sudo systemctl stop runner-manager && sudo runner-manager service install \
--start-at boot` (for a managed WSL host, run `runner-manager wsl install \
--distribution <name>` from Windows)"
}
StartMode::Login => {
"`systemctl --user stop runner-manager && runner-manager service install \
--start-at login`"
}
};
self.problems.push(StatusProblem {
subject: "definition",
detail: format!(
"{} was written by runner-manager {} and differs from what {} renders, so \
the daemon runs under outdated settings (for example a credential frozen \
at service start). Re-register it with {repair}; configuration and \
credentials are kept.",
drift.path.display(),
drift.installed_by_version,
env!("CARGO_PKG_VERSION"),
),
});
}
self
}
#[must_use]
pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
if let Some(since) = since {
self.credential_rejected_since = Some(since);
self.problems.push(StatusProblem {
subject: "github credential",
detail: format!(
"GitHub has rejected the service's credential since {}, so the service \
cannot see queued jobs or start runners. Run `runner-manager auth login` \
(for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
from Windows).",
since.to_rfc3339()
),
});
}
self
}
#[must_use]
pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
self.credential_rejected_since
}
#[must_use]
pub fn with_launches_blocked(
mut self,
blocked: Option<crate::launch_health::LaunchesBlocked>,
) -> Self {
if let Some(blocked) = &blocked {
self.problems.push(StatusProblem {
subject: LAUNCHES_BLOCKED_SUBJECT,
detail: format!(
"the service has started no runner {blocked}. Fix: {}.",
blocked.remedy
),
});
}
self.launches_blocked = blocked;
self
}
#[must_use]
pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
self.launches_blocked.as_ref()
}
#[must_use]
pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
if let Some(since) = since {
let mode = self.start_mode().unwrap_or_default();
self.problems.push(StatusProblem {
subject: CREDENTIAL_UNREADABLE_SUBJECT,
detail: format!(
"the service has been unable to read its stored GitHub credential since {}, \
so it starts no runner; the diagnostic log has the store's own answer.{} \
Sign in again: {}.",
since.to_rfc3339(),
if cfg!(target_os = "macos") {
" That is what a keychain item written by a different build looks like."
} else {
""
},
sign_in_instruction(mode)
),
});
}
self
}
#[must_use]
pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
self.runner_root
.as_ref()
.map(|(path, report)| (path.as_path(), report))
}
#[must_use]
pub const fn is_installed(&self) -> bool {
self.registration.is_some() || self.record.is_some()
}
#[must_use]
pub fn is_running(&self) -> bool {
self.registration
.as_ref()
.is_some_and(|registration| registration.running)
}
#[must_use]
pub fn is_healthy(&self) -> bool {
self.problems.is_empty()
}
#[must_use]
pub fn problems(&self) -> &[StatusProblem] {
&self.problems
}
#[must_use]
pub fn notes(&self) -> &[String] {
&self.notes
}
#[must_use]
pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
if let Some(unfit) = unfit {
self.problems.push(StatusProblem {
subject: HOST_UNFIT_SUBJECT,
detail: format!(
"the service has started no runner since {}, because a required host check \
fails from its own session: {}",
unfit.since.to_rfc3339(),
unfit.describe()
),
});
}
self
}
#[must_use]
pub fn with_stall(mut self, stall: Option<String>) -> Self {
if let Some(detail) = stall {
self.problems.push(StatusProblem {
subject: DAEMON_STALLED_SUBJECT,
detail,
});
}
self
}
#[must_use]
pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
if let Some(gap) = &gap {
self.notes.push(gap.reason.clone());
}
self.unattended_gap = gap;
self
}
#[must_use]
pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
self.unattended_gap.as_ref()
}
#[must_use]
pub fn restart_command(&self) -> String {
self.identity
.restart_command(self.start_mode().unwrap_or(StartMode::Boot))
}
#[must_use]
pub fn start_mode(&self) -> Option<StartMode> {
self.record
.as_ref()
.map(|record| record.start_mode)
.or_else(|| {
self.registration
.as_ref()
.filter(|_| self.is_unread())
.map(|registration| registration.start_mode)
})
}
#[must_use]
pub fn service_binary(&self) -> Option<PathBuf> {
self.record
.as_ref()
.map(|record| record.binary.clone())
.or_else(|| {
self.registration
.as_ref()
.filter(|_| self.is_unread())
.and_then(Registration::binary)
})
}
#[must_use]
pub const fn binary(&self) -> Option<&BinaryPath> {
self.binary.as_ref()
}
#[must_use]
pub fn log_file(&self) -> &Path {
&self.log_file
}
#[must_use]
pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
self.last_github_contact
}
#[must_use]
pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
self.store.as_ref()
}
#[must_use]
pub const fn record(&self) -> Option<&InstallRecord> {
self.record.as_ref()
}
#[must_use]
pub const fn registration(&self) -> Option<&Registration> {
self.registration.as_ref()
}
}
impl fmt::Display for ServiceStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Service: {}", self.identity)?;
match (&self.record, &self.registration) {
(None, None) => {
writeln!(
f,
" installed no. `service install` registers `{} {}`.",
SERVICE_NAME,
DAEMON_ARGUMENTS.join(" ")
)?;
}
_ => {
let manager = self
.registration
.as_ref()
.map(|registration| registration.manager.manager());
writeln!(
f,
" installed {}",
manager.unwrap_or("yes, but no service manager knows it")
)?;
writeln!(
f,
" state {}",
if self.is_running() {
"running"
} else {
"not running"
}
)?;
}
}
if let Some(foreign) = &self.foreign {
writeln!(
f,
" installed from {} -- another installation's, not this account's ({})",
foreign.installed_by(),
foreign.local_root.display()
)?;
if foreign.is_unread()
&& let Some(registration) = &self.registration
{
writeln!(f, " start mode {}", registration.start_mode)?;
if let Some(account) = ®istration.account {
writeln!(f, " account {account}")?;
}
if let Some(binary) = self.service_binary() {
writeln!(f, " binary {}", binary.display())?;
}
}
}
if let Some(record) = &self.record {
writeln!(f, " start mode {}", record.start_mode)?;
writeln!(f, " account {}", record.account)?;
writeln!(f, " restart on failure {}", record.restart())?;
writeln!(
f,
" arguments {}",
record.arguments.join(" ")
)?;
}
if let Some(binary) = &self.binary {
writeln!(f, " binary {binary}")?;
}
writeln!(f, " diagnostic log {}", self.log_file.display())?;
if let Some((path, report)) = &self.runner_root {
writeln!(f, " default runner root {}", path.display())?;
if *report != RootAccessReport::NotApplicable {
writeln!(f, " default root access {report}")?;
}
}
if let Some(store) = &self.store {
writeln!(f, " secret store {store}")?;
}
if !self.is_unread() {
writeln!(
f,
" last GitHub contact {}",
match self.last_github_contact {
Some(at) => at.to_rfc3339(),
None => "never".to_string(),
}
)?;
}
for note in &self.notes {
writeln!(f, " note {note}")?;
}
for problem in &self.problems {
writeln!(f, " ERROR {problem}")?;
}
write!(f, " verdict {}", self.verdict())
}
}
#[derive(Debug, Clone, Default)]
pub struct RecordingControls {
state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
}
#[derive(Debug, Default)]
struct RecordingState {
registrations: BTreeMap<(StartMode, String), Registration>,
definitions: BTreeMap<String, ServiceDefinition>,
calls: Vec<String>,
#[cfg(test)]
install_failures: BTreeMap<StartMode, String>,
#[cfg(test)]
after_install: BTreeMap<StartMode, TestInstallSideEffect>,
}
#[cfg(test)]
#[derive(Debug, Clone)]
enum TestInstallSideEffect {
HideDirectory { directory: PathBuf, hidden: PathBuf },
}
impl RecordingControls {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn calls(&self) -> Vec<String> {
self.state.lock().expect("not poisoned").calls.clone()
}
#[must_use]
pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
self.state
.lock()
.expect("not poisoned")
.registrations
.iter()
.map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
.collect()
}
#[must_use]
pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
self.state
.lock()
.expect("not poisoned")
.definitions
.get(name)
.cloned()
}
pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
let mut state = self.state.lock().expect("not poisoned");
if let Some((_, registration)) = state
.registrations
.iter_mut()
.find(|((_, held), _)| held == name)
{
edit(registration);
}
}
#[cfg(test)]
fn fail_next_install(&self, mode: StartMode, detail: &str) {
self.state
.lock()
.expect("not poisoned")
.install_failures
.insert(mode, detail.to_string());
}
#[cfg(test)]
fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
self.state
.lock()
.expect("not poisoned")
.after_install
.insert(
mode,
TestInstallSideEffect::HideDirectory { directory, hidden },
);
}
}
impl ControlFactory for RecordingControls {
fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
Ok(Box::new(RecordingControl {
mode,
state: std::sync::Arc::clone(&self.state),
}))
}
}
#[derive(Debug)]
struct RecordingControl {
mode: StartMode,
state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
}
impl RecordingControl {
fn note(&self, operation: &str, name: &str) {
self.state
.lock()
.expect("not poisoned")
.calls
.push(format!("{operation} {name} ({})", self.mode));
}
}
impl ServiceControl for RecordingControl {
fn manager(&self) -> DefinitionKind {
host_definition_kind(self.mode)
}
fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
self.note("install", plan.identity().name());
#[cfg(test)]
if let Some(detail) = self
.state
.lock()
.expect("not poisoned")
.install_failures
.remove(&self.mode)
{
return Err(ServiceError::Control {
operation: "install",
name: plan.identity().name().to_string(),
manager: "recording control",
detail,
});
}
let definition = ServiceDefinition::for_host(plan)?;
let mut state = self.state.lock().expect("not poisoned");
state.registrations.insert(
(self.mode, plan.identity().name().to_string()),
Registration {
manager: host_definition_kind(self.mode),
start_mode: self.mode,
command_line: plan.command_line(),
account: Some(plan.account().as_str().to_string()),
running: false,
starts_automatically: true,
restart_delay: Some(plan.restart().delay()),
},
);
state
.definitions
.insert(plan.identity().name().to_string(), definition.clone());
#[cfg(test)]
let side_effect = state.after_install.remove(&self.mode);
drop(state);
#[cfg(test)]
if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
std::fs::write(&directory, b"blocks recreation")
.expect("test fault can block record directory recreation");
}
Ok(definition)
}
fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
self.note("uninstall", identity.name());
let mut state = self.state.lock().expect("not poisoned");
state.definitions.remove(identity.name());
Ok(state
.registrations
.remove(&(self.mode, identity.name().to_string()))
.is_some())
}
fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
self.note("query", identity.name());
Ok(self
.state
.lock()
.expect("not poisoned")
.registrations
.get(&(self.mode, identity.name().to_string()))
.cloned())
}
fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
self.note("start", identity.name());
let mut state = self.state.lock().expect("not poisoned");
match state
.registrations
.get_mut(&(self.mode, identity.name().to_string()))
{
Some(registration) => {
registration.running = true;
Ok(())
}
None => Err(ServiceError::NotInstalled {
name: identity.name().to_string(),
operation: "start",
}),
}
}
fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
self.note("stop", identity.name());
let mut state = self.state.lock().expect("not poisoned");
match state
.registrations
.get_mut(&(self.mode, identity.name().to_string()))
{
Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
None => Err(ServiceError::NotInstalled {
name: identity.name().to_string(),
operation: "stop",
}),
}
}
}
impl ControlFactory for HostControls {
fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
sys::control(mode)
}
fn installed_definition(&self, path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
crate::unattended_login::probe()
}
}
#[must_use]
pub fn host_home() -> Option<PathBuf> {
directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
}
#[cfg(unix)]
fn home_directory() -> Option<PathBuf> {
host_home()
}
fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
let output = std::process::Command::new(program)
.args(arguments)
.output()?;
Ok((
output.status.success(),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
))
}
#[must_use]
pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
if cfg!(windows) {
match mode {
StartMode::Boot => DefinitionKind::WindowsService,
StartMode::Login => DefinitionKind::WindowsScheduledTask,
}
} else if cfg!(target_os = "macos") {
DefinitionKind::LaunchdPlist
} else {
DefinitionKind::SystemdUnit
}
}
#[cfg(any(target_os = "macos", test))]
fn enable_launchd_registration(
mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
domain: &str,
service_target: &str,
plist: &Path,
name: &str,
elevation_remedy: &'static str,
) -> Result<(), ServiceError> {
let (enabled, cause) = launchctl(&[
std::ffi::OsStr::new("enable"),
std::ffi::OsStr::new(service_target),
]);
if enabled {
return Ok(());
}
let (booted_out, bootout_detail) = launchctl(&[
std::ffi::OsStr::new("bootout"),
std::ffi::OsStr::new(service_target),
]);
let removed = std::fs::remove_file(plist);
if !booted_out || removed.is_err() {
return Err(ServiceError::Rollback {
operation: "enable launchd registration",
name: name.to_string(),
cause,
rollback: format!(
"launchctl bootout {domain}: {}; remove {}: {}",
if booted_out {
"succeeded".to_string()
} else {
bootout_detail
},
plist.display(),
removed
.err()
.map_or_else(|| "succeeded".to_string(), |error| error.to_string())
),
});
}
if cause.to_ascii_lowercase().contains("permission denied") {
Err(ServiceError::NeedsElevation {
operation: "enable",
name: name.to_string(),
detail: cause,
remedy: elevation_remedy,
})
} else {
Err(ServiceError::Control {
operation: "enable",
name: name.to_string(),
manager: "launchd",
detail: cause,
})
}
}
#[derive(Debug, Clone)]
pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
impl ServiceShutdown {
pub async fn wait(mut self) {
if !*self.0.borrow() {
let _ = self.0.changed().await;
}
}
}
#[cfg(windows)]
pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
where
F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
{
windows_host::run(Box::new(run))
}
#[cfg(windows)]
mod windows_host {
use std::ffi::OsString;
use std::sync::{Arc, Mutex, OnceLock, mpsc};
use std::time::Duration;
use windows_service::service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
ServiceType,
};
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
};
use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
struct Invocation {
run: Runner,
result: mpsc::SyncSender<Result<u8, String>>,
}
static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
windows_service::define_windows_service!(ffi_service_main, service_main);
pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
let (result_tx, result_rx) = mpsc::sync_channel(1);
let slot = INVOCATION.get_or_init(|| Mutex::new(None));
let mut invocation = slot
.lock()
.map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
if invocation.is_some() {
return Err(host_error(
"prepare",
"the service-host slot was already used",
));
}
*invocation = Some(Invocation {
run,
result: result_tx,
});
drop(invocation);
windows_service::service_dispatcher::start("", ffi_service_main)
.map_err(|error| host_error("connect", &error.to_string()))?;
result_rx
.recv()
.map_err(|error| host_error("finish", &error.to_string()))?
.map_err(|detail| host_error("run", &detail))
}
fn service_main(_arguments: Vec<OsString>) {
let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
return;
};
let result = run_service(invocation.run);
let _ = invocation.result.send(result);
}
fn run_service(run: Runner) -> Result<u8, String> {
let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
let handler_status = Arc::clone(&status);
let handler = move |control| match control {
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
ServiceControl::Stop | ServiceControl::Shutdown => {
if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
let _ = handle.set_service_status(service_status(
ServiceState::StopPending,
ServiceControlAccept::empty(),
1,
Duration::from_secs(300),
0,
));
}
let _ = stop_tx.send(true);
ServiceControlHandlerResult::NoError
}
_ => ServiceControlHandlerResult::NotImplemented,
};
let handle = service_control_handler::register("", handler)
.map_err(|error| format!("cannot register the service control handler: {error}"))?;
*status
.lock()
.map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
handle
.set_service_status(service_status(
ServiceState::Running,
ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
0,
Duration::default(),
0,
))
.map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
let exit = run(ServiceShutdown(stop_rx));
handle
.set_service_status(service_status(
ServiceState::Stopped,
ServiceControlAccept::empty(),
0,
Duration::default(),
u32::from(exit),
))
.map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
Ok(exit)
}
fn service_status(
state: ServiceState,
accepted: ServiceControlAccept,
checkpoint: u32,
wait_hint: Duration,
exit: u32,
) -> ServiceStatus {
ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: accepted,
exit_code: if exit == 0 {
ServiceExitCode::Win32(0)
} else {
ServiceExitCode::ServiceSpecific(exit)
},
checkpoint,
wait_hint,
process_id: None,
}
}
fn host_error(operation: &'static str, detail: &str) -> ServiceError {
ServiceError::Control {
operation,
name: SERVICE_NAME.to_string(),
manager: "the Windows Service Control Manager",
detail: detail.to_string(),
}
}
}
#[cfg(windows)]
mod sys {
use std::ffi::{OsStr, OsString};
use std::time::{Duration, Instant};
use runner_manager_domain::model::StartMode;
use windows_service::service::{
ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
ServiceState, ServiceType,
};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use super::{
DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
};
const SERVICE_DOES_NOT_EXIST: i32 = 1060;
const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
const ACCESS_DENIED: i32 = 5;
const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
choose \"Run as administrator\".";
pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
Ok(match mode {
StartMode::Boot => Box::new(ScmControl),
StartMode::Login => Box::new(TaskControl),
})
}
#[derive(Debug)]
struct ScmControl;
fn scm_error(
operation: &'static str,
name: &str,
error: &windows_service::Error,
) -> ServiceError {
let raw = match error {
windows_service::Error::Winapi(io) => io.raw_os_error(),
_ => None,
};
let detail = match error {
windows_service::Error::Winapi(io) => io.to_string(),
other => other.to_string(),
};
if raw == Some(ACCESS_DENIED) {
return ServiceError::NeedsElevation {
operation,
name: name.to_string(),
detail,
remedy: ELEVATION_REMEDY,
};
}
ServiceError::Control {
operation,
name: name.to_string(),
manager: "the Windows Service Control Manager",
detail,
}
}
fn open_manager(
access: ServiceManagerAccess,
operation: &'static str,
name: &str,
) -> Result<ServiceManager, ServiceError> {
ServiceManager::local_computer(None::<&OsStr>, access)
.map_err(|error| scm_error(operation, name, &error))
}
impl ServiceControl for ScmControl {
fn manager(&self) -> DefinitionKind {
DefinitionKind::WindowsService
}
fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
let spec = windows_service_spec(plan);
let manager = open_manager(
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
"install",
&spec.name,
)?;
let info = ServiceInfo {
name: OsString::from(&spec.name),
display_name: OsString::from(&spec.display_name),
service_type: ServiceType::OWN_PROCESS,
start_type: if spec.automatic_start {
ServiceStartType::AutoStart
} else {
ServiceStartType::OnDemand
},
error_control: ServiceErrorControl::Normal,
executable_path: plan.binary().to_path_buf(),
launch_arguments: plan.arguments().to_vec(),
dependencies: Vec::new(),
account_name: spec.account.as_ref().map(OsString::from),
account_password: None,
};
let service = manager
.create_service(
&info,
ServiceAccess::CHANGE_CONFIG
| ServiceAccess::QUERY_CONFIG
| ServiceAccess::QUERY_STATUS
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::DELETE,
)
.map_err(|error| scm_error("install", &spec.name, &error))?;
service
.set_description(&spec.description)
.map_err(|error| scm_error("describe", &spec.name, &error))?;
service
.update_failure_actions(ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
reboot_msg: None,
command: None,
actions: Some(vec![
ServiceAction {
action_type: ServiceActionType::Restart,
delay: spec.restart.delay(),
};
3
]),
})
.map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
service
.set_failure_actions_on_non_crash_failures(true)
.map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
Ok(ServiceDefinition::windows_service(plan))
}
fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
let manager =
open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
let service = match manager.open_service(
identity.name(),
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
) {
Ok(service) => Some(service),
Err(error) if is_missing(&error) => return Ok(false),
Err(error) if is_marked_for_delete(&error) => None,
Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
};
if let Some(service) = service {
if let Ok(status) = service.query_status()
&& status.current_state != ServiceState::Stopped
{
let _ = service.stop();
}
match service.delete() {
Ok(()) => {}
Err(error) if is_marked_for_delete(&error) => {}
Err(error) => {
return Err(scm_error("uninstall", identity.name(), &error));
}
}
drop(service);
}
let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
Ok(service) => {
drop(service);
Ok(false)
}
Err(error) if is_missing(&error) => Ok(true),
Err(error) if is_marked_for_delete(&error) => Ok(false),
Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
}
})?;
if !absent {
return Err(ServiceError::Control {
operation: "verify uninstall of",
name: identity.name().to_string(),
manager: "the Windows Service Control Manager",
detail: format!(
"the registration was still visible {} seconds after DeleteService; \
retry `service uninstall` from an elevated prompt",
DELETE_TIMEOUT.as_secs()
),
});
}
Ok(true)
}
fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
let service = match manager.open_service(
identity.name(),
ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
) {
Ok(service) => service,
Err(error) if is_missing(&error) => return Ok(None),
Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
};
let config = service
.query_config()
.map_err(|error| scm_error("inspect", identity.name(), &error))?;
let status = service
.query_status()
.map_err(|error| scm_error("inspect", identity.name(), &error))?;
let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
actions
.actions
.and_then(|actions| actions.into_iter().next())
.filter(|action| action.action_type == ServiceActionType::Restart)
.map(|action| action.delay)
});
Ok(Some(Registration {
manager: DefinitionKind::WindowsService,
start_mode: StartMode::Boot,
command_line: config.executable_path.to_string_lossy().into_owned(),
account: config
.account_name
.map(|account| account.to_string_lossy().into_owned()),
running: status.current_state == ServiceState::Running,
starts_automatically: config.start_type == ServiceStartType::AutoStart,
restart_delay,
}))
}
fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
let service = manager
.open_service(identity.name(), ServiceAccess::START)
.map_err(|error| scm_error("start", identity.name(), &error))?;
service
.start::<&OsStr>(&[])
.map_err(|error| scm_error("start", identity.name(), &error))
}
fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
let service = manager
.open_service(
identity.name(),
ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
)
.map_err(|error| scm_error("stop", identity.name(), &error))?;
let status = service
.query_status()
.map_err(|error| scm_error("stop", identity.name(), &error))?;
if status.current_state == ServiceState::Stopped {
return Ok(false);
}
service
.stop()
.map_err(|error| scm_error("stop", identity.name(), &error))?;
Ok(true)
}
}
fn is_missing(error: &windows_service::Error) -> bool {
matches!(error, windows_service::Error::Winapi(io)
if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
}
pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
matches!(error, windows_service::Error::Winapi(io)
if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
}
pub(super) fn wait_until_scm_absent(
timeout: Duration,
poll_interval: Duration,
mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
) -> Result<bool, ServiceError> {
let deadline = Instant::now() + timeout;
loop {
if probe_absent()? {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(poll_interval);
}
}
#[derive(Debug)]
struct TaskControl;
fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
if detail.to_ascii_lowercase().contains("access is denied") {
return ServiceError::NeedsElevation {
operation,
name: name.to_string(),
detail,
remedy: ELEVATION_REMEDY,
};
}
ServiceError::Control {
operation,
name: name.to_string(),
manager: "Windows Task Scheduler",
detail,
}
}
fn schtasks(
operation: &'static str,
name: &str,
arguments: &[&OsStr],
) -> Result<(bool, String), ServiceError> {
match run("schtasks.exe", arguments) {
Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
Err(error) => Err(task_error(operation, name, error.to_string())),
}
}
fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
let mut bytes = vec![0xFF, 0xFE];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
std::fs::write(path, bytes)
}
impl ServiceControl for TaskControl {
fn manager(&self) -> DefinitionKind {
DefinitionKind::WindowsScheduledTask
}
fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
let name = plan.identity().name().to_string();
let principal = TaskPrincipal::current()?;
let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
let directory = tempfile::tempdir()
.map_err(|error| task_error("install", &name, error.to_string()))?;
let document = directory.path().join("task.xml");
write_utf16(&document, definition.text())
.map_err(|error| task_error("install", &name, error.to_string()))?;
let (ok, message) = schtasks(
"install",
&name,
&[
OsStr::new("/Create"),
OsStr::new("/TN"),
OsStr::new(&name),
OsStr::new("/XML"),
document.as_os_str(),
],
)?;
if !ok {
return Err(task_error("install", &name, message));
}
Ok(definition)
}
fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
if self.query(identity)?.is_none() {
return Ok(false);
}
let name = identity.name().to_string();
let (ok, message) = schtasks(
"uninstall",
&name,
&[
OsStr::new("/Delete"),
OsStr::new("/TN"),
OsStr::new(&name),
OsStr::new("/F"),
],
)?;
if !ok {
return Err(task_error("uninstall", &name, message));
}
Ok(true)
}
fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
let name = identity.name().to_string();
let (ok, document) = schtasks(
"inspect",
&name,
&[
OsStr::new("/Query"),
OsStr::new("/TN"),
OsStr::new(&name),
OsStr::new("/XML"),
OsStr::new("ONE"),
],
)?;
if !ok {
return Ok(None);
}
let command = xml_value(&document, "Command").unwrap_or_default();
let arguments = xml_value(&document, "Arguments").unwrap_or_default();
let command_line = if arguments.is_empty() {
super::quote_argument(&command)
} else {
format!("{} {arguments}", super::quote_argument(&command))
};
Ok(Some(Registration {
manager: DefinitionKind::WindowsScheduledTask,
start_mode: StartMode::Login,
command_line,
account: xml_value(&document, "UserId"),
running: task_is_running(&name),
starts_automatically: super::windows_login_task_starts_automatically(&document),
restart_delay: xml_value(&document, "Interval")
.as_deref()
.and_then(parse_iso8601),
}))
}
fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
let name = identity.name().to_string();
let (ok, message) = schtasks(
"start",
&name,
&[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
)?;
if ok {
Ok(())
} else {
Err(task_error("start", &name, message))
}
}
fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
let running = self
.query(identity)?
.is_some_and(|registration| registration.running);
if !running {
return Ok(false);
}
let name = identity.name().to_string();
let (ok, message) = schtasks(
"stop",
&name,
&[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
)?;
if ok {
Ok(true)
} else {
Err(task_error("stop", &name, message))
}
}
}
fn task_is_running(name: &str) -> bool {
let Ok((true, stdout, _)) = run(
"schtasks.exe",
&[
OsStr::new("/Query"),
OsStr::new("/TN"),
OsStr::new(name),
OsStr::new("/FO"),
OsStr::new("CSV"),
OsStr::new("/NH"),
],
) else {
return false;
};
stdout
.lines()
.filter_map(|line| line.rsplit(',').next())
.any(|status| {
status
.trim()
.trim_matches('"')
.eq_ignore_ascii_case("running")
})
}
fn parse_iso8601(value: &str) -> Option<Duration> {
let rest = value.strip_prefix("PT")?;
if let Some(minutes) = rest.strip_suffix('M') {
return minutes
.parse::<u64>()
.ok()
.map(|minutes| Duration::from_secs(minutes * 60));
}
rest.strip_suffix('S')?
.parse::<u64>()
.ok()
.map(Duration::from_secs)
}
}
#[cfg(unix)]
fn write_definition(
operation: &'static str,
name: &str,
path: &Path,
text: &str,
remedy: &'static str,
) -> Result<(), ServiceError> {
if let Some(parent) = path.parent()
&& let Err(error) = std::fs::create_dir_all(parent)
&& error.kind() != std::io::ErrorKind::AlreadyExists
{
return Err(definition_error(operation, name, error, remedy, parent));
}
std::fs::write(path, text)
.map_err(|error| definition_error(operation, name, error, remedy, path))
}
#[cfg(all(unix, any(target_os = "macos", test)))]
fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
use std::io::Write as _;
let path = drift.path.as_path();
let directory = path.parent().unwrap_or_else(|| Path::new("/"));
let failed = |error: std::io::Error, at: &Path| {
definition_error("rewrite", name, error, SUDO_REMEDY, at)
};
let mut staged =
tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
staged
.write_all(drift.rendered.as_bytes())
.and_then(|()| staged.as_file().sync_all())
.map_err(|error| failed(error, staged.path()))?;
std::fs::set_permissions(
staged.path(),
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
)
.map_err(|error| failed(error, path))?;
staged
.persist(path)
.map(drop)
.map_err(|error| failed(error.error, path))
}
#[cfg(unix)]
fn definition_error(
operation: &'static str,
name: &str,
error: std::io::Error,
remedy: &'static str,
path: &Path,
) -> ServiceError {
let detail = format!("{}: {error}", path.display());
if error.kind() == std::io::ErrorKind::PermissionDenied {
ServiceError::NeedsElevation {
operation,
name: name.to_string(),
detail,
remedy,
}
} else {
ServiceError::Control {
operation,
name: name.to_string(),
manager: "the local service manager",
detail,
}
}
}
#[cfg(unix)]
const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
`sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
agent not running until you sign in.";
#[cfg(target_os = "macos")]
mod sys {
use std::ffi::OsStr;
use std::path::PathBuf;
use std::time::Duration;
use runner_manager_domain::model::StartMode;
use super::{
DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
write_definition, xml_value,
};
pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
Ok(Box::new(LaunchdControl { mode }))
}
#[derive(Debug)]
struct LaunchdControl {
mode: StartMode,
}
impl LaunchdControl {
fn domain(&self) -> String {
match self.mode {
StartMode::Boot => "system".to_string(),
StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
}
}
fn service_target(&self, identity: &ServiceIdentity) -> String {
format!("{}/{}", self.domain(), identity.launchd_label())
}
fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
let file = format!("{}.plist", identity.launchd_label());
match self.mode {
StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
StartMode::Login => {
home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
}
}
}
fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
ServiceError::Control {
operation,
name: name.to_string(),
manager: "launchd",
detail,
}
}
fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
match run("launchctl", arguments) {
Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
Err(error) => (false, error.to_string()),
}
}
}
impl ServiceControl for LaunchdControl {
fn manager(&self) -> DefinitionKind {
DefinitionKind::LaunchdPlist
}
fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
let name = plan.identity().name().to_string();
let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
return Err(self.failed(
"install",
&name,
"this account has no home directory, so there is nowhere to put a \
LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
/Library/LaunchDaemons."
.to_string(),
));
};
write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
let target = self.domain();
let (ok, message) = self.launchctl(&[
OsStr::new("bootstrap"),
OsStr::new(&target),
path.as_os_str(),
]);
if !ok {
let _ = std::fs::remove_file(&path);
if message.to_ascii_lowercase().contains("permission denied") {
return Err(ServiceError::NeedsElevation {
operation: "install",
name,
detail: message,
remedy: SUDO_REMEDY,
});
}
return Err(self.failed("install", &name, message));
}
let service_target = self.service_target(plan.identity());
enable_launchd_registration(
|arguments| self.launchctl(arguments),
&target,
&service_target,
&path,
&name,
SUDO_REMEDY,
)?;
Ok(definition)
}
fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
let Some(path) = self.plist_path(identity) else {
return Ok(false);
};
if !path.exists() {
return Ok(false);
}
let target = self.service_target(identity);
let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
if !ok
&& !message.to_ascii_lowercase().contains("no such process")
&& !message.contains("113")
{
if message.to_ascii_lowercase().contains("permission denied") {
return Err(ServiceError::NeedsElevation {
operation: "uninstall",
name: identity.name().to_string(),
detail: message,
remedy: SUDO_REMEDY,
});
}
return Err(self.failed("uninstall", identity.name(), message));
}
std::fs::remove_file(&path).map_err(|error| {
super::definition_error(
"uninstall",
identity.name(),
error,
SUDO_REMEDY,
path.as_path(),
)
})?;
Ok(true)
}
fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
let Some(path) = self.plist_path(identity) else {
return Ok(None);
};
let Ok(document) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let target = self.service_target(identity);
let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
Ok(Some(Registration {
manager: DefinitionKind::LaunchdPlist,
start_mode: self.mode,
command_line: program_arguments(&document),
account: plist_string_value(&document, "UserName")
.or_else(|| Some("the invoking user".to_string())),
running: loaded && printed.contains("state = running"),
starts_automatically: document.contains("<key>RunAtLoad</key>")
&& super::plist_bool_value(&document, "RunAtLoad") == Some(true),
restart_delay: xml_value(
super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
"integer",
)
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_secs),
}))
}
fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
let target = self.service_target(identity);
let (ok, message) = self.launchctl(&[
OsStr::new("kickstart"),
OsStr::new("-k"),
OsStr::new(&target),
]);
if ok {
Ok(())
} else {
Err(self.failed("start", identity.name(), message))
}
}
fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
let running = self
.query(identity)?
.is_some_and(|registration| registration.running);
if !running {
return Ok(false);
}
let target = self.service_target(identity);
let (ok, message) = self.launchctl(&[
OsStr::new("kill"),
OsStr::new("SIGTERM"),
OsStr::new(&target),
]);
if ok {
Ok(true)
} else {
Err(self.failed("stop", identity.name(), message))
}
}
}
pub(super) fn reload_detached(
identity: &ServiceIdentity,
mode: StartMode,
plist: &std::path::Path,
) -> Result<(), ServiceError> {
use std::os::unix::process::CommandExt;
const SCRIPT: &str = r#"(
sleep 1
/bin/launchctl bootout "$1"
tries=0
until /bin/launchctl bootstrap "$2" "$3"; do
tries=$((tries + 1))
if [ "$tries" -ge 60 ]; then
echo "runner-manager: launchd did not load $3 again; run: $4" >&2
exit 1
fi
sleep 1
done
) </dev/null &"#;
let control = LaunchdControl { mode };
let reinstall = match mode {
StartMode::Boot => "sudo runner-manager service install --start-at boot",
StartMode::Login => "runner-manager service install --start-at login",
};
let mut command = std::process::Command::new("/bin/sh");
command
.args(["-c", SCRIPT, "runner-manager-reload"])
.arg(control.service_target(identity))
.arg(control.domain())
.arg(plist)
.arg(reinstall)
.stdin(std::process::Stdio::null());
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let status = command.status().map_err(|error| {
control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
})?;
if status.success() {
Ok(())
} else {
Err(control.failed(
"reload",
identity.name(),
format!("the reload helper exited with {status}"),
))
}
}
fn program_arguments(document: &str) -> String {
let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
return String::new();
};
let Some(end) = rest.find("</array>") else {
return String::new();
};
let mut out = Vec::new();
let mut cursor = &rest[..end];
while let Some(open) = cursor.find("<string>") {
let after = &cursor[open + "<string>".len()..];
let Some(close) = after.find("</string>") else {
break;
};
out.push(quote_argument(&super::xml_unescape(&after[..close])));
cursor = &after[close..];
}
out.join(" ")
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod sys {
use std::ffi::OsStr;
use std::path::PathBuf;
use std::time::Duration;
use runner_manager_domain::model::StartMode;
use super::{
DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
home_directory, ini_directives, run, write_definition,
};
pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
Ok(Box::new(SystemdControl { mode }))
}
#[derive(Debug)]
struct SystemdControl {
mode: StartMode,
}
impl SystemdControl {
fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
let file = identity.systemd_unit();
match self.mode {
StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
StartMode::Login => {
home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
}
}
}
fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
if self.mode == StartMode::Login {
all.push(OsStr::new("--user"));
}
all.extend(arguments.iter().map(OsStr::new));
match run("systemctl", &all) {
Ok((ok, stdout, stderr)) => (
ok,
if stdout.trim().is_empty() {
stderr
} else {
stdout
},
),
Err(error) => (false, error.to_string()),
}
}
fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
ServiceError::Control {
operation,
name: name.to_string(),
manager: "systemd",
detail,
}
}
}
impl ServiceControl for SystemdControl {
fn manager(&self) -> DefinitionKind {
DefinitionKind::SystemdUnit
}
fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
let name = plan.identity().name().to_string();
let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
return Err(self.failed(
"install",
&name,
"this account has no home directory, so there is nowhere to put a systemd \
user unit. Use --start-at boot, which installs a system unit under \
/etc/systemd/system."
.to_string(),
));
};
write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
let unit = plan.identity().systemd_unit();
let (reloaded, message) = self.systemctl(&["daemon-reload"]);
if !reloaded {
let _ = std::fs::remove_file(&path);
return Err(self.failed("install", &name, message));
}
let (enabled, message) = self.systemctl(&["enable", &unit]);
if !enabled {
let _ = std::fs::remove_file(&path);
let _ = self.systemctl(&["daemon-reload"]);
return Err(self.failed("install", &name, message));
}
Ok(definition)
}
fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
let Some(path) = self.unit_path(identity) else {
return Ok(false);
};
if !path.exists() {
return Ok(false);
}
let unit = identity.systemd_unit();
let _ = self.systemctl(&["disable", "--now", &unit]);
std::fs::remove_file(&path).map_err(|error| {
super::definition_error(
"uninstall",
identity.name(),
error,
SUDO_REMEDY,
path.as_path(),
)
})?;
let _ = self.systemctl(&["daemon-reload"]);
Ok(true)
}
fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
let Some(path) = self.unit_path(identity) else {
return Ok(None);
};
let Ok(document) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let unit = identity.systemd_unit();
let directives = ini_directives(&document, "Service");
let (_, active) = self.systemctl(&["is-active", &unit]);
let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
Ok(Some(Registration {
manager: DefinitionKind::SystemdUnit,
start_mode: self.mode,
command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
account: directives.get("User").cloned().or_else(|| {
Some(match self.mode {
StartMode::Boot => "root".to_string(),
StartMode::Login => "the invoking user".to_string(),
})
}),
running: active.trim() == "active",
starts_automatically: enabled.trim() == "enabled",
restart_delay: directives
.get("RestartSec")
.and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
.map(Duration::from_secs),
}))
}
fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
let unit = identity.systemd_unit();
let (ok, message) = self.systemctl(&["start", &unit]);
if ok {
Ok(())
} else {
Err(self.failed("start", identity.name(), message))
}
}
fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
let running = self
.query(identity)?
.is_some_and(|registration| registration.running);
if !running {
return Ok(false);
}
let unit = identity.systemd_unit();
let (ok, message) = self.systemctl(&["stop", &unit]);
if ok {
Ok(true)
} else {
Err(self.failed("stop", identity.name(), message))
}
}
}
}
#[cfg(all(test, target_os = "macos"))]
pub(crate) mod launchd_fixture {
use std::path::{Path, PathBuf};
const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
pub(crate) fn enabled() -> bool {
let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
if !enabled {
eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
}
enabled
}
pub(crate) fn job_binary(directory: &Path) -> PathBuf {
let binary = directory.join("launch-agent-job");
std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
.expect("test binary copied");
binary
}
pub(crate) struct LoadedAgent(String);
impl LoadedAgent {
pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
let domain = format!("gui/{}", unsafe { libc::getuid() });
let loaded = Self(format!("{domain}/{label}"));
let bootstrap = std::process::Command::new("launchctl")
.arg("bootstrap")
.arg(&domain)
.arg(plist)
.output()
.expect("launchctl runs");
assert!(
bootstrap.status.success(),
"launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
String::from_utf8_lossy(&bootstrap.stderr)
);
loaded
}
pub(crate) fn target(&self) -> &str {
&self.0
}
}
impl Drop for LoadedAgent {
fn drop(&mut self) {
let _ = std::process::Command::new("launchctl")
.args(["bootout", &self.0])
.output();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn task_scheduler_omitting_default_enabled_still_means_automatic() {
assert!(windows_login_task_starts_automatically(
"<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
));
assert!(windows_login_task_starts_automatically(
"<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
));
assert!(!windows_login_task_starts_automatically(
"<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
));
assert!(!windows_login_task_starts_automatically(
"<Task><BootTrigger></BootTrigger></Task>"
));
}
fn linux_plan(mode: StartMode) -> InstallPlan {
InstallPlan::unchecked(
ServiceIdentity::product(),
mode,
"/opt/runner-manager/bin/runner-manager",
ServiceDirectories {
config: PathBuf::from("/var/lib/runner-manager/config"),
state: PathBuf::from("/var/lib/runner-manager/state"),
runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
logs: PathBuf::from("/var/log/runner-manager"),
},
)
.with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
}
fn windows_plan(mode: StartMode) -> InstallPlan {
InstallPlan::unchecked(
ServiceIdentity::product(),
mode,
"C:\\Program Files\\runner-manager\\runner-manager.exe",
ServiceDirectories {
config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
},
)
}
fn edited(text: &str, from: &str, to: &str) -> String {
assert!(
text.contains(from),
"the rendered definition does not contain `{from}`, so this test would assert \
nothing about a widened one"
);
text.replace(from, to)
}
fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else if let Ok(bytes) = std::fs::read(&path) {
out.insert(path, bytes);
}
}
}
let mut out = BTreeMap::new();
for root in roots {
walk(root, &mut out);
}
out
}
struct Host {
_root: tempfile::TempDir,
paths: AppPaths,
binary: PathBuf,
runner_root: LocalAbsolutePath,
controls: RecordingControls,
}
impl Host {
fn new() -> Self {
let root = tempfile::tempdir().expect("a temporary directory");
let paths = AppPaths::rooted_at(root.path());
paths.create_all().expect("the four directories");
let binary = root.path().join(if cfg!(windows) {
"runner-manager.exe"
} else {
"runner-manager"
});
std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
let runner_root = LocalAbsolutePath::new(
root.path()
.join("runner-root")
.to_str()
.expect("a unicode temporary path"),
)
.expect("a local absolute path");
Self {
_root: root,
paths,
binary,
runner_root,
controls: RecordingControls::new(),
}
}
fn operations(&self) -> ServiceOperations {
ServiceOperations::with_controls(
self.paths.clone(),
ServiceIdentity::product(),
std::sync::Arc::new(self.controls.clone()),
)
.with_runner_root(self.runner_root.clone())
}
fn request(&self, mode: StartMode) -> InstallRequest {
InstallRequest::new(mode).for_binary(&self.binary)
}
}
#[cfg(windows)]
#[test]
fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
let probes = std::cell::Cell::new(0);
let absent =
super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
let next = probes.get() + 1;
probes.set(next);
Ok(next == 3)
})
.expect("the simulated SCM probe succeeds");
assert!(absent);
assert_eq!(
probes.get(),
3,
"uninstall must recheck after transient presence instead of treating it as a leak"
);
}
#[cfg(windows)]
#[test]
fn windows_recognises_an_already_pending_service_deletion() {
let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
assert!(super::sys::is_marked_for_delete(&error));
}
#[test]
fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
let root = tempfile::tempdir().expect("a temporary directory");
let plist = root.path().join("fixture.plist");
std::fs::write(&plist, b"fixture").expect("a plist fixture");
let calls = std::cell::RefCell::new(Vec::new());
let error = enable_launchd_registration(
|arguments| {
let call = arguments
.iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let operation = call[0].clone();
calls.borrow_mut().push(call);
if operation == "enable" {
(false, "label remains disabled".to_string())
} else {
(true, String::new())
}
},
"system",
"system/com.openai.runner-manager-selftest",
&plist,
"runner-manager-selftest",
"rerun with administrative rights",
)
.expect_err("enable failure must fail the install");
assert!(
matches!(
error,
ServiceError::Control {
operation: "enable",
..
}
),
"{error}"
);
assert_eq!(calls.borrow().len(), 2);
assert_eq!(calls.borrow()[0][0], "enable");
assert_eq!(calls.borrow()[1][0], "bootout");
assert!(!plist.exists(), "rollback must remove the plist");
}
#[test]
fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
let plan = windows_plan(StartMode::Boot);
let command_line = plan.command_line();
assert!(
command_line.starts_with('"'),
"a path with a space must be quoted, got {command_line}"
);
assert_eq!(
executable_from_command_line(&command_line).as_deref(),
Some(plan.binary())
);
}
#[test]
fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
let plan = linux_plan(StartMode::Boot);
let command_line = plan.command_line();
assert!(!command_line.starts_with('"'), "got {command_line}");
assert_eq!(
executable_from_command_line(&command_line).as_deref(),
Some(plan.binary())
);
}
#[test]
fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
let awkward = r#"C:\odd "name"\rm.exe"#;
let quoted = quote_argument(awkward);
assert_eq!(
executable_from_command_line(&format!("{quoted} daemon run"))
.as_deref()
.map(Path::to_string_lossy)
.as_deref(),
Some(awkward)
);
}
#[test]
fn an_empty_command_line_has_no_executable() {
assert_eq!(executable_from_command_line(" "), None);
assert_eq!(executable_from_command_line(""), None);
}
#[test]
fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
let awkward = r#"DOMAIN\R&D <team> "ops""#;
assert_eq!(
xml_escape(awkward),
"DOMAIN\\R&D <team> "ops""
);
assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
}
#[test]
fn a_restart_delay_under_the_floor_is_refused() {
let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
.expect_err("half a second is under the one-second floor");
assert!(
matches!(error, ServiceError::RestartDelay { .. }),
"{error}"
);
}
#[test]
fn a_restart_delay_over_the_ceiling_is_refused() {
let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
.expect_err("an hour is over the five-minute ceiling");
assert!(
matches!(error, ServiceError::RestartDelay { .. }),
"{error}"
);
}
#[test]
fn a_reset_window_no_longer_than_the_delay_is_refused() {
let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
.expect_err("a window equal to the delay can never elapse between restarts");
assert!(
matches!(error, ServiceError::RestartResetWindow { .. }),
"{error}"
);
}
#[test]
fn a_delay_inside_the_bound_is_accepted() {
let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
.expect("twenty seconds is inside the bound");
assert_eq!(policy.delay(), Duration::from_secs(20));
assert_eq!(policy.reset_after(), Duration::from_secs(300));
}
#[test]
fn a_fixture_identity_can_never_be_the_product_identity() {
let fixture = ServiceIdentity::fixture("abc123");
assert!(fixture.is_fixture());
assert!(!ServiceIdentity::product().is_fixture());
assert_ne!(fixture.name(), ServiceIdentity::product().name());
assert_ne!(
fixture.launchd_label(),
ServiceIdentity::product().launchd_label()
);
assert_ne!(
fixture.systemd_unit(),
ServiceIdentity::product().systemd_unit()
);
}
#[test]
fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
let fixture = ServiceIdentity::fixture("A b/c:\\d");
assert!(
fixture
.name()
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"got {}",
fixture.name()
);
}
#[test]
fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
let unit = systemd_unit(&linux_plan(StartMode::Boot));
assert!(unit.contains("KillMode=process\n"), "{unit}");
assert!(unit.contains("Restart=on-failure\n"), "{unit}");
assert!(unit.contains("RestartSec=15\n"), "{unit}");
assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
}
#[test]
fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
let unit = systemd_unit(&linux_plan(StartMode::Boot));
assert!(
!unit.contains("LoadCredential="),
"a startup snapshot would shadow every rotated credential until restart:\n{unit}"
);
}
#[test]
fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
let unit = systemd_unit(&linux_plan(StartMode::Login));
assert!(
!unit.contains("LoadCredential="),
"a user unit must not name a root-owned credential file, got:\n{unit}"
);
assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
}
#[test]
fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
let plan = linux_plan(StartMode::Boot);
let unit = systemd_unit(&plan);
let directives = ini_directives(&unit, "Service");
let listed = split_quoted(
directives
.get("ReadWritePaths")
.expect("the unit names its writable paths"),
);
assert_eq!(listed.len(), 5, "{listed:?}");
for path in plan.directories().all() {
assert!(
listed.iter().any(|entry| entry == &path.to_string_lossy()),
"{} is missing from {listed:?}",
path.display()
);
}
assert!(
listed
.iter()
.any(|entry| entry == "/var/lib/runner-manager/secrets"),
"atomic credential replacement needs its parent directory in {listed:?}"
);
}
#[test]
fn the_unit_records_the_absolute_binary_path() {
let plan = linux_plan(StartMode::Boot);
let unit = systemd_unit(&plan);
assert!(
unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
"{unit}"
);
}
#[test]
fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
let plist = launchd_plist(&linux_plan(StartMode::Boot));
assert!(
plist.contains(
"<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
),
"{plist}"
);
assert!(
plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
"{plist}"
);
}
#[test]
fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
let daemon = launchd_plist(&linux_plan(StartMode::Boot));
assert_eq!(
plist_string_value(&daemon, "UserName").as_deref(),
Some("root")
);
assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
let agent = launchd_plist(&linux_plan(StartMode::Login));
assert_eq!(
plist_string_value(&agent, "UserName"),
None,
"a LaunchAgent already runs as the operator:\n{agent}"
);
}
#[test]
fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
for mode in [StartMode::Boot, StartMode::Login] {
let plan = linux_plan(mode);
let plist = launchd_plist(&plan);
assert_eq!(
plist_string_value(&plist, "ProcessType").as_deref(),
Some("Interactive"),
"launchd applies ProcessType to every runner the daemon starts:\n{plist}"
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
&plan,
);
assert!(
review
.controls()
.iter()
.any(|control| control == "ProcessType=Interactive"),
"{review}"
);
assert!(
!review
.findings()
.iter()
.any(|finding| finding.subject == "ProcessType"),
"{review}"
);
}
}
#[test]
fn a_plist_that_throttles_its_runners_is_a_shortfall() {
let plan = linux_plan(StartMode::Login);
let rendered = launchd_plist(&plan);
let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
for (label, throttled) in [
(
"Background",
edited(
&rendered,
interactive,
"<key>ProcessType</key>\n <string>Background</string>\n",
),
),
("absent", edited(&rendered, interactive, "")),
] {
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
&plan,
);
assert!(review.is_least_privilege(), "{label}: {review}");
assert!(
review.findings().iter().any(|finding| {
finding.kind == FindingKind::Shortfall
&& finding.subject == "ProcessType"
&& finding.detail.contains(label)
}),
"{label}: {review}"
);
}
}
#[test]
fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
let host = Host::new();
let identity = ServiceIdentity::product();
let plan = InstallPlan::resolve(
identity.clone(),
&host.request(StartMode::Login),
ServiceDirectories::of(&host.paths),
)
.expect("a resolvable plan");
let installed = launchd_plist(&plan);
let definition =
ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
let mut record = InstallRecord::of(&plan, &definition, Utc::now());
record.installed_by_version = "0.4.31".into();
let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
assert_eq!(
launchd_definition_drift(&identity, &record, path.clone(), &installed),
None
);
let edited_by_operator = edited(
&installed,
"</dict>\n</plist>\n",
" <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
);
assert_eq!(
launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
None,
"the daemon repairs this drift itself, so it must not key on edits it did not cause"
);
let written_by_0_4_31 = edited(
&installed,
"<string>Interactive</string>",
"<string>Background</string>",
);
let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
.expect("a Background plist is drift");
assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
assert_eq!(drift.start_mode, StartMode::Login);
assert_eq!(
drift.rendered, installed,
"the repair must write exactly what `service install` writes"
);
let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
.with_definition_drift(Some(drift));
let problem = status
.problems()
.iter()
.find(|problem| problem.subject == "definition")
.expect("the drift is reported");
assert!(
problem.detail.contains("0.4.31")
&& problem.detail.contains(
"launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
)
&& !problem.detail.contains("service install")
&& !problem.detail.contains("systemctl")
&& !problem.detail.contains("sudo"),
"{}",
problem.detail
);
}
#[cfg(unix)]
#[test]
fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
use std::os::unix::fs::PermissionsExt as _;
let root = tempfile::tempdir().expect("a temporary directory");
let path = root
.path()
.join("io.github.IvanMurzak.runner-manager.plist");
std::fs::write(&path, "<string>Background</string>").expect("old plist");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
let drift = DefinitionDrift {
path: path.clone(),
installed_by_version: "0.4.31".into(),
rendered: launchd_plist(&linux_plan(StartMode::Login)),
start_mode: StartMode::Login,
manager: DefinitionKind::LaunchdPlist,
};
replace_definition("runner-manager", &drift).expect("replaced");
assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o644
);
assert_eq!(
std::fs::read_dir(root.path()).unwrap().count(),
1,
"no staging file is left beside the plist"
);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "run only by launchd, as the job of the detached-reload test"]
fn detached_reload_job() {
let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
let tag = label
.split_once("-selftest-")
.map(|(_, tag)| tag.to_string())
.unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
let identity = ServiceIdentity::fixture(&tag);
assert_eq!(identity.launchd_label(), label);
let path = std::env::current_dir()
.expect("working directory")
.join(format!("{label}.plist"));
let installed = std::fs::read_to_string(&path).expect("own plist");
if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
let drift = DefinitionDrift {
path: path.clone(),
installed_by_version: "0.4.31".into(),
rendered: installed.replace(
"<string>Background</string>",
&format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
),
start_mode: StartMode::Login,
manager: DefinitionKind::LaunchdPlist,
};
replace_definition(identity.name(), &drift).expect("plist replaced");
super::sys::reload_detached(&identity, StartMode::Login, &path)
.expect("helper started");
}
std::thread::sleep(Duration::from_secs(120));
}
#[cfg(target_os = "macos")]
#[test]
fn a_job_reloads_itself_from_its_rewritten_plist() {
if !super::launchd_fixture::enabled() {
return;
}
let root = tempfile::tempdir().expect("a temporary directory");
let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
let label = identity.launchd_label();
let path = root.path().join(format!("{label}.plist"));
let binary = super::launchd_fixture::job_binary(root.path());
let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
plist.push_str("<plist version=\"1.0\">\n<dict>\n");
plist.push_str(&plist_string("Label", &label));
plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
for argument in [
binary.to_string_lossy().as_ref(),
"--ignored",
"--exact",
"service::tests::detached_reload_job",
"--test-threads=1",
] {
plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
}
plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
plist.push_str(&plist_string(
"WorkingDirectory",
&root.path().to_string_lossy(),
));
plist.push_str(&plist_string("ProcessType", "Background"));
plist.push_str("</dict>\n</plist>\n");
std::fs::write(&path, plist).expect("plist written");
let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
let spawn_type = || {
let printed = std::process::Command::new("launchctl")
.args(["print", loaded.target()])
.output()
.expect("launchctl runs");
String::from_utf8_lossy(&printed.stdout)
.lines()
.find_map(|line| {
line.trim()
.strip_prefix("spawn type = ")
.map(str::to_string)
})
};
assert_eq!(spawn_type().as_deref(), Some("background (5)"));
let deadline = std::time::Instant::now() + Duration::from_secs(90);
while spawn_type().as_deref() != Some("interactive (4)") {
assert!(
std::time::Instant::now() < deadline,
"the job did not come back under its rewritten plist; launchd reports {:?}",
spawn_type()
);
std::thread::sleep(Duration::from_millis(250));
}
let rewritten = std::fs::read_to_string(&path).expect("plist readable");
assert_eq!(
plist_string_value(&rewritten, "ProcessType").as_deref(),
Some(LAUNCHD_PROCESS_TYPE)
);
}
#[test]
fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
let plist = launchd_plist(&linux_plan(StartMode::Boot));
assert!(
plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
"{plist}"
);
assert!(plist.contains("<string>daemon</string>"), "{plist}");
assert!(plist.contains("<string>run</string>"), "{plist}");
}
#[test]
fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
assert_eq!(
ServiceIdentity::product().launchd_label(),
"io.github.IvanMurzak.runner-manager"
);
}
#[test]
fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
let xml = windows_scheduled_task_xml(
&windows_plan(StartMode::Login),
&TaskPrincipal::named("HOST\\operator"),
);
assert!(xml.contains("<LogonTrigger>"), "{xml}");
assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
assert!(
xml.contains("<LogonType>InteractiveToken</LogonType>"),
"{xml}"
);
assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
assert!(
xml.contains("<Interval>PT1M</Interval>"),
"Task Scheduler takes whole minutes only, and rejects the registration outright \
for anything finer:\n{xml}"
);
}
#[test]
fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
let policy =
RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
.expect("inside the supported range");
assert_eq!(
policy
.effective_delay(DefinitionKind::WindowsScheduledTask)
.as_secs(),
enforced,
"a {asked}s delay must be enforced as {enforced}s"
);
assert!(
policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
"rounding must never shorten the bound"
);
}
}
#[test]
fn every_other_manager_enforces_the_delay_exactly_as_configured() {
let policy = RestartPolicy::default();
for kind in [
DefinitionKind::WindowsService,
DefinitionKind::LaunchdPlist,
DefinitionKind::SystemdUnit,
] {
assert_eq!(
policy.effective_delay(kind),
policy.delay(),
"{kind:?} takes seconds and enforces exactly what it is given"
);
}
}
#[test]
fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Login))
.expect("an install at login");
host.controls.edit("runner-manager", |registration| {
registration.manager = DefinitionKind::WindowsScheduledTask;
registration.restart_delay = Some(Duration::from_secs(60));
});
let status = operations.status().expect("a status");
assert!(
status.is_healthy(),
"minute granularity is the manager's, not a mis-registration: {status}"
);
assert!(
status
.notes()
.iter()
.any(|note| note.contains("whole minutes")),
"but the operator must be told why 15 became 60: {status}"
);
host.controls.edit("runner-manager", |registration| {
registration.restart_delay = Some(Duration::from_secs(1));
});
assert!(
!operations.status().expect("a status").is_healthy(),
"a one-second delay is not what any manager was asked for"
);
}
#[test]
fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
let plan = windows_plan(StartMode::Login);
let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
assert_eq!(
xml_value(&xml, "Command").as_deref(),
Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
"{xml}"
);
assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
}
#[test]
fn an_account_name_holding_xml_punctuation_is_escaped() {
let xml = windows_scheduled_task_xml(
&windows_plan(StartMode::Login),
&TaskPrincipal::named("R&D\\ops"),
);
assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
}
#[test]
fn the_service_starts_automatically_under_the_account_the_store_admits() {
let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
let directives = ini_directives(&text, "windows-service");
assert_eq!(
directives.get("StartType").map(String::as_str),
Some("AutoStart")
);
assert_eq!(
directives.get("Account").map(String::as_str),
Some("NT AUTHORITY\\SYSTEM")
);
assert_eq!(
directives.get("ServiceType").map(String::as_str),
Some("OWN_PROCESS")
);
assert_eq!(
directives
.get("FailureActionRestartDelaySecs")
.map(String::as_str),
Some("15")
);
assert_eq!(
directives
.get("FailureActionsOnNonCrashFailures")
.map(String::as_str),
Some("true"),
"without this flag a non-zero exit is not a failure the manager restarts"
);
}
#[test]
fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
let spec = windows_service_spec(&windows_plan(StartMode::Boot));
assert_eq!(spec.account, None);
assert!(spec.automatic_start);
assert!(
spec.command_line.contains("daemon run"),
"{}",
spec.command_line
);
}
#[test]
fn each_definition_goes_where_its_platform_expects_it() {
let home = PathBuf::from("/home/op");
assert_eq!(
ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
Some(Path::new(
"/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
))
);
assert_eq!(
ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
Some(Path::new(
"/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
))
);
assert_eq!(
ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
Some(Path::new("/etc/systemd/system/runner-manager.service"))
);
assert_eq!(
ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
Some(Path::new(
"/home/op/.config/systemd/user/runner-manager.service"
))
);
assert_eq!(
ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
None,
"the Service Control Manager has no file"
);
}
#[test]
fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
assert_eq!(
ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
None
);
assert_eq!(
ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
None
);
}
#[test]
fn the_rendered_definitions_are_all_least_privilege() {
let linux = linux_plan(StartMode::Boot);
let windows = windows_plan(StartMode::Boot);
for (definition, plan) in [
(ServiceDefinition::systemd(&linux, None), &linux),
(ServiceDefinition::launchd(&linux, None), &linux),
(ServiceDefinition::windows_service(&windows), &windows),
] {
let review = review_least_privilege(&definition, plan);
assert!(
review.is_least_privilege(),
"{:?} should be least privilege, got:\n{review}",
definition.kind()
);
assert!(
!review.controls().is_empty(),
"a review that confirms nothing proves nothing: {:?}",
definition.kind()
);
}
}
#[test]
fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
let plan = windows_plan(StartMode::Login);
let definition =
ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
let review = review_least_privilege(&definition, &plan);
assert!(review.is_least_privilege(), "{review}");
assert!(
review
.controls()
.iter()
.any(|control| control.contains("LeastPrivilege")),
"{review}"
);
}
#[test]
fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
let plan = linux_plan(StartMode::Boot);
let rendered = systemd_unit(&plan);
let widened = edited(
&rendered,
"ReadWritePaths=/var/lib/runner-manager/config",
"ReadWritePaths=/etc /var/lib/runner-manager/config",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
assert!(
review
.excesses()
.iter()
.any(|finding| finding.detail.contains("/etc")),
"the review must name the directory it objects to: {review}"
);
}
#[test]
fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
let plan = linux_plan(StartMode::Boot);
let rendered = systemd_unit(&plan);
let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
assert!(
review
.excesses()
.iter()
.any(|finding| finding.subject == "NoNewPrivileges"),
"{review}"
);
}
#[test]
fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
let plan = linux_plan(StartMode::Boot);
let rendered = systemd_unit(&plan);
let widened = edited(
&rendered,
"CapabilityBoundingSet=\n",
"CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
assert!(
review
.excesses()
.iter()
.any(|finding| finding.subject == "CapabilityBoundingSet"),
"{review}"
);
}
#[test]
fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
let plan = linux_plan(StartMode::Boot);
let rendered = systemd_unit(&plan);
let widened = edited(
&rendered,
"[Install]",
"ListenStream=127.0.0.1:9000\n\n[Install]",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
&plan,
);
assert!(
!review.is_least_privilege(),
"07-security.md rule 2 forbids any inbound surface: {review}"
);
}
#[test]
fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
let plan = linux_plan(StartMode::Boot);
let rendered = systemd_unit(&plan);
let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
&plan,
);
assert!(
review.is_least_privilege(),
"too little authority is not an excess: {review}"
);
assert!(
review
.findings()
.iter()
.any(|finding| finding.kind == FindingKind::Shortfall
&& finding.detail.contains("runtime")),
"{review}"
);
}
#[test]
fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
let plan = linux_plan(StartMode::Login);
let rendered = launchd_plist(&plan);
let widened = edited(
&rendered,
"<key>ProcessType</key>",
"<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
assert!(
review
.excesses()
.iter()
.any(|finding| finding.subject == "UserName"),
"{review}"
);
}
#[test]
fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
let plan = linux_plan(StartMode::Boot);
let rendered = launchd_plist(&plan);
let widened = edited(
&rendered,
"<key>SessionCreate</key>\n <false/>",
"<key>SessionCreate</key>\n <true/>",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
assert!(
review
.excesses()
.iter()
.any(|finding| finding.subject == "SessionCreate"),
"{review}"
);
}
#[test]
fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
let plan = linux_plan(StartMode::Boot);
let rendered = launchd_plist(&plan);
let widened = edited(
&rendered,
"<key>ProcessType</key>",
"<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
}
#[test]
fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
let plan = windows_plan(StartMode::Login);
let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
let widened = edited(
&rendered,
"<RunLevel>LeastPrivilege</RunLevel>",
"<RunLevel>HighestAvailable</RunLevel>",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
assert!(
review
.excesses()
.iter()
.any(|finding| finding.subject == "RunLevel"),
"{review}"
);
}
#[test]
fn a_task_that_would_store_a_password_is_not_least_privilege() {
let plan = windows_plan(StartMode::Login);
let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
let widened = edited(
&rendered,
"<LogonType>InteractiveToken</LogonType>",
"<LogonType>Password</LogonType>",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
}
#[test]
fn an_interactive_windows_service_is_not_least_privilege() {
let plan = windows_plan(StartMode::Boot);
let rendered = windows_service_descriptor(&plan);
let widened = edited(
&rendered,
"ServiceType=OWN_PROCESS",
"ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
&plan,
);
assert!(!review.is_least_privilege(), "{review}");
assert!(
review
.excesses()
.iter()
.any(|finding| finding.subject == "ServiceType"),
"{review}"
);
}
#[test]
fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
let plan = windows_plan(StartMode::Boot);
let rendered = windows_service_descriptor(&plan);
let changed = edited(
&rendered,
"Account=NT AUTHORITY\\SYSTEM",
"Account=NT AUTHORITY\\LocalService",
);
let review = review_least_privilege(
&ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
&plan,
);
assert!(review.is_least_privilege(), "{review}");
assert!(
review.findings().iter().any(|finding| {
finding.kind == FindingKind::Shortfall && finding.subject == "Account"
}),
"{review}"
);
}
#[test]
fn a_recorded_path_that_is_still_there_is_current() {
let host = Host::new();
let state = inspect_binary(&host.binary, Some(&host.binary));
assert!(!state.is_error(), "{state}");
assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
}
#[test]
fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
let host = Host::new();
let recorded = host.binary.clone();
let healthy = inspect_binary(&recorded, Some(&recorded));
assert!(
!healthy.is_error(),
"the discriminator: before the binary moves, this must be healthy"
);
std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
let state = inspect_binary(&recorded, Some(&recorded));
assert!(state.is_error(), "{state}");
assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
assert!(
state.to_string().contains("npm"),
"the message must name the cause an operator will not otherwise connect: {state}"
);
}
#[test]
fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
let root = tempfile::tempdir().expect("a temporary directory");
let state = inspect_binary(root.path(), None);
assert!(state.is_error(), "{state}");
assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
}
#[test]
fn a_registration_naming_a_different_binary_is_a_divergence() {
let host = Host::new();
let other = host.binary.with_file_name("something-else");
let state = inspect_binary(&host.binary, Some(&other));
assert!(state.is_error(), "{state}");
assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
}
#[test]
fn absence_is_reported_before_divergence() {
let host = Host::new();
let recorded = host.binary.clone();
std::fs::remove_file(&recorded).expect("removable");
let other = recorded.with_file_name("something-else");
assert!(matches!(
inspect_binary(&recorded, Some(&other)),
BinaryPath::Missing { .. }
));
}
#[test]
fn the_record_round_trips_through_toml() {
let host = Host::new();
let plan = InstallPlan::resolve(
ServiceIdentity::product(),
&host.request(StartMode::Boot),
ServiceDirectories::of(&host.paths),
)
.expect("a resolvable plan");
let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
let record = InstallRecord::of(&plan, &definition, Utc::now());
record.write(&host.paths).expect("a writable record");
let read = InstallRecord::read(&host.paths)
.expect("a readable record")
.expect("a record is there");
assert_eq!(read, record);
assert_eq!(read.binary, host.binary);
assert!(read.binary.is_absolute());
}
#[test]
fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
let host = Host::new();
let identity = ServiceIdentity::product();
let plan = InstallPlan::resolve(
identity.clone(),
&host.request(StartMode::Boot),
ServiceDirectories::of(&host.paths),
)
.expect("a resolvable plan");
let installed = systemd_unit(&plan);
let definition =
ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
let mut record = InstallRecord::of(&plan, &definition, Utc::now());
let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
assert_eq!(
systemd_definition_drift(&identity, &record, path.clone(), &installed),
None,
"the record must rebuild exactly the unit `service install` wrote"
);
record.installed_by_version = "0.4.7".into();
let frozen = installed.replace(
"[Service]\n",
"[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
);
let drift = systemd_definition_drift(&identity, &record, path, &frozen)
.expect("an outdated unit is drift");
assert_eq!(drift.installed_by_version, "0.4.7");
assert_eq!(drift.rendered, installed);
let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
.with_definition_drift(Some(drift));
assert!(!status.is_healthy());
assert!(
status
.problems()
.iter()
.any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
"{status}"
);
}
#[test]
fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
let host = Host::new();
assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
let first = Utc::now() - chrono::Duration::hours(9);
record_github_credential_rejected(&host.paths, first).unwrap();
record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
let since = github_credential_rejected_since(&host.paths).unwrap();
assert_eq!(since, Some(first));
let status = ServiceStatus::compose(
ServiceIdentity::product(),
None,
None,
None,
None,
&host.paths,
)
.with_credential_rejection(since);
assert!(!status.is_healthy());
assert_eq!(status.credential_rejected_since(), Some(first));
assert!(status.to_string().contains("auth login"), "{status}");
clear_github_credential_rejection(&host.paths).unwrap();
clear_github_credential_rejection(&host.paths).unwrap();
assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
}
#[test]
fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
use crate::launch_health::{
BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
record_launches_blocked,
};
let host = Host::new();
let compose = || {
ServiceStatus::compose(
ServiceIdentity::product(),
None,
None,
None,
None,
&host.paths,
)
.with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
};
assert!(
!compose()
.problems()
.iter()
.any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
);
let blocked = BlockCause::FenceHeld {
directory: PathBuf::from("launch-fence"),
owner: None,
}
.blocked_since(Utc::now(), Viewpoint::Guest);
record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
let status = compose();
assert!(!status.is_healthy());
assert_eq!(status.launches_blocked(), Some(&blocked));
let problem = status
.problems()
.iter()
.find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
.expect("reported under its own subject");
assert!(problem.detail.contains("systemctl restart"), "{status}");
clear_launches_blocked(&host.paths).unwrap();
assert_eq!(compose().launches_blocked(), None);
}
#[test]
fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
let host = Host::new();
assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
let first = Utc::now() - chrono::Duration::minutes(40);
record_credential_unreadable(&host.paths, first).unwrap();
record_credential_unreadable(&host.paths, Utc::now()).unwrap();
let since = credential_unreadable_since(&host.paths).unwrap();
assert_eq!(since, Some(first), "a restart loop must not move the start");
let status = ServiceStatus::compose(
ServiceIdentity::product(),
None,
None,
None,
None,
&host.paths,
)
.with_credential_unreadable(since);
assert!(!status.is_healthy());
let problem = status
.problems()
.iter()
.find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
.expect("reported under its own subject");
assert!(
problem
.detail
.contains(&sign_in_instruction(StartMode::default())),
"{status}"
);
clear_credential_unreadable(&host.paths).unwrap();
clear_credential_unreadable(&host.paths).unwrap();
assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
let healthy = ServiceStatus::compose(
ServiceIdentity::product(),
None,
None,
None,
None,
&host.paths,
)
.with_credential_unreadable(None);
assert!(
!healthy
.problems()
.iter()
.any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
"{healthy}"
);
}
#[test]
fn the_sign_in_instruction_names_the_start_mode_it_writes() {
assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
assert_eq!(
sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
cfg!(unix),
"a machine-scoped store needs root on Unix"
);
assert_eq!(
sign_in_instruction(StartMode::Login).contains("SSH"),
cfg!(target_os = "macos"),
"only the macOS login keychain is out of an SSH session's reach"
);
}
#[cfg(unix)]
#[test]
fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
use std::os::unix::fs::PermissionsExt as _;
let host = Host::new();
let plan = InstallPlan::resolve(
ServiceIdentity::product(),
&host.request(StartMode::Boot),
ServiceDirectories::of(&host.paths),
)
.expect("a resolvable plan");
let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
InstallRecord::of(&plan, &definition, Utc::now())
.write(&host.paths)
.expect("a writable record");
let mode = std::fs::metadata(InstallRecord::path(&host.paths))
.expect("the record is there")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o644,
"the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
service install` wrote, and `service status` fails on their own host. It holds no \
credential and sits in a 0700 directory, so 0644 discloses nothing"
);
}
#[test]
fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
let host = Host::new();
let path = InstallRecord::path(&host.paths);
std::fs::write(
&path,
format!(
"schema_version = {RECORD_SCHEMA_VERSION}
service_name = \"runner-manager\"
manager = \"systemd\"
start_mode = \"boot\"
account = \"root\"
binary = \"/x\"
arguments = []
restart_delay_secs = 15
restart_reset_secs = 600
log_file = \"/x\"
installed_at = \"2026-01-01T00:00:00Z\"
installed_by_version = \"0.1.0\"
[directories]
config = \"/a\"
state = \"/b\"
runtime = \"/c\"
logs = \"/d\"
"
),
)
.expect("a writable record");
let read = InstallRecord::read(&host.paths)
.expect("a record missing an optional field is still readable")
.expect("a record is there");
assert_eq!(
read.source_binary, None,
"the legacy layout has no source, and must not invent one"
);
}
#[test]
fn a_registration_remembers_the_file_it_was_copied_from() {
let host = Host::new();
let source = host.binary.with_file_name("npm-installed-runner-manager");
std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
let plan = InstallPlan::resolve(
ServiceIdentity::product(),
&host.request(StartMode::Boot).copied_from(&source),
ServiceDirectories::of(&host.paths),
)
.expect("a resolvable plan");
let definition = ServiceDefinition::from_text(
DefinitionKind::SystemdUnit,
"[Service]
",
);
let record = InstallRecord::of(&plan, &definition, Utc::now());
record.write(&host.paths).expect("a writable record");
let read = InstallRecord::read(&host.paths)
.expect("a readable record")
.expect("a record is there");
assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
assert_ne!(
read.source_binary.as_deref(),
Some(read.binary.as_path()),
"the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
);
}
#[test]
fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
let host = Host::new();
let path = InstallRecord::path(&host.paths);
std::fs::write(
&path,
format!(
"schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
[directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
RECORD_SCHEMA_VERSION + 1
),
)
.expect("a writable record");
let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
assert!(
matches!(error, ServiceError::RecordUnreadable { .. }),
"{error}"
);
assert!(
error.to_string().contains("service uninstall"),
"the message must say how to recover: {error}"
);
}
#[test]
fn no_record_is_not_an_error() {
let host = Host::new();
assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
}
#[test]
fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
let host = Host::new();
assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
}
#[test]
fn the_heartbeat_round_trips_to_the_second() {
let host = Host::new();
let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
.expect("a valid timestamp")
.with_timezone(&Utc);
record_github_contact(&host.paths, at).expect("a writable heartbeat");
assert_eq!(
last_github_contact(&host.paths).expect("readable"),
Some(at)
);
}
#[test]
fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
let host = Host::new();
std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
let error = last_github_contact(&host.paths)
.expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
assert!(matches!(error, ServiceError::Record { .. }), "{error}");
}
#[test]
fn a_runner_root_refusal_round_trips_for_service_status() {
let host = Host::new();
let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
assert!(
runner_root_refusals(&host.paths)
.expect("readable")
.is_empty(),
"no record means every policy is placing runners"
);
record_runner_root_refusal(
&host.paths,
"policy-a",
at,
"denied_by_privacy_policy",
"/Volumes/NVME/runners",
"the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
)
.expect("a writable record");
let refusals = runner_root_refusals(&host.paths).expect("readable");
assert_eq!(refusals.len(), 1);
assert_eq!(refusals[0].policy, "policy-a");
assert_eq!(refusals[0].at, at);
assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
assert!(
refusals[0].detail.contains("/Volumes/NVME/runners")
&& refusals[0].detail.contains("Full Disk Access"),
"the path and the remediation are the whole point of this file: {refusals:?}"
);
}
#[test]
fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
let host = Host::new();
let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
record_runner_root_refusal(
&host.paths,
"broken",
at,
"denied_by_privacy_policy",
"/Volumes/NVME/runners",
"detail",
)
.expect("a writable record");
record_runner_root_refusal(
&host.paths,
"also-broken",
at,
"not_writable",
"/srv/other",
"detail",
)
.expect("a writable record");
clear_runner_root_refusal(&host.paths, "healthy")
.expect("clearing an absent policy is fine");
clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
let refusals = runner_root_refusals(&host.paths).expect("readable");
assert_eq!(
refusals
.iter()
.map(|r| r.policy.as_str())
.collect::<Vec<_>>(),
vec!["broken"],
"the policy that is still refused must keep its record"
);
}
#[test]
fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
let host = Host::new();
record_runner_root_refusal(
&host.paths,
"p",
Utc::now(),
"not_writable",
"/srv/x",
"detail",
)
.expect("a writable record");
clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
assert!(
!root_refusal_path(&host.paths).exists(),
"a host with nothing refused leaves nothing behind"
);
clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
}
#[test]
fn a_malformed_refusal_is_an_error_and_not_silently_none() {
let host = Host::new();
std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
let error = runner_root_refusals(&host.paths)
.expect_err("an unparseable record is not the same as no record");
assert!(matches!(error, ServiceError::Record { .. }), "{error}");
}
#[test]
fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
let host = Host::new();
record_runner_root_refusal(
&host.paths,
"policy-a",
DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
"denied_by_privacy_policy",
"/Volumes/NVME/runners",
"Grant Full Disk Access to the program that runs the service",
)
.expect("a writable record");
let status = host.operations().status().expect("a readable status");
let notes = status.notes().join("\n");
assert!(
notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
"the directory and the remediation the log had to scrub must appear here: {notes}"
);
assert!(
notes.contains("policy-a"),
"the operator has to know which target placed no runner: {notes}"
);
assert!(
!status
.problems()
.iter()
.any(|problem| problem.subject == "runner root"),
"a record nothing an operator types can clear must not drive the exit code"
);
}
#[test]
fn install_records_the_absolute_binary_path_and_the_four_directories() {
let host = Host::new();
let installed = host
.operations()
.install(&host.request(StartMode::Boot))
.expect("an install against the recording controls");
assert_eq!(installed.record.binary, host.binary);
assert!(installed.record.binary.is_absolute());
assert_eq!(installed.record.start_mode, StartMode::Boot);
assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
assert_eq!(
installed.record.directories,
ServiceDirectories::of(&host.paths)
);
assert_eq!(
installed.record.log_file,
host.paths.logs_dir().join(LOG_FILE_STEM)
);
assert_eq!(installed.record.restart_delay_secs, 15);
let registrations = host.controls.registrations();
assert_eq!(registrations.len(), 1);
assert_eq!(registrations[0].0, StartMode::Boot);
assert_eq!(registrations[0].1, "runner-manager");
}
#[test]
fn install_is_refused_while_the_single_instance_lock_is_held() {
let host = Host::new();
{
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install with the lock free");
operations.uninstall().expect("a clean slate");
}
let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
.expect("this process takes the lock first");
let error = host
.operations()
.install(&host.request(StartMode::Boot))
.expect_err("a second agent must not be registered while one is running");
assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
assert!(
error.to_string().contains("already running"),
"the message must be actionable: {error}"
);
assert!(
host.controls.registrations().is_empty(),
"a refused install must register nothing"
);
assert_eq!(
InstallRecord::read(&host.paths).expect("readable"),
None,
"a refused install must write no record"
);
}
#[test]
fn installing_over_the_same_start_mode_replaces_the_registration() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("the first install");
let again = operations
.install(&host.request(StartMode::Boot))
.expect("an install over the same mode replaces rather than refusing");
assert!(
again.replaced_existing,
"the operator is told this replaced something rather than made it"
);
assert_eq!(
host.controls.registrations().len(),
1,
"replacing must not leave two registrations behind"
);
assert_eq!(
InstallRecord::read(&host.paths)
.expect("readable")
.expect("a record")
.start_mode,
StartMode::Boot
);
}
#[test]
fn installing_over_the_other_start_mode_is_refused() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("the first install");
let error = operations
.install(&host.request(StartMode::Login))
.expect_err("a mode change is not an install");
assert!(
matches!(
error,
ServiceError::AlreadyInstalled {
existing: StartMode::Boot,
requested: StartMode::Login,
..
}
),
"{error}"
);
assert!(
!error
.to_string()
.contains("switch the start mode in place,"),
"the old remedy named a capability no command offers; the terminal UI is where \
the start mode moves: {error}"
);
assert_eq!(host.controls.registrations().len(), 1);
}
#[test]
fn install_rolls_back_the_registration_when_record_persistence_fails() {
let host = Host::new();
let record_path = InstallRecord::path(&host.paths);
std::fs::create_dir(&record_path).expect("a directory blocks the record file");
let error = host
.operations()
.install(&host.request(StartMode::Boot))
.expect_err("record persistence must fail");
assert!(matches!(error, ServiceError::Record { .. }), "{error}");
assert!(
host.controls.registrations().is_empty(),
"a failed install must not leave a live unrecorded registration"
);
assert!(
host.controls
.calls()
.iter()
.any(|call| call == "uninstall runner-manager (boot)"),
"the registration must be explicitly rolled back: {:?}",
host.controls.calls()
);
assert!(
!host.runner_root.as_path().exists(),
"the rollback must take the runner root this install created with it; a directory \
prepared for a registration that does not exist is litter, and on Windows it is \
litter with a security descriptor"
);
}
#[test]
fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
assert_eq!(
ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
ServiceAccount::LocalSystem
);
assert_eq!(
ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
ServiceAccount::InvokingUser
);
let boot = default_root_sddl(&RootAdmission::LocalSystem);
assert!(!grants_broad_write(&boot), "{boot}");
assert!(
!boot.contains("S-1-5-21"),
"a boot registration runs as LocalSystem, so its root names no operator: {boot}"
);
let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
assert!(!grants_broad_write(&login), "{login}");
}
#[test]
fn an_install_reports_the_runner_root_it_prepared() {
let host = Host::new();
let installed = host
.operations()
.install(&host.request(StartMode::Boot))
.expect("an install");
let rendered = installed.runner_root.to_string();
assert!(
!rendered.contains("S-1-5-21"),
"the report must add no identity to the output: {rendered}"
);
if cfg!(windows) {
assert_eq!(
installed.runner_root.path(),
Some(host.runner_root.as_path())
);
assert!(
host.runner_root.as_path().is_dir(),
"the directory jobs would run in has to exist once the service is registered"
);
} else {
assert_eq!(
installed.runner_root,
crate::runner_root_access::RootAccessSummary::NotApplicable,
"macOS and Linux keep the runtime directory they have always used"
);
}
}
#[test]
fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install at boot");
let change = operations
.set_start_mode(StartMode::Login)
.expect("a switch to login");
assert!(change.changed);
if cfg!(windows) {
assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
assert!(
host.runner_root.as_path().is_dir(),
"the switch must not remove the directory it reconciled"
);
}
assert!(
change.to_string().contains("runner root"),
"{}",
change.to_string()
);
}
#[test]
fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install at boot");
let change = operations
.set_start_mode(StartMode::Boot)
.expect("a switch to the mode already in force");
assert!(!change.changed);
assert_eq!(
change.runner_root,
crate::runner_root_access::RootAccessSummary::NotApplicable,
"nothing moves, so nothing about the root's access control has to; reconciling here \
would turn a no-op command into one that can fail on a permission it does not need"
);
}
#[cfg(windows)]
#[test]
fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
let host = Host::new();
host.controls
.fail_next_install(StartMode::Boot, "injected registration failure");
let error = host
.operations()
.install(&host.request(StartMode::Boot))
.expect_err("the manager refuses the registration");
assert!(matches!(error, ServiceError::Control { .. }), "{error}");
assert!(
!host.runner_root.as_path().exists(),
"the directory was created for a registration that does not exist"
);
}
#[cfg(windows)]
#[test]
fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
let host = Host::new();
crate::runner_root_access::create_with_descriptor_for_tests(
host.runner_root.as_path(),
"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
)
.expect("a deliberately open runner root");
let before = crate::runner_root_access::report(host.runner_root.as_path());
let error = host
.operations()
.install(&host.request(StartMode::Boot))
.expect_err("an open runner root is refused");
assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
assert!(
error.to_string().contains("nothing was registered"),
"{error}"
);
assert!(
host.controls.registrations().is_empty(),
"the refusal has to come before the platform is asked to register anything: {:?}",
host.controls.calls()
);
assert_eq!(
crate::runner_root_access::report(host.runner_root.as_path()),
before,
"an open directory is refused rather than tightened: its contents cannot be trusted, \
so adopting it would be worse than declining it"
);
}
#[cfg(windows)]
#[test]
fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
assert!(host.runner_root.as_path().is_dir());
operations.uninstall().expect("an uninstall");
assert!(
host.runner_root.as_path().is_dir(),
"`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
runner root may hold an operator's retained workspaces."
);
}
#[test]
fn install_reviews_what_it_registered() {
let host = Host::new();
let installed = host
.operations()
.install(&host.request(StartMode::Boot))
.expect("an install");
assert!(
installed.review.is_least_privilege(),
"{}",
installed.review
);
assert!(
!installed.review.controls().is_empty(),
"a review that confirms nothing proves nothing: {}",
installed.review
);
assert_eq!(
installed.review.kind(),
host_definition_kind(StartMode::Boot),
"the review must be of the definition this host's manager was given"
);
assert!(
!installed.review.account().justification().is_empty(),
"a privileged account with no stated reason is an unreviewed one"
);
}
#[test]
fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
let config = host.paths.config_dir();
std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
std::fs::write(
host.paths
.state_dir()
.join("packages/2.330.0/runner.tar.gz"),
b"cached package",
)
.expect("writable");
std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
std::fs::write(
host.paths.state_dir().join("secrets/user-access-token"),
b"a stand-in for the stored credential",
)
.expect("writable");
std::fs::write(
host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
b"diagnostics",
)
.expect("writable");
let roots: Vec<PathBuf> = host
.paths
.all()
.iter()
.map(|(_, path)| (*path).to_path_buf())
.collect();
let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
let before = snapshot(&roots);
assert!(
before.len() >= 6,
"the fixture must actually contain the files this test is about, got {before:#?}"
);
let record_path = InstallRecord::path(&host.paths);
assert!(
before.contains_key(&record_path),
"the install record must be present before uninstall"
);
let uninstalled = operations.uninstall().expect("an uninstall");
assert!(uninstalled.removed_registration);
assert!(uninstalled.removed_record);
let after = snapshot(&roots);
let mut expected = before.clone();
expected.remove(&record_path);
assert_eq!(
after, expected,
"uninstall must remove its own record and nothing else"
);
assert!(
!record_path.exists(),
"the record itself must go, or `uninstall` did nothing at all"
);
assert!(
uninstalled
.preserved
.iter()
.all(|path| roots.contains(&path.as_path())),
"the preserved list must name the four directories: {uninstalled}"
);
}
#[test]
fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
let host = Host::new();
let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
assert!(!uninstalled.removed_registration);
assert!(!uninstalled.removed_record);
}
#[test]
fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
let uninstalled = operations.uninstall().expect("an uninstall");
assert!(
uninstalled.removed_registration,
"a lost record must not strand a registration"
);
assert!(host.controls.registrations().is_empty());
}
#[test]
fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install at boot");
std::fs::remove_file(&host.binary).expect("the installed binary goes away");
let change = operations
.set_start_mode(StartMode::Login)
.expect("a switch that does not reinstall the product");
assert!(change.changed);
assert_eq!(change.from, StartMode::Boot);
assert_eq!(change.to, StartMode::Login);
assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
let record = InstallRecord::read(&host.paths)
.expect("readable")
.expect("a record");
assert_eq!(record.start_mode, StartMode::Login);
assert_eq!(
record.binary, host.binary,
"the recorded path must survive the switch untouched"
);
let registrations = host.controls.registrations();
assert_eq!(registrations.len(), 1, "{registrations:?}");
assert_eq!(registrations[0].0, StartMode::Login);
assert!(
registrations[0]
.2
.command_line
.contains(&host.binary.to_string_lossy().into_owned()),
"{:?}",
registrations[0].2
);
}
#[test]
fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install at boot");
let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
host.controls
.fail_next_install(StartMode::Login, "injected target failure");
let error = operations
.set_start_mode(StartMode::Login)
.expect_err("the target manager refuses the install");
assert!(matches!(error, ServiceError::Control { .. }), "{error}");
assert_eq!(
std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
record_before
);
let registrations = host.controls.registrations();
assert_eq!(registrations.len(), 1, "{registrations:?}");
assert_eq!(registrations[0].0, StartMode::Boot);
}
#[test]
fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install at boot");
let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
let config = host.paths.config_dir().to_path_buf();
let hidden = config.with_file_name("config-hidden-by-fault");
host.controls.hide_directory_after_install(
StartMode::Login,
config.clone(),
hidden.clone(),
);
let error = operations
.set_start_mode(StartMode::Login)
.expect_err("the injected filesystem fault prevents persistence");
std::fs::remove_file(&config).expect("remove the injected blocker");
std::fs::rename(&hidden, &config).expect("restore the record directory");
assert!(matches!(error, ServiceError::Record { .. }), "{error}");
assert_eq!(
std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
record_before
);
let registrations = host.controls.registrations();
assert_eq!(registrations.len(), 1, "{registrations:?}");
assert_eq!(registrations[0].0, StartMode::Boot);
assert!(
host.controls
.calls()
.iter()
.any(|call| call == "uninstall runner-manager (login)"),
"the target must be rolled back: {:?}",
host.controls.calls()
);
}
#[test]
fn switching_to_the_mode_already_in_force_registers_nothing_again() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
let before = host.controls.calls().len();
let change = operations
.set_start_mode(StartMode::Boot)
.expect("a no-op switch");
assert!(!change.changed);
assert_eq!(
host.controls.calls().len(),
before,
"a no-op switch must not touch the service manager"
);
}
#[test]
fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
let host = Host::new();
let error = host
.operations()
.set_start_mode(StartMode::Login)
.expect_err("there is nothing to switch");
assert!(
matches!(error, ServiceError::NotInstalled { .. }),
"{error}"
);
}
#[test]
fn status_reports_the_four_facts_journey_five_asks_for() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
.expect("a valid timestamp")
.with_timezone(&Utc);
record_github_contact(&host.paths, at).expect("a heartbeat");
let status = operations.status().expect("a status");
assert_eq!(status.start_mode(), Some(StartMode::Boot));
assert_eq!(
status.binary().map(BinaryPath::recorded),
Some(host.binary.as_path())
);
assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
assert_eq!(status.last_github_contact(), Some(at));
assert!(status.is_installed());
assert!(status.is_healthy(), "{status}");
let printed = status.to_string();
for fragment in [
"start mode",
"diagnostic log",
"last GitHub contact",
"binary",
] {
assert!(printed.contains(fragment), "{printed}");
}
}
#[cfg(unix)]
#[test]
fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
use std::os::unix::fs::PermissionsExt as _;
if unsafe { libc::geteuid() } == 0 {
return;
}
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
assert!(
operations.status().expect("a status").is_healthy(),
"the discriminator: healthy before the record is made unreadable"
);
let record = InstallRecord::path(&host.paths);
std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
.expect("the mode is applied");
let status = operations
.status()
.expect("a record this account may not read is reported, not thrown");
assert!(status.is_installed(), "{status}");
assert!(!status.is_healthy(), "{status}");
let printed = status.to_string();
assert!(
printed.contains("this account may not read it"),
"the operator is told which of the two states this is: {printed}"
);
assert!(
!printed.contains("there is no install record"),
"a record that is there and unreadable is not a record that is missing, and the \
missing one's remedy starts with `service uninstall`: {printed}"
);
std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
.expect("the mode is restored");
}
#[test]
fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
assert!(
operations.status().expect("a status").is_healthy(),
"the freshly installed host must be healthy"
);
std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
let status = operations.status().expect("a status");
assert!(!status.is_healthy(), "{status}");
assert!(
status
.problems()
.iter()
.any(|problem| problem.subject == "binary"),
"{status}"
);
assert!(status.to_string().contains("STALE"), "{status}");
}
#[test]
fn status_reports_a_registration_that_would_not_start_at_boot() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
assert!(operations.status().expect("a status").is_healthy());
host.controls.edit("runner-manager", |registration| {
registration.starts_automatically = false;
});
let status = operations.status().expect("a status");
assert!(!status.is_healthy(), "{status}");
assert!(
status
.problems()
.iter()
.any(|problem| problem.detail.contains("after a reboot")),
"{status}"
);
}
#[test]
fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Login))
.expect("install and immediate start");
assert!(
operations.status().expect("running status").is_running(),
"install must not wait for the next login trigger"
);
operations.stop().expect("stop the registration");
let status = operations.status().expect("stopped status");
assert!(!status.is_healthy(), "{status}");
assert!(
status
.problems()
.iter()
.any(|problem| problem.subject == "runtime"),
"{status}"
);
let rendered = status.to_string();
assert!(
rendered.contains("runner-manager service install --start-at login"),
"{rendered}"
);
assert!(
!rendered.contains("runner-manager service start"),
"{rendered}"
);
}
#[test]
fn status_reports_a_restart_policy_something_else_edited() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
assert!(operations.status().expect("a status").is_healthy());
host.controls.edit("runner-manager", |registration| {
registration.restart_delay = Some(Duration::from_secs(1));
});
let status = operations.status().expect("a status");
assert!(!status.is_healthy(), "{status}");
assert!(
status
.problems()
.iter()
.any(|problem| problem.subject == "restart policy"),
"{status}"
);
}
#[test]
fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
let other = host.binary.with_file_name("someone-elses.exe");
std::fs::write(&other, b"x").expect("writable");
host.controls.edit("runner-manager", |registration| {
registration.command_line = quote_argument(&other.to_string_lossy());
});
let status = operations.status().expect("a status");
assert!(!status.is_healthy(), "{status}");
assert!(
matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
"{status}"
);
}
#[test]
fn status_reports_a_record_no_service_manager_knows_about() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Boot))
.expect("an install");
for mode in [StartMode::Boot, StartMode::Login] {
host.controls
.control(mode)
.expect("a control")
.uninstall(&ServiceIdentity::product())
.expect("removed");
}
let status = operations.status().expect("a status");
assert!(!status.is_healthy(), "{status}");
assert!(
status
.problems()
.iter()
.any(|problem| problem.subject == "registration"),
"{status}"
);
}
#[test]
fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
let host = Host::new();
let status = host.operations().status().expect("a status");
assert!(!status.is_installed());
assert!(
status.is_healthy(),
"a host that never installed the service has no fault to report: {status}"
);
assert!(status.to_string().contains("installed"), "{status}");
}
#[test]
fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Login))
.expect("an install at login");
let status = operations.status().expect("a status");
assert!(
status
.notes()
.iter()
.any(|note| note.contains("does not run until the operator signs in")),
"05-infrastructure.md requires `service status` to say so: {status}"
);
}
#[test]
fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
use crate::unattended_login::{AutoLogin, UnattendedLogin};
let automatic = UnattendedLogin {
auto_login: AutoLogin::As("ivan".into()),
filevault_on: Some(false),
};
assert_eq!(
unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
None
);
let off = UnattendedLogin {
auto_login: AutoLogin::Off,
filevault_on: Some(false),
};
let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
let locked = UnattendedLogin {
auto_login: AutoLogin::As("ivan".into()),
filevault_on: Some(true),
};
let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
let unread = UnattendedLogin {
auto_login: AutoLogin::As("ivan".into()),
filevault_on: None,
};
assert_eq!(
unattended_gap(StartMode::Login, "ivan", Some(&unread)),
None
);
let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
assert!(
gap.reason
.contains("does not run until the operator signs in")
);
assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
}
#[test]
fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Login))
.expect("an install at login");
let stalled_problem = |operations: &ServiceOperations| {
operations
.status()
.expect("a status")
.problems()
.iter()
.find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
.map(|problem| problem.detail.clone())
};
crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
assert_eq!(stalled_problem(&operations), None, "beating");
crate::daemon_heartbeat::beat(
&operations.paths,
Utc::now() - chrono::Duration::minutes(20),
)
.unwrap();
let detail = stalled_problem(&operations).expect("a stall is a problem");
assert!(detail.contains("has made no progress since"), "{detail}");
let restart = ServiceIdentity::product().restart_command(StartMode::Login);
assert!(detail.contains(&restart), "{detail}");
assert!(!detail.contains("runner-manager service stop"), "{detail}");
assert!(!detail.contains("runner-manager service start"), "{detail}");
}
#[test]
fn the_restart_command_is_the_service_managers_own() {
let identity = ServiceIdentity::product();
let boot = identity.restart_command(StartMode::Boot);
let login = identity.restart_command(StartMode::Login);
for command in [&boot, &login] {
assert!(!command.contains("runner-manager service"), "{command}");
assert!(command.contains(identity.name()), "{command}");
}
assert_ne!(boot, login);
if cfg!(windows) {
assert!(
boot.starts_with("Restart-Service -Name runner-manager"),
"{boot}"
);
assert!(
login.contains("schtasks /Run /TN runner-manager"),
"{login}"
);
} else if cfg!(target_os = "macos") {
assert!(
boot.starts_with("sudo launchctl kickstart -k system/"),
"{boot}"
);
assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
} else {
assert_eq!(boot, "sudo systemctl restart runner-manager.service");
assert_eq!(login, "systemctl --user restart runner-manager.service");
}
}
#[test]
fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Login))
.expect("an install at login");
let paths = operations.paths.clone();
crate::host_fitness::record_host_unfit(
&paths,
&[crate::host_fitness::UnfitFinding {
id: "host.runner_root_responsive".into(),
detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
remedy: Some("click Allow".into()),
}],
Utc::now(),
)
.unwrap();
let status = operations.status().expect("a status");
assert!(!status.is_healthy(), "{status}");
let problem = status
.problems()
.iter()
.find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
.expect("the refusal is a problem");
assert!(
problem.detail.contains("/Volumes/NVME/runners"),
"{problem:?}"
);
assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
crate::host_fitness::clear_host_unfit(&paths).unwrap();
crate::host_fitness::record_host_unfit(
&paths,
&[],
Utc::now() - chrono::Duration::hours(2),
)
.unwrap();
let status = operations.status().expect("a status");
assert!(
!status
.problems()
.iter()
.any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
"{status}"
);
}
#[test]
fn start_and_stop_reach_the_domain_that_holds_the_registration() {
let host = Host::new();
let operations = host.operations();
operations
.install(&host.request(StartMode::Login))
.expect("an install at login");
operations.start().expect("a start");
assert!(operations.status().expect("a status").is_running());
assert!(operations.stop().expect("a stop"));
assert!(!operations.status().expect("a status").is_running());
}
#[test]
fn starting_a_host_with_no_registration_is_refused() {
let host = Host::new();
let error = host.operations().start().expect_err("nothing to start");
assert!(
matches!(error, ServiceError::NotInstalled { .. }),
"{error}"
);
}
#[cfg(windows)]
#[test]
fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
let root = tempfile::tempdir().expect("a temporary directory");
let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
.expect("a rooted machine-scoped store");
store
.store(&secrecy::SecretString::from("a stand-in for the token"))
.expect("the store accepts a value");
let protection = store.protection().expect("the DACL can be read back");
assert!(
protection.description().contains(";;;SY)"),
"the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
protection.description()
);
assert_eq!(
ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
ServiceAccount::LocalSystem,
"and that is the account this installer registers, which is why SY is what matters"
);
assert!(
!protection.readable_by_other_local_users(),
"the same DACL must still exclude ordinary local users: {}",
protection.description()
);
for rejected in [";;;LS)", ";;;NS)"] {
assert!(
!protection.description().contains(rejected),
"if the store ever admitted {rejected}, the least-privilege analysis in \
docs/service-account.md would need redoing: {}",
protection.description()
);
}
}
fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
arguments.extend(ServiceDirectories::of(&host.paths).arguments());
host.request(mode).with_arguments(arguments)
}
fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
ServiceOperations::with_controls(
account.paths.clone(),
ServiceIdentity::product(),
std::sync::Arc::new(machine.controls.clone()),
)
.with_runner_root(account.runner_root.clone())
}
fn a_machine_with_a_stale_second_account() -> (Host, Host) {
let owner = Host::new();
owner
.operations()
.install(&request_with_directories(&owner, StartMode::Boot))
.expect("the owner's boot install");
let earlier = Host::new();
earlier
.operations()
.install(&request_with_directories(&earlier, StartMode::Login))
.expect("the second account's earlier login install");
assert!(
InstallRecord::read(&earlier.paths).unwrap().is_some(),
"the earlier install left its record behind"
);
(owner, earlier)
}
const DESTRUCTIVE_ADVICE: [&str; 4] = [
"service uninstall",
"service install`",
"service install --",
"auth login",
];
#[test]
fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
let (owner, earlier) = a_machine_with_a_stale_second_account();
let status = second_account(&earlier, &owner).status().expect("a status");
let foreign = status.foreign().expect("the service is the owner's");
assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
assert!(foreign.unreadable.is_none(), "{status}");
assert!(
status.is_healthy(),
"the owner's service is healthy, and a stale record elsewhere is not its fault: \
{status}"
);
for subject in [
"binary",
"start mode",
"secret store",
"record",
"registration",
] {
assert!(
!status
.problems()
.iter()
.any(|problem| problem.subject == subject),
"{subject} compared the second account's record against the owner's \
registration: {status}"
);
}
let rendered = status.to_string();
for advice in DESTRUCTIVE_ADVICE {
assert!(!rendered.contains(advice), "{advice}: {rendered}");
}
assert!(rendered.contains("another installation's"), "{rendered}");
assert!(
status
.notes()
.iter()
.any(|note| note.contains("unused local configuration")
&& note.contains("host forget-local")),
"{status}"
);
assert_eq!(status.start_mode(), Some(StartMode::Boot));
}
#[test]
fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
let (owner, earlier) = a_machine_with_a_stale_second_account();
InstallRecord::remove(&owner.paths).expect("the owner's record removed");
let operations = second_account(&earlier, &owner);
let status = operations.status().expect("a status");
let foreign = status.foreign().expect("the service is the owner's");
assert!(foreign.unreadable.is_some(), "{status}");
assert!(
status.is_healthy(),
"running, and nothing it could judge: {status}"
);
let rendered = status.to_string();
for advice in DESTRUCTIVE_ADVICE {
assert!(!rendered.contains(advice), "{advice}: {rendered}");
}
assert!(rendered.contains("running; installed from"), "{rendered}");
assert!(!rendered.contains("last GitHub contact"), "{rendered}");
owner.operations().stop().expect("stopped");
let stopped = operations.status().expect("a status");
assert!(!stopped.is_healthy(), "{stopped}");
let rendered = stopped.to_string();
for advice in DESTRUCTIVE_ADVICE {
assert!(!rendered.contains(advice), "{advice}: {rendered}");
}
}
#[test]
fn this_accounts_own_registration_still_reports_every_mismatch() {
let host = Host::new();
let operations = host.operations();
operations
.install(&request_with_directories(&host, StartMode::Boot))
.expect("an install");
let other = host.binary.with_file_name("someone-elses");
std::fs::write(&other, b"x").expect("writable");
host.controls.edit("runner-manager", |registration| {
let mut arguments = split_command_line(®istration.command_line);
arguments[0] = other.to_string_lossy().into_owned();
registration.command_line = arguments
.iter()
.map(|argument| quote_argument(argument))
.collect::<Vec<_>>()
.join(" ");
registration.start_mode = StartMode::Login;
});
let status = operations.status().expect("a status");
assert!(status.foreign().is_none(), "{status}");
assert!(
matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
"{status}"
);
assert!(
status
.problems()
.iter()
.any(|problem| problem.subject == "start mode"),
"{status}"
);
assert!(status.to_string().contains("service uninstall"), "{status}");
}
#[test]
fn directories_read_back_from_a_command_line_with_spaces() {
let root = Path::new("/Users/someone/Library/Application Support/runner manager");
let directories = ServiceDirectories {
config: root.join("config"),
state: root.join("data").join("state"),
runtime: root.join("data").join("runtime"),
logs: root.join("data").join("logs"),
};
let mut command_line = quote_argument("/usr/local/bin/runner-manager");
for argument in ["daemon", "run"]
.map(OsString::from)
.into_iter()
.chain(directories.arguments())
{
command_line.push(' ');
command_line.push_str("e_argument(&argument.to_string_lossy()));
}
assert_eq!(
ServiceDirectories::from_command_line(&command_line),
Some(directories.clone())
);
assert_eq!(directories.root(), root);
assert_eq!(
ServiceDirectories::from_command_line("runner-manager daemon run"),
None,
"a registration that names no directories cannot be judged foreign"
);
}
#[test]
fn the_owner_of_a_data_root_is_read_from_its_path() {
let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
let home = users.join("ivand");
assert_eq!(
data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
Some("yuriv")
);
assert_eq!(
data_root_owner(
Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
Some(&home)
),
None
);
if cfg!(windows) {
assert_eq!(
data_root_owner(
Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
Some(&home)
)
.as_deref(),
Some("LocalSystem")
);
} else {
assert_eq!(
data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
Some("root")
);
}
}
#[test]
fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
let (owner, earlier) = a_machine_with_a_stale_second_account();
let owner_before = snapshot(&[owner._root.path()]);
let config = earlier.paths.config_dir().to_path_buf();
let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
let forgotten = second_account(&earlier, &owner)
.forget_local(Utc::now())
.expect("archived");
assert_eq!(
snapshot(&[owner._root.path()]),
owner_before,
"the owner's directories must not change by one byte"
);
let archive = forgotten.archive.expect("something was archived");
assert!(archive.starts_with(&config), "{}", archive.display());
assert_eq!(
std::fs::read(archive.join(RECORD_FILE)).unwrap(),
record,
"moved, not rewritten or deleted"
);
assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
assert_eq!(
forgotten.service_root,
Some(ServiceDirectories::of(&owner.paths).root())
);
let status = second_account(&earlier, &owner).status().expect("status");
assert!(
!status
.notes()
.iter()
.any(|note| note.contains("unused local configuration")),
"{status}"
);
}
#[test]
fn forget_local_refuses_a_configuration_a_service_runs_against() {
let (owner, earlier) = a_machine_with_a_stale_second_account();
let before = snapshot(&[owner._root.path()]);
let refused = owner.operations().forget_local(Utc::now());
assert!(
matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
"{refused:?}"
);
assert_eq!(snapshot(&[owner._root.path()]), before);
let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
.expect("the agent's lock");
let refused = second_account(&earlier, &owner).forget_local(Utc::now());
assert!(
matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
"{refused:?}"
);
assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
}
#[test]
fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
let (owner, earlier) = a_machine_with_a_stale_second_account();
owner.operations().stop().expect("stopped");
record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
let status = second_account(&earlier, &owner).status().expect("a status");
assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
for subject in [
"runtime",
"github credential",
CREDENTIAL_UNREADABLE_SUBJECT,
] {
assert!(subjects.contains(&subject), "{subject}: {status}");
}
let rendered = status.to_string();
for advice in DESTRUCTIVE_ADVICE {
assert!(!rendered.contains(advice), "{advice}: {rendered}");
}
for problem in status.problems() {
assert!(problem.detail.contains("Act on it"), "{problem}");
}
}
#[test]
fn no_command_of_this_account_changes_another_installations_service() {
let (owner, earlier) = a_machine_with_a_stale_second_account();
let operations = second_account(&earlier, &owner);
let foreign = |result: Result<(), ServiceError>| {
assert!(
matches!(result, Err(ServiceError::ForeignInstallation { .. })),
"{result:?}"
);
};
foreign(
operations
.install(&request_with_directories(&earlier, StartMode::Boot))
.map(drop),
);
foreign(operations.uninstall().map(drop));
foreign(operations.set_start_mode(StartMode::Boot).map(drop));
foreign(operations.start());
foreign(operations.stop().map(drop));
let registration = owner
.controls
.control(StartMode::Boot)
.unwrap()
.query(&ServiceIdentity::product())
.unwrap()
.expect("the owner's registration is still there");
assert_eq!(
ServiceDirectories::from_command_line(®istration.command_line),
Some(ServiceDirectories::of(&owner.paths)),
"and still runs against the owner's directories"
);
assert!(registration.running, "and was never stopped");
assert!(
InstallRecord::read(&earlier.paths).unwrap().is_some(),
"the refused uninstall kept this account's record"
);
}
#[test]
fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
let root = tempfile::tempdir().unwrap();
let config = root.path().join("Application Support");
let paths = AppPaths::from_directories(
&config,
config.join("state"),
config.join("runtime"),
config.join("logs"),
);
paths.create_all().unwrap();
std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
let operations = ServiceOperations::with_controls(
paths.clone(),
ServiceIdentity::product(),
std::sync::Arc::new(RecordingControls::new()),
);
let forgotten = operations.forget_local(Utc::now()).expect("archived");
assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
assert!(kept.is_dir(), "{} was moved", kept.display());
}
assert!(paths.logs_dir().join("runner-manager.log").is_file());
}
#[test]
fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
for (detail, diagnosis) in [
(
"x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
service install --start-at boot`; if the service manager denies replacement",
"x holds an automatic registration, but the daemon is stopped",
),
(
"store -- MISMATCH. Run `auth login` again so the token is stored",
"store -- MISMATCH",
),
(
"unit differs, so the daemon runs under outdated settings. Re-register it with \
`sudo runner-manager service install --start-at boot`",
"unit differs, so the daemon runs under outdated settings",
),
(
"it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
"it starts no runner; the log has it",
),
(
"C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
install`",
"C:\\a\\runner-manager.exe -- STALE: moved",
),
(
"a new builder: run `runner-manager service install` here, or not",
"the installation's own records report this",
),
] {
assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
}
}
}