1use std::collections::BTreeMap;
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106 Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109pub const SERVICE_NAME: &str = "runner-manager";
115pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126pub const DESCRIPTION: &str =
128 "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137pub const RECORD_FILE: &str = "service.toml";
145
146pub const CONTACT_FILE: &str = "github-contact.toml";
152
153pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
172
173pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
184
185#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct ServiceIdentity {
199 name: String,
200 display_name: String,
201 description: String,
202}
203
204impl ServiceIdentity {
205 #[must_use]
207 pub fn product() -> Self {
208 Self {
209 name: SERVICE_NAME.to_string(),
210 display_name: DISPLAY_NAME.to_string(),
211 description: DESCRIPTION.to_string(),
212 }
213 }
214
215 #[must_use]
222 pub fn fixture(tag: &str) -> Self {
223 let tag: String = tag
224 .chars()
225 .map(|c| {
226 if c.is_ascii_alphanumeric() {
227 c.to_ascii_lowercase()
228 } else {
229 '-'
230 }
231 })
232 .collect();
233 let name = format!("{SERVICE_NAME}-selftest-{tag}");
234 Self {
235 display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
236 description: "Disposable fixture created by runner-manager's own installer tests. \
237 Safe to remove."
238 .to_string(),
239 name,
240 }
241 }
242
243 #[must_use]
248 pub fn is_fixture(&self) -> bool {
249 self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
250 }
251
252 #[must_use]
255 pub fn name(&self) -> &str {
256 &self.name
257 }
258
259 #[must_use]
261 pub fn display_name(&self) -> &str {
262 &self.display_name
263 }
264
265 #[must_use]
267 pub fn description(&self) -> &str {
268 &self.description
269 }
270
271 #[must_use]
277 pub fn launchd_label(&self) -> String {
278 format!(
279 "{}.{}.{}",
280 crate::paths::QUALIFIER,
281 crate::paths::ORGANIZATION,
282 self.name
283 )
284 }
285
286 #[must_use]
288 pub fn systemd_unit(&self) -> String {
289 format!("{}.service", self.name)
290 }
291}
292
293impl fmt::Display for ServiceIdentity {
294 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295 f.write_str(&self.name)
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub struct RestartPolicy {
314 delay: Duration,
315 reset_after: Duration,
316}
317
318impl RestartPolicy {
319 pub const MIN_DELAY: Duration = Duration::from_secs(1);
326
327 pub const MAX_DELAY: Duration = Duration::from_secs(300);
330
331 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
333
334 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
336
337 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
345 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
346 return Err(ServiceError::RestartDelay {
347 requested_secs: delay.as_secs(),
348 min_secs: Self::MIN_DELAY.as_secs(),
349 max_secs: Self::MAX_DELAY.as_secs(),
350 });
351 }
352 if reset_after <= delay {
353 return Err(ServiceError::RestartResetWindow {
354 reset_secs: reset_after.as_secs(),
355 delay_secs: delay.as_secs(),
356 });
357 }
358 Ok(Self { delay, reset_after })
359 }
360
361 #[must_use]
363 pub const fn delay(&self) -> Duration {
364 self.delay
365 }
366
367 #[must_use]
369 pub const fn reset_after(&self) -> Duration {
370 self.reset_after
371 }
372
373 #[must_use]
390 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
391 match kind {
392 DefinitionKind::WindowsScheduledTask => {
393 let seconds = self.delay.as_secs();
394 let minutes = seconds.div_ceil(60);
395 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
396 }
397 _ => self.delay,
398 }
399 }
400}
401
402impl Default for RestartPolicy {
403 fn default() -> Self {
404 Self {
405 delay: Self::DEFAULT_DELAY,
406 reset_after: Self::DEFAULT_RESET_AFTER,
407 }
408 }
409}
410
411impl fmt::Display for RestartPolicy {
412 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413 write!(
414 f,
415 "restart after {}s, failure count resets after {}s",
416 self.delay.as_secs(),
417 self.reset_after.as_secs()
418 )
419 }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "snake_case")]
434pub enum ServiceAccount {
435 LocalSystem,
438 Root,
441 InvokingUser,
443}
444
445impl ServiceAccount {
446 #[must_use]
455 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
456 match (kind, mode) {
457 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
458 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
459 (_, StartMode::Boot) => Self::Root,
460 }
461 }
462
463 #[must_use]
466 pub const fn for_start_mode(mode: StartMode) -> Self {
467 Self::for_definition(host_definition_kind(mode), mode)
468 }
469
470 #[must_use]
472 pub const fn as_str(&self) -> &'static str {
473 match self {
474 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
475 Self::Root => "root",
476 Self::InvokingUser => "the invoking user",
477 }
478 }
479
480 #[must_use]
487 pub const fn justification(&self) -> &'static str {
488 match self {
489 Self::LocalSystem => {
490 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
491 NetworkService cannot read it, and widening the DACL to reach them would grant \
492 every service on this host read access to the one credential this product holds"
493 }
494 Self::Root => {
495 "a boot-time registration runs outside every login session: on macOS the System \
496 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
497 machine-scoped store is a 0600 file under /var/lib that only root can open \
498 before a session exists"
499 }
500 Self::InvokingUser => {
501 "a login-mode registration reads the user-scoped store, which is deliberately \
502 readable by exactly one account and needs no elevation at all"
503 }
504 }
505 }
506
507 #[must_use]
509 pub const fn needs_elevation(&self) -> bool {
510 matches!(self, Self::LocalSystem | Self::Root)
511 }
512}
513
514impl fmt::Display for ServiceAccount {
515 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516 f.write_str(self.as_str())
517 }
518}
519
520#[derive(Debug, thiserror::Error)]
531pub enum ServiceError {
532 #[error("cannot install the service while an agent is already running on this host: {source}")]
538 LockHeld {
539 #[source]
541 source: Box<LockError>,
542 },
543
544 #[error(
547 "cannot tell whether an agent is already running on this host, so the service was not \
548 installed: {source}. Fix the reported problem with the state directory and try again."
549 )]
550 LockUnreadable {
551 #[source]
553 source: Box<LockError>,
554 },
555
556 #[error(
564 "this registration would run jobs under the default runner root, and that root could \
565 not be prepared, so nothing was registered: {source}"
566 )]
567 RunnerRoot {
568 #[source]
570 source: Box<RootAccessError>,
571 },
572
573 #[error(
575 "cannot resolve the absolute path of this executable, so there is nothing to register: \
576 {detail}. Run the installer from the installed binary rather than through a shell \
577 function or a wrapper that replaces argv[0]."
578 )]
579 BinaryPath {
580 detail: String,
582 },
583
584 #[error(
586 "{} is not a file, so registering it would create a service that cannot start. Install \
587 the product first, then run `service install` from the installed binary.",
588 path.display()
589 )]
590 BinaryMissing {
591 path: PathBuf,
593 },
594
595 #[error(
597 "a restart delay of {requested_secs}s is outside the supported range \
598 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
599 value, so `service status` would report a delay that is not the one in force."
600 )]
601 RestartDelay {
602 requested_secs: u64,
604 min_secs: u64,
606 max_secs: u64,
608 },
609
610 #[error(
612 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
613 restart delay, so the count would reset between every pair of restarts and no \
614 start limit could ever apply."
615 )]
616 RestartResetWindow {
617 reset_secs: u64,
619 delay_secs: u64,
621 },
622
623 #[error(
629 "{name} is already registered to start at {existing}, and this asks for {requested}. \
630 Changing the start mode moves the registration between two different service \
631 managers, so it is not something an install does by itself: run `service uninstall` \
632 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
633 switches it in place and keeps the registration running throughout."
634 )]
635 AlreadyInstalled {
636 name: String,
638 existing: StartMode,
640 requested: StartMode,
642 },
643
644 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
646 NotInstalled {
647 name: String,
649 operation: &'static str,
651 },
652
653 #[error("cannot {operation} the service record {}: {detail}", path.display())]
655 Record {
656 operation: &'static str,
658 path: PathBuf,
660 detail: String,
662 },
663
664 #[error(
666 "the service record {} was not written by this product, or was written by a version \
667 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
668 neither touches configuration, secrets, or the cache.",
669 path.display()
670 )]
671 RecordUnreadable {
672 path: PathBuf,
674 detail: String,
676 },
677
678 #[error(
692 "the service record {} is there and this account may not read it: {detail}. It was \
693 written by whichever account installed the service -- on a boot-mode host, `sudo \
694 service install`. Running `service install` again rewrites it readable; until then \
695 only what the service manager itself reports is available.",
696 path.display()
697 )]
698 RecordNotPermitted {
699 path: PathBuf,
701 detail: String,
703 },
704
705 #[error("cannot prepare this host's application-data directories: {source}")]
707 Paths {
708 #[source]
710 source: Box<crate::paths::PathsError>,
711 },
712
713 #[error("cannot {operation} {name} through {manager}: {detail}")]
715 Control {
716 operation: &'static str,
718 name: String,
720 manager: &'static str,
722 detail: String,
724 },
725
726 #[error(
728 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
729 Inspect `service status` before retrying."
730 )]
731 Rollback {
732 operation: &'static str,
734 name: String,
736 cause: String,
738 rollback: String,
740 },
741
742 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
744 NeedsElevation {
745 operation: &'static str,
747 name: String,
749 detail: String,
751 remedy: &'static str,
753 },
754}
755
756#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
768pub struct ServiceDirectories {
769 pub config: PathBuf,
771 pub state: PathBuf,
773 pub runtime: PathBuf,
775 pub logs: PathBuf,
777}
778
779impl ServiceDirectories {
780 #[must_use]
782 pub fn of(paths: &AppPaths) -> Self {
783 Self {
784 config: paths.config_dir().to_path_buf(),
785 state: paths.state_dir().to_path_buf(),
786 runtime: paths.runtime_dir().to_path_buf(),
787 logs: paths.logs_dir().to_path_buf(),
788 }
789 }
790
791 #[must_use]
793 pub fn all(&self) -> [&Path; 4] {
794 [
795 self.config.as_path(),
796 self.state.as_path(),
797 self.runtime.as_path(),
798 self.logs.as_path(),
799 ]
800 }
801
802 #[must_use]
805 pub fn log_file(&self) -> PathBuf {
806 self.logs.join(LOG_FILE_STEM)
807 }
808}
809
810#[derive(Debug, Clone, PartialEq, Eq)]
822pub struct InstallRequest {
823 start_mode: StartMode,
824 binary: Option<PathBuf>,
825 source_binary: Option<PathBuf>,
826 arguments: Vec<OsString>,
827 restart: RestartPolicy,
828 on_demand: bool,
829}
830
831impl InstallRequest {
832 #[must_use]
834 pub fn new(start_mode: StartMode) -> Self {
835 Self {
836 start_mode,
837 binary: None,
838 source_binary: None,
839 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
840 restart: RestartPolicy::default(),
841 on_demand: false,
842 }
843 }
844
845 #[must_use]
855 pub const fn started_on_demand(mut self) -> Self {
856 self.on_demand = true;
857 self
858 }
859
860 #[must_use]
867 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
868 self.binary = Some(binary.into());
869 self
870 }
871
872 #[must_use]
889 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
890 self.source_binary = Some(source.into());
891 self
892 }
893
894 #[must_use]
896 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
897 where
898 I: IntoIterator<Item = S>,
899 S: Into<OsString>,
900 {
901 self.arguments = arguments.into_iter().map(Into::into).collect();
902 self
903 }
904
905 #[must_use]
908 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
909 self.restart = restart;
910 self
911 }
912
913 #[must_use]
915 pub const fn start_mode(&self) -> StartMode {
916 self.start_mode
917 }
918}
919
920#[derive(Debug, Clone, PartialEq, Eq)]
927pub struct InstallPlan {
928 identity: ServiceIdentity,
929 start_mode: StartMode,
930 binary: PathBuf,
931 source_binary: Option<PathBuf>,
932 arguments: Vec<OsString>,
933 account: ServiceAccount,
934 restart: RestartPolicy,
935 directories: ServiceDirectories,
936 secret_guard: Option<PathBuf>,
937 on_demand: bool,
938}
939
940impl InstallPlan {
941 pub fn resolve(
949 identity: ServiceIdentity,
950 request: &InstallRequest,
951 directories: ServiceDirectories,
952 ) -> Result<Self, ServiceError> {
953 let binary = match &request.binary {
954 Some(named) => absolute(named)?,
955 None => running_executable()?,
956 };
957 if !binary.is_file() {
958 return Err(ServiceError::BinaryMissing { path: binary });
959 }
960 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
965 .ok()
966 .map(|store| store.guard());
967 Ok(Self {
968 identity,
969 start_mode: request.start_mode,
970 binary,
971 source_binary: request.source_binary.clone(),
972 arguments: request.arguments.clone(),
973 account: ServiceAccount::for_start_mode(request.start_mode),
974 restart: request.restart,
975 directories,
976 secret_guard,
977 on_demand: request.on_demand,
978 })
979 }
980
981 #[must_use]
988 pub fn unchecked(
989 identity: ServiceIdentity,
990 start_mode: StartMode,
991 binary: impl Into<PathBuf>,
992 directories: ServiceDirectories,
993 ) -> Self {
994 Self {
995 identity,
996 start_mode,
997 binary: binary.into(),
998 source_binary: None,
999 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1000 account: ServiceAccount::for_start_mode(start_mode),
1001 restart: RestartPolicy::default(),
1002 directories,
1003 secret_guard: None,
1004 on_demand: false,
1005 }
1006 }
1007
1008 #[must_use]
1011 pub const fn started_on_demand(mut self) -> Self {
1012 self.on_demand = true;
1013 self
1014 }
1015
1016 #[must_use]
1018 pub const fn is_on_demand(&self) -> bool {
1019 self.on_demand
1020 }
1021
1022 #[must_use]
1029 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1030 self.secret_guard = Some(guard.into());
1031 self
1032 }
1033
1034 #[must_use]
1036 pub fn secret_guard(&self) -> Option<&Path> {
1037 self.secret_guard.as_deref()
1038 }
1039
1040 #[must_use]
1042 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1043 self.restart = restart;
1044 self
1045 }
1046
1047 #[must_use]
1049 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1050 where
1051 I: IntoIterator<Item = S>,
1052 S: Into<OsString>,
1053 {
1054 self.arguments = arguments.into_iter().map(Into::into).collect();
1055 self
1056 }
1057
1058 #[must_use]
1060 pub const fn identity(&self) -> &ServiceIdentity {
1061 &self.identity
1062 }
1063
1064 #[must_use]
1066 pub const fn start_mode(&self) -> StartMode {
1067 self.start_mode
1068 }
1069
1070 #[must_use]
1072 pub fn binary(&self) -> &Path {
1073 &self.binary
1074 }
1075
1076 #[must_use]
1082 pub fn source_binary(&self) -> Option<&Path> {
1083 self.source_binary.as_deref()
1084 }
1085
1086 #[must_use]
1088 pub fn arguments(&self) -> &[OsString] {
1089 &self.arguments
1090 }
1091
1092 #[must_use]
1094 pub const fn account(&self) -> &ServiceAccount {
1095 &self.account
1096 }
1097
1098 #[must_use]
1100 pub const fn restart(&self) -> RestartPolicy {
1101 self.restart
1102 }
1103
1104 #[must_use]
1106 pub const fn directories(&self) -> &ServiceDirectories {
1107 &self.directories
1108 }
1109
1110 #[must_use]
1112 pub fn command_line(&self) -> String {
1113 let mut out = quote_argument(&self.binary.to_string_lossy());
1114 for argument in &self.arguments {
1115 out.push(' ');
1116 out.push_str("e_argument(&argument.to_string_lossy()));
1117 }
1118 out
1119 }
1120}
1121
1122fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1130 let reversal = change.revert();
1131 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1132}
1133
1134fn rolled_back<T>(
1147 retained: Option<String>,
1148 rollback: Result<T, ServiceError>,
1149 operation: &'static str,
1150 identity: &ServiceIdentity,
1151 cause: ServiceError,
1152) -> ServiceError {
1153 let left_behind = match (rollback.err(), retained) {
1154 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1155 (Some(rollback), None) => Some(rollback.to_string()),
1156 (None, retained) => retained,
1157 };
1158 match left_behind {
1159 Some(rollback) => ServiceError::Rollback {
1160 operation,
1161 name: identity.name().to_string(),
1162 cause: cause.to_string(),
1163 rollback,
1164 },
1165 None => cause,
1166 }
1167}
1168
1169fn undo_runner_root(
1172 change: &RootAccessChange,
1173 operation: &'static str,
1174 identity: &ServiceIdentity,
1175 cause: ServiceError,
1176) -> ServiceError {
1177 rolled_back(
1178 retained_runner_root(change),
1179 Ok(()),
1180 operation,
1181 identity,
1182 cause,
1183 )
1184}
1185
1186fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1200 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1201 detail: format!("{} could not be made absolute: {error}", path.display()),
1202 })
1203}
1204
1205fn running_executable() -> Result<PathBuf, ServiceError> {
1207 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1208 detail: error.to_string(),
1209 })?;
1210 absolute(&raw)
1211}
1212
1213pub(crate) fn quote_argument(argument: &str) -> String {
1222 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1223 return argument.to_string();
1224 }
1225 let mut out = String::with_capacity(argument.len() + 2);
1226 out.push('"');
1227 let mut backslashes = 0usize;
1228 for c in argument.chars() {
1229 match c {
1230 '\\' => {
1231 backslashes += 1;
1232 out.push('\\');
1233 }
1234 '"' => {
1235 for _ in 0..=backslashes {
1238 out.push('\\');
1239 }
1240 out.push('"');
1241 backslashes = 0;
1242 }
1243 other => {
1244 backslashes = 0;
1245 out.push(other);
1246 }
1247 }
1248 }
1249 for _ in 0..backslashes {
1251 out.push('\\');
1252 }
1253 out.push('"');
1254 out
1255}
1256
1257#[must_use]
1268pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1269 let trimmed = command_line.trim_start();
1270 if trimmed.is_empty() {
1271 return None;
1272 }
1273 let mut out = String::new();
1274 let mut chars = trimmed.chars().peekable();
1275 let quoted = chars.peek() == Some(&'"');
1276 if quoted {
1277 chars.next();
1278 let mut backslashes = 0usize;
1279 for c in chars {
1280 match c {
1281 '\\' => {
1282 backslashes += 1;
1283 }
1284 '"' => {
1285 out.extend(std::iter::repeat_n('\\', backslashes / 2));
1288 if backslashes.is_multiple_of(2) {
1289 break;
1290 }
1291 backslashes = 0;
1292 out.push('"');
1293 }
1294 other => {
1295 out.extend(std::iter::repeat_n('\\', backslashes));
1296 backslashes = 0;
1297 out.push(other);
1298 }
1299 }
1300 }
1301 } else {
1302 for c in chars {
1303 if c == ' ' || c == '\t' {
1304 break;
1305 }
1306 out.push(c);
1307 }
1308 }
1309 if out.is_empty() {
1310 None
1311 } else {
1312 Some(PathBuf::from(out))
1313 }
1314}
1315
1316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1322pub enum DefinitionKind {
1323 WindowsService,
1327 WindowsScheduledTask,
1329 LaunchdPlist,
1331 SystemdUnit,
1333}
1334
1335impl DefinitionKind {
1336 #[must_use]
1338 pub const fn manager(self) -> &'static str {
1339 match self {
1340 Self::WindowsService => "the Windows Service Control Manager",
1341 Self::WindowsScheduledTask => "Windows Task Scheduler",
1342 Self::LaunchdPlist => "launchd",
1343 Self::SystemdUnit => "systemd",
1344 }
1345 }
1346}
1347
1348impl fmt::Display for DefinitionKind {
1349 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1350 f.write_str(self.manager())
1351 }
1352}
1353
1354#[derive(Debug, Clone, PartialEq, Eq)]
1361pub struct ServiceDefinition {
1362 kind: DefinitionKind,
1363 text: String,
1364 install_path: Option<PathBuf>,
1365}
1366
1367impl ServiceDefinition {
1368 #[must_use]
1370 pub const fn kind(&self) -> DefinitionKind {
1371 self.kind
1372 }
1373
1374 #[must_use]
1376 pub fn text(&self) -> &str {
1377 &self.text
1378 }
1379
1380 #[must_use]
1387 pub fn install_path(&self) -> Option<&Path> {
1388 self.install_path.as_deref()
1389 }
1390
1391 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1404 Ok(match host_definition_kind(plan.start_mode()) {
1405 DefinitionKind::WindowsService => Self::windows_service(plan),
1406 DefinitionKind::WindowsScheduledTask => {
1407 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1408 }
1409 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1410 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1411 })
1412 }
1413
1414 #[must_use]
1416 pub fn windows_service(plan: &InstallPlan) -> Self {
1417 Self {
1418 kind: DefinitionKind::WindowsService,
1419 text: windows_service_descriptor(plan),
1420 install_path: None,
1421 }
1422 }
1423
1424 #[must_use]
1426 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1427 Self {
1428 kind: DefinitionKind::WindowsScheduledTask,
1429 text: windows_scheduled_task_xml(plan, principal),
1430 install_path: None,
1431 }
1432 }
1433
1434 #[must_use]
1439 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1440 let file = format!("{}.plist", plan.identity().launchd_label());
1441 let install_path = match plan.start_mode() {
1442 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1443 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1444 };
1445 Self {
1446 kind: DefinitionKind::LaunchdPlist,
1447 text: launchd_plist(plan),
1448 install_path,
1449 }
1450 }
1451
1452 #[must_use]
1460 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1461 Self {
1462 kind,
1463 text: text.into(),
1464 install_path: None,
1465 }
1466 }
1467
1468 #[must_use]
1473 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1474 let file = plan.identity().systemd_unit();
1475 let install_path = match plan.start_mode() {
1476 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1477 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1478 };
1479 Self {
1480 kind: DefinitionKind::SystemdUnit,
1481 text: systemd_unit(plan),
1482 install_path,
1483 }
1484 }
1485}
1486
1487pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1489pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1491pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1493pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1495
1496const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1499
1500pub const START_LIMIT_BURST: u32 = 5;
1508
1509#[must_use]
1530pub fn systemd_unit(plan: &InstallPlan) -> String {
1531 let identity = plan.identity();
1532 let restart = plan.restart();
1533 let directories = plan.directories();
1534
1535 let mut out = String::new();
1536 out.push_str("[Unit]\n");
1537 out.push_str(&format!("Description={}\n", identity.display_name()));
1538 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1539 out.push_str("After=network-online.target\n");
1540 out.push_str("Wants=network-online.target\n");
1541 out.push_str(&format!(
1544 "StartLimitIntervalSec={}\n",
1545 restart.reset_after().as_secs()
1546 ));
1547 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1548
1549 out.push_str("\n[Service]\n");
1550 out.push_str("Type=simple\n");
1551 out.push_str("KillMode=process\n");
1555 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1556 out.push_str(&format!(
1557 "WorkingDirectory={}\n",
1558 directories.state.display()
1559 ));
1560 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1561 out.push_str("Restart=on-failure\n");
1562 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1563
1564 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1565 for directive in SYSTEMD_HARDENING {
1566 out.push_str(directive);
1567 out.push('\n');
1568 }
1569 let mut writable = directories.all().to_vec();
1570 if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1571 writable.push(secret_directory);
1572 }
1573 out.push_str(&format!(
1574 "ReadWritePaths={}\n",
1575 writable
1576 .iter()
1577 .map(|path| quote_argument(&path.to_string_lossy()))
1578 .collect::<Vec<_>>()
1579 .join(" ")
1580 ));
1581
1582 out.push_str("\n[Install]\n");
1583 out.push_str(match plan.start_mode() {
1584 StartMode::Boot => "WantedBy=multi-user.target\n",
1585 StartMode::Login => "WantedBy=default.target\n",
1586 });
1587 out
1588}
1589
1590pub const SYSTEMD_HARDENING: [&str; 13] = [
1597 "NoNewPrivileges=yes",
1598 "CapabilityBoundingSet=",
1599 "AmbientCapabilities=",
1600 "PrivateTmp=yes",
1601 "PrivateDevices=yes",
1602 "ProtectSystem=strict",
1603 "ProtectKernelTunables=yes",
1604 "ProtectKernelModules=yes",
1605 "ProtectControlGroups=yes",
1606 "RestrictNamespaces=yes",
1607 "RestrictRealtime=yes",
1608 "RestrictSUIDSGID=yes",
1609 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1610];
1611
1612pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1636
1637#[must_use]
1644pub fn launchd_plist(plan: &InstallPlan) -> String {
1645 let identity = plan.identity();
1646 let directories = plan.directories();
1647 let mut out = String::new();
1648 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1649 out.push_str(
1650 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1651 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1652 );
1653 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1654 out.push_str(&plist_string("Label", &identity.launchd_label()));
1655
1656 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1657 out.push_str(&format!(
1658 " <string>{}</string>\n",
1659 xml_escape(&plan.binary().to_string_lossy())
1660 ));
1661 for argument in plan.arguments() {
1662 out.push_str(&format!(
1663 " <string>{}</string>\n",
1664 xml_escape(&argument.to_string_lossy())
1665 ));
1666 }
1667 out.push_str(" </array>\n");
1668
1669 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1670 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1671 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1672 out.push_str(" </dict>\n");
1673 out.push_str(&format!(
1674 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1675 plan.restart().delay().as_secs()
1676 ));
1677 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1678 out.push_str(&plist_string(
1679 "WorkingDirectory",
1680 &directories.state.to_string_lossy(),
1681 ));
1682 out.push_str(&plist_string(
1683 "StandardOutPath",
1684 &directories
1685 .logs
1686 .join("runner-manager.launchd.out.log")
1687 .to_string_lossy(),
1688 ));
1689 out.push_str(&plist_string(
1690 "StandardErrorPath",
1691 &directories
1692 .logs
1693 .join("runner-manager.launchd.err.log")
1694 .to_string_lossy(),
1695 ));
1696
1697 match plan.start_mode() {
1698 StartMode::Boot => {
1699 out.push_str(&plist_string(
1702 "UserName",
1703 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1704 .as_str(),
1705 ));
1706 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1708 }
1709 StartMode::Login => {
1710 }
1714 }
1715
1716 out.push_str("</dict>\n</plist>\n");
1717 out
1718}
1719
1720fn plist_string(key: &str, value: &str) -> String {
1722 format!(
1723 " <key>{}</key>\n <string>{}</string>\n",
1724 xml_escape(key),
1725 xml_escape(value)
1726 )
1727}
1728
1729#[derive(Debug, Clone, PartialEq, Eq)]
1737pub struct TaskPrincipal {
1738 user_id: String,
1739}
1740
1741impl TaskPrincipal {
1742 pub fn current() -> Result<Self, ServiceError> {
1750 let user = std::env::var("USERNAME")
1751 .ok()
1752 .filter(|value| !value.trim().is_empty());
1753 let Some(user) = user else {
1754 return Err(ServiceError::Control {
1755 operation: "identify the account for",
1756 name: SERVICE_NAME.to_string(),
1757 manager: "Windows Task Scheduler",
1758 detail: "this session reports no %USERNAME%, so there is no principal to \
1759 register a logon-triggered task for"
1760 .to_string(),
1761 });
1762 };
1763 let domain = std::env::var("USERDOMAIN")
1764 .ok()
1765 .filter(|value| !value.trim().is_empty());
1766 Ok(Self {
1767 user_id: match domain {
1768 Some(domain) => format!("{domain}\\{user}"),
1769 None => user,
1770 },
1771 })
1772 }
1773
1774 #[must_use]
1776 pub fn named(user_id: impl Into<String>) -> Self {
1777 Self {
1778 user_id: user_id.into(),
1779 }
1780 }
1781
1782 #[must_use]
1784 pub fn user_id(&self) -> &str {
1785 &self.user_id
1786 }
1787}
1788
1789#[must_use]
1796pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1797 let identity = plan.identity();
1798 let user = xml_escape(principal.user_id());
1799 let arguments = plan
1800 .arguments()
1801 .iter()
1802 .map(|argument| quote_argument(&argument.to_string_lossy()))
1803 .collect::<Vec<_>>()
1804 .join(" ");
1805 let mut out = String::new();
1806 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1807 out.push_str(
1808 "<Task version=\"1.4\" \
1809 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1810 );
1811 out.push_str(" <RegistrationInfo>\n");
1812 out.push_str(&format!(
1813 " <Description>{}</Description>\n",
1814 xml_escape(identity.description())
1815 ));
1816 out.push_str(&format!(
1817 " <URI>\\{}</URI>\n",
1818 xml_escape(identity.name())
1819 ));
1820 out.push_str(" </RegistrationInfo>\n");
1821
1822 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1823 out.push_str(" <Enabled>true</Enabled>\n");
1824 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1825 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1826
1827 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1828 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1829 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1830 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1831 out.push_str(" </Principal>\n </Principals>\n");
1832
1833 out.push_str(" <Settings>\n");
1834 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1838 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1839 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1840 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1841 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1842 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1843 out.push_str(" <IdleSettings>\n");
1844 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1845 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1846 out.push_str(" </IdleSettings>\n");
1847 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1848 out.push_str(" <Enabled>true</Enabled>\n");
1849 out.push_str(" <Hidden>false</Hidden>\n");
1850 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1851 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1852 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1854 out.push_str(" <Priority>7</Priority>\n");
1855 out.push_str(" <RestartOnFailure>\n");
1856 out.push_str(&format!(
1857 " <Interval>{}</Interval>\n",
1858 iso8601_minutes(
1859 plan.restart()
1860 .effective_delay(DefinitionKind::WindowsScheduledTask)
1861 )
1862 ));
1863 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1864 out.push_str(" </RestartOnFailure>\n");
1865 out.push_str(" </Settings>\n");
1866
1867 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1868 out.push_str(&format!(
1869 " <Command>{}</Command>\n",
1870 xml_escape(&plan.binary().to_string_lossy())
1871 ));
1872 if !arguments.is_empty() {
1873 out.push_str(&format!(
1874 " <Arguments>{}</Arguments>\n",
1875 xml_escape(&arguments)
1876 ));
1877 }
1878 out.push_str(&format!(
1879 " <WorkingDirectory>{}</WorkingDirectory>\n",
1880 xml_escape(&plan.directories().state.to_string_lossy())
1881 ));
1882 out.push_str(" </Exec>\n </Actions>\n");
1883 out.push_str("</Task>\n");
1884 out
1885}
1886
1887fn iso8601_minutes(duration: Duration) -> String {
1890 format!("PT{}M", duration.as_secs() / 60)
1891}
1892
1893pub(crate) fn xml_escape(value: &str) -> String {
1901 let mut out = String::with_capacity(value.len());
1902 for c in value.chars() {
1903 match c {
1904 '&' => out.push_str("&"),
1905 '<' => out.push_str("<"),
1906 '>' => out.push_str(">"),
1907 '"' => out.push_str("""),
1908 '\'' => out.push_str("'"),
1909 other => out.push(other),
1910 }
1911 }
1912 out
1913}
1914
1915fn xml_unescape(value: &str) -> String {
1921 value
1922 .replace("<", "<")
1923 .replace(">", ">")
1924 .replace(""", "\"")
1925 .replace("'", "'")
1926 .replace("&", "&")
1927}
1928
1929#[derive(Debug, Clone, PartialEq, Eq)]
1938pub struct WindowsServiceSpec {
1939 pub name: String,
1941 pub display_name: String,
1943 pub description: String,
1945 pub automatic_start: bool,
1947 pub account: Option<String>,
1950 pub command_line: String,
1952 pub restart: RestartPolicy,
1954}
1955
1956#[must_use]
1958pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1959 WindowsServiceSpec {
1960 name: plan.identity().name().to_string(),
1961 display_name: plan.identity().display_name().to_string(),
1962 description: plan.identity().description().to_string(),
1963 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1968 account: match ServiceAccount::for_definition(
1969 DefinitionKind::WindowsService,
1970 plan.start_mode(),
1971 ) {
1972 ServiceAccount::LocalSystem => None,
1975 other => Some(other.as_str().to_string()),
1976 },
1977 command_line: plan.command_line(),
1978 restart: plan.restart(),
1979 }
1980}
1981
1982#[must_use]
1985fn windows_service_descriptor(plan: &InstallPlan) -> String {
1986 let spec = windows_service_spec(plan);
1987 let mut out = String::new();
1988 out.push_str("[windows-service]\n");
1989 out.push_str(&format!("Name={}\n", spec.name));
1990 out.push_str(&format!("DisplayName={}\n", spec.display_name));
1991 out.push_str(&format!("Description={}\n", spec.description));
1992 out.push_str("ServiceType=OWN_PROCESS\n");
1996 out.push_str(&format!(
1997 "StartType={}\n",
1998 if spec.automatic_start {
1999 "AutoStart"
2000 } else {
2001 "OnDemand"
2002 }
2003 ));
2004 out.push_str("ErrorControl=Normal\n");
2005 out.push_str(&format!(
2006 "Account={}\n",
2007 spec.account
2008 .as_deref()
2009 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2010 ));
2011 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2012 out.push_str(&format!(
2013 "FailureActionRestartDelaySecs={}\n",
2014 spec.restart.delay().as_secs()
2015 ));
2016 out.push_str(&format!(
2017 "FailureActionsResetPeriodSecs={}\n",
2018 spec.restart.reset_after().as_secs()
2019 ));
2020 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2024 out.push_str(&format!(
2025 "ReadWritePaths={}\n",
2026 plan.directories()
2027 .all()
2028 .iter()
2029 .map(|path| quote_argument(&path.to_string_lossy()))
2030 .collect::<Vec<_>>()
2031 .join(" ")
2032 ));
2033 out
2034}
2035
2036#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2042pub enum FindingKind {
2043 Excess,
2046 Shortfall,
2049}
2050
2051impl fmt::Display for FindingKind {
2052 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2053 f.write_str(match self {
2054 Self::Excess => "excess",
2055 Self::Shortfall => "shortfall",
2056 })
2057 }
2058}
2059
2060#[derive(Debug, Clone, PartialEq, Eq)]
2062pub struct PrivilegeFinding {
2063 pub kind: FindingKind,
2065 pub subject: String,
2067 pub detail: String,
2069}
2070
2071impl fmt::Display for PrivilegeFinding {
2072 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2073 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2074 }
2075}
2076
2077#[derive(Debug, Clone, PartialEq, Eq)]
2091pub struct PrivilegeReview {
2092 kind: DefinitionKind,
2093 account: ServiceAccount,
2094 controls: Vec<String>,
2095 findings: Vec<PrivilegeFinding>,
2096}
2097
2098impl PrivilegeReview {
2099 #[must_use]
2101 pub fn is_least_privilege(&self) -> bool {
2102 !self
2103 .findings
2104 .iter()
2105 .any(|finding| finding.kind == FindingKind::Excess)
2106 }
2107
2108 #[must_use]
2110 pub fn findings(&self) -> &[PrivilegeFinding] {
2111 &self.findings
2112 }
2113
2114 #[must_use]
2117 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2118 self.findings
2119 .iter()
2120 .filter(|finding| finding.kind == FindingKind::Excess)
2121 .collect()
2122 }
2123
2124 #[must_use]
2130 pub fn controls(&self) -> &[String] {
2131 &self.controls
2132 }
2133
2134 #[must_use]
2136 pub const fn account(&self) -> &ServiceAccount {
2137 &self.account
2138 }
2139
2140 #[must_use]
2142 pub const fn kind(&self) -> DefinitionKind {
2143 self.kind
2144 }
2145}
2146
2147impl fmt::Display for PrivilegeReview {
2148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2149 writeln!(
2150 f,
2151 "{} runs as {} ({})",
2152 self.kind,
2153 self.account,
2154 self.account.justification()
2155 )?;
2156 for control in &self.controls {
2157 writeln!(f, " confirmed {control}")?;
2158 }
2159 for finding in &self.findings {
2160 writeln!(f, " {finding}")?;
2161 }
2162 if self.is_least_privilege() {
2163 write!(f, " verdict least privilege")
2164 } else {
2165 write!(
2166 f,
2167 " verdict NOT least privilege: {} excess(es)",
2168 self.excesses().len()
2169 )
2170 }
2171 }
2172}
2173
2174#[must_use]
2180pub fn review_least_privilege(
2181 definition: &ServiceDefinition,
2182 plan: &InstallPlan,
2183) -> PrivilegeReview {
2184 let mut controls = Vec::new();
2185 let mut findings = Vec::new();
2186 match definition.kind() {
2187 DefinitionKind::SystemdUnit => {
2188 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2189 }
2190 DefinitionKind::LaunchdPlist => {
2191 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2192 }
2193 DefinitionKind::WindowsScheduledTask => {
2194 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2195 }
2196 DefinitionKind::WindowsService => {
2197 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2198 }
2199 }
2200 PrivilegeReview {
2201 kind: definition.kind(),
2202 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2207 controls,
2208 findings,
2209 }
2210}
2211
2212fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2214 let mut permitted = plan
2215 .directories()
2216 .all()
2217 .iter()
2218 .map(|path| path.to_string_lossy().into_owned())
2219 .collect::<Vec<_>>();
2220 if kind == DefinitionKind::SystemdUnit
2221 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2222 {
2223 permitted.push(secret_directory.to_string_lossy().into_owned());
2224 }
2225 permitted
2226}
2227
2228fn same_path_text(left: &str, right: &str) -> bool {
2235 if cfg!(windows) {
2236 left.eq_ignore_ascii_case(right)
2237 } else {
2238 left == right
2239 }
2240}
2241
2242fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2245 match kind {
2246 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2247 left.eq_ignore_ascii_case(right)
2248 }
2249 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2250 }
2251}
2252
2253fn review_writable_paths(
2255 kind: DefinitionKind,
2256 subject: &str,
2257 listed: &[String],
2258 plan: &InstallPlan,
2259 controls: &mut Vec<String>,
2260 findings: &mut Vec<PrivilegeFinding>,
2261) {
2262 let permitted = permitted_paths(kind, plan);
2263 for entry in listed {
2264 if !permitted
2265 .iter()
2266 .any(|allowed| same_path_for(kind, allowed, entry))
2267 {
2268 findings.push(PrivilegeFinding {
2269 kind: FindingKind::Excess,
2270 subject: subject.to_string(),
2271 detail: format!(
2272 "{entry} is writable but is not one of this registration's required paths"
2273 ),
2274 });
2275 }
2276 }
2277 for allowed in &permitted {
2278 if !listed
2279 .iter()
2280 .any(|entry| same_path_for(kind, allowed, entry))
2281 {
2282 findings.push(PrivilegeFinding {
2283 kind: FindingKind::Shortfall,
2284 subject: subject.to_string(),
2285 detail: format!(
2286 "{allowed} is one of this registration's directories but is not writable, \
2287 so the daemon cannot use it"
2288 ),
2289 });
2290 }
2291 }
2292 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2293 controls.push(format!(
2294 "{subject} names exactly the required application-data and credential paths"
2295 ));
2296 }
2297}
2298
2299fn review_inbound_surface(
2307 text: &str,
2308 markers: &[(&str, &str)],
2309 controls: &mut Vec<String>,
2310 findings: &mut Vec<PrivilegeFinding>,
2311) {
2312 let mut clean = true;
2313 for (marker, detail) in markers {
2314 if text.contains(marker) {
2315 clean = false;
2316 findings.push(PrivilegeFinding {
2317 kind: FindingKind::Excess,
2318 subject: (*marker).to_string(),
2319 detail: (*detail).to_string(),
2320 });
2321 }
2322 }
2323 if clean {
2324 controls.push(
2325 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2326 );
2327 }
2328}
2329
2330fn review_systemd(
2331 text: &str,
2332 plan: &InstallPlan,
2333 controls: &mut Vec<String>,
2334 findings: &mut Vec<PrivilegeFinding>,
2335) {
2336 let directives = ini_directives(text, "Service");
2337 for expected in SYSTEMD_HARDENING {
2338 let (key, value) = expected
2339 .split_once('=')
2340 .expect("every hardening directive is written as key=value");
2341 match directives.get(key) {
2342 Some(actual) if actual == value => controls.push((*expected).to_string()),
2343 Some(actual) => findings.push(PrivilegeFinding {
2344 kind: FindingKind::Excess,
2345 subject: key.to_string(),
2346 detail: format!(
2347 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2348 requirement does not ask for"
2349 ),
2350 }),
2351 None => findings.push(PrivilegeFinding {
2352 kind: FindingKind::Excess,
2353 subject: key.to_string(),
2354 detail: format!(
2355 "is absent, so the unit inherits systemd's default rather than `{value}`"
2356 ),
2357 }),
2358 }
2359 }
2360
2361 match directives.get("ReadWritePaths") {
2362 Some(value) => {
2363 let listed = split_quoted(value);
2364 review_writable_paths(
2365 DefinitionKind::SystemdUnit,
2366 "ReadWritePaths",
2367 &listed,
2368 plan,
2369 controls,
2370 findings,
2371 );
2372 }
2373 None => findings.push(PrivilegeFinding {
2374 kind: FindingKind::Shortfall,
2375 subject: "ReadWritePaths".to_string(),
2376 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2377 .to_string(),
2378 }),
2379 }
2380
2381 if directives.contains_key("PrivateUsers")
2384 && directives.get("PrivateUsers") == Some(&"no".to_string())
2385 {
2386 findings.push(PrivilegeFinding {
2387 kind: FindingKind::Excess,
2388 subject: "PrivateUsers".to_string(),
2389 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2390 .to_string(),
2391 });
2392 }
2393
2394 review_inbound_surface(
2395 text,
2396 &[
2397 (
2398 "ListenStream=",
2399 "asks systemd to open a listening socket for this service, which \
2400 07-security.md rule 2 forbids the product to have",
2401 ),
2402 (
2403 "ListenDatagram=",
2404 "asks systemd to open a listening socket for this service, which \
2405 07-security.md rule 2 forbids the product to have",
2406 ),
2407 ],
2408 controls,
2409 findings,
2410 );
2411}
2412
2413fn review_launchd(
2414 text: &str,
2415 plan: &InstallPlan,
2416 controls: &mut Vec<String>,
2417 findings: &mut Vec<PrivilegeFinding>,
2418) {
2419 match plist_string_value(text, "ProcessType") {
2423 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2424 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2425 }
2426 found => findings.push(PrivilegeFinding {
2427 kind: FindingKind::Shortfall,
2428 subject: "ProcessType".to_string(),
2429 detail: format!(
2430 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2431 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2432 when it next starts with no runner; `service status` says how to restart it",
2433 found.map_or_else(
2434 || "absent (launchd's `Standard`)".to_string(),
2435 |value| format!("`{value}`")
2436 )
2437 ),
2438 }),
2439 }
2440
2441 match plan.start_mode() {
2442 StartMode::Boot => {
2443 if plist_bool_value(text, "SessionCreate") == Some(true) {
2444 findings.push(PrivilegeFinding {
2445 kind: FindingKind::Excess,
2446 subject: "SessionCreate".to_string(),
2447 detail: "asks launchd to create a security session for a job that runs \
2448 outside every login session and has no use for one"
2449 .to_string(),
2450 });
2451 } else {
2452 controls.push("SessionCreate is not requested".to_string());
2453 }
2454 match plist_string_value(text, "UserName").as_deref() {
2455 Some("root") => {
2456 controls.push("UserName=root, stated rather than inherited".to_string())
2457 }
2458 Some(other) => findings.push(PrivilegeFinding {
2459 kind: FindingKind::Shortfall,
2460 subject: "UserName".to_string(),
2461 detail: format!(
2462 "is `{other}`, which cannot unlock the System Keychain: \
2463 /var/db/SystemKey is root-only, so the daemon would start and then \
2464 find no credential"
2465 ),
2466 }),
2467 None => findings.push(PrivilegeFinding {
2468 kind: FindingKind::Shortfall,
2469 subject: "UserName".to_string(),
2470 detail: "is absent, so the account is launchd's implicit default and this \
2471 review cannot confirm it"
2472 .to_string(),
2473 }),
2474 }
2475 }
2476 StartMode::Login => {
2477 if let Some(named) = plist_string_value(text, "UserName") {
2478 findings.push(PrivilegeFinding {
2479 kind: FindingKind::Excess,
2480 subject: "UserName".to_string(),
2481 detail: format!(
2482 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2483 naming an account here asks launchd for a switch a login-mode \
2484 registration has no reason to want"
2485 ),
2486 });
2487 } else {
2488 controls
2489 .push("no UserName: the agent runs as the operator and no other".to_string());
2490 }
2491 }
2492 }
2493
2494 review_inbound_surface(
2495 text,
2496 &[
2497 (
2498 "<key>Sockets</key>",
2499 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2500 forbids the product to have",
2501 ),
2502 (
2503 "<key>MachServices</key>",
2504 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2505 forbids the product to have",
2506 ),
2507 ],
2508 controls,
2509 findings,
2510 );
2511}
2512
2513fn review_scheduled_task(
2514 text: &str,
2515 controls: &mut Vec<String>,
2516 findings: &mut Vec<PrivilegeFinding>,
2517) {
2518 match xml_value(text, "RunLevel").as_deref() {
2519 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2520 Some(other) => findings.push(PrivilegeFinding {
2521 kind: FindingKind::Excess,
2522 subject: "RunLevel".to_string(),
2523 detail: format!(
2524 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2525 an administrator"
2526 ),
2527 }),
2528 None => findings.push(PrivilegeFinding {
2529 kind: FindingKind::Excess,
2530 subject: "RunLevel".to_string(),
2531 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2532 .to_string(),
2533 }),
2534 }
2535
2536 match xml_value(text, "LogonType").as_deref() {
2537 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2538 Some(other) => findings.push(PrivilegeFinding {
2539 kind: FindingKind::Excess,
2540 subject: "LogonType".to_string(),
2541 detail: format!(
2542 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2543 task; an interactive token needs neither"
2544 ),
2545 }),
2546 None => findings.push(PrivilegeFinding {
2547 kind: FindingKind::Shortfall,
2548 subject: "LogonType".to_string(),
2549 detail: "is absent, so this review cannot confirm that no credential is stored"
2550 .to_string(),
2551 }),
2552 }
2553}
2554
2555fn review_windows_service(
2556 text: &str,
2557 plan: &InstallPlan,
2558 controls: &mut Vec<String>,
2559 findings: &mut Vec<PrivilegeFinding>,
2560) {
2561 let directives = ini_directives(text, "windows-service");
2562
2563 match directives.get("ServiceType").map(String::as_str) {
2564 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2565 Some(other) => findings.push(PrivilegeFinding {
2566 kind: FindingKind::Excess,
2567 subject: "ServiceType".to_string(),
2568 detail: format!(
2569 "is `{other}`; an interactive or shared-process service reaches further than a \
2570 daemon that only talks to GitHub over HTTPS"
2571 ),
2572 }),
2573 None => findings.push(PrivilegeFinding {
2574 kind: FindingKind::Shortfall,
2575 subject: "ServiceType".to_string(),
2576 detail: "is absent, so this review cannot confirm the service is not interactive"
2577 .to_string(),
2578 }),
2579 }
2580
2581 match directives.get("Account").map(String::as_str) {
2585 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2586 controls.push(format!(
2587 "Account={account}: the only stock account the machine-scoped store's DACL \
2588 (SY, BA, OW) admits"
2589 ));
2590 }
2591 Some(other) => findings.push(PrivilegeFinding {
2592 kind: FindingKind::Shortfall,
2593 subject: "Account".to_string(),
2594 detail: format!(
2595 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2596 daemon would start and then find no credential. Widening that DACL is not this \
2597 registration's to do: an ACE reaching `{other}` would also reach every other \
2598 service running under it"
2599 ),
2600 }),
2601 None => findings.push(PrivilegeFinding {
2602 kind: FindingKind::Shortfall,
2603 subject: "Account".to_string(),
2604 detail: "is absent, so this review cannot confirm which account was registered"
2605 .to_string(),
2606 }),
2607 }
2608
2609 match directives.get("ReadWritePaths") {
2610 Some(value) => {
2611 let listed = split_quoted(value);
2612 review_writable_paths(
2613 DefinitionKind::WindowsService,
2614 "ReadWritePaths",
2615 &listed,
2616 plan,
2617 controls,
2618 findings,
2619 );
2620 }
2621 None => findings.push(PrivilegeFinding {
2622 kind: FindingKind::Shortfall,
2623 subject: "ReadWritePaths".to_string(),
2624 detail: "is absent, so the directories the service was installed against are not \
2625 recorded"
2626 .to_string(),
2627 }),
2628 }
2629}
2630
2631fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2639 let mut out = BTreeMap::new();
2640 let mut inside = false;
2641 for line in text.lines() {
2642 let line = line.trim();
2643 if line.starts_with('[') && line.ends_with(']') {
2644 inside = &line[1..line.len() - 1] == section;
2645 continue;
2646 }
2647 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2648 continue;
2649 }
2650 if let Some((key, value)) = line.split_once('=') {
2651 out.insert(key.trim().to_string(), value.trim().to_string());
2652 }
2653 }
2654 out
2655}
2656
2657fn split_quoted(value: &str) -> Vec<String> {
2660 let mut out = Vec::new();
2661 let mut rest = value.trim();
2662 while !rest.is_empty() {
2663 if rest.starts_with('"') {
2664 if let Some(parsed) = executable_from_command_line(rest) {
2667 out.push(parsed.to_string_lossy().into_owned());
2668 }
2669 let mut depth = 0usize;
2671 let mut end = rest.len();
2672 for (index, c) in rest.char_indices() {
2673 match c {
2674 '\\' => depth += 1,
2675 '"' => {
2676 if depth.is_multiple_of(2) && index > 0 {
2677 end = index + 1;
2678 break;
2679 }
2680 depth = 0;
2681 }
2682 _ => depth = 0,
2683 }
2684 }
2685 rest = rest[end..].trim_start();
2686 } else {
2687 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2688 out.push(rest[..end].to_string());
2689 rest = rest[end..].trim_start();
2690 }
2691 }
2692 out
2693}
2694
2695pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2707 let open = format!("<{tag}>");
2708 let close = format!("</{tag}>");
2709 let start = text.find(&open)? + open.len();
2710 let end = text[start..].find(&close)? + start;
2711 Some(xml_unescape(text[start..end].trim()))
2712}
2713
2714#[cfg(any(windows, test))]
2720fn windows_login_task_starts_automatically(document: &str) -> bool {
2721 document.contains("<LogonTrigger>")
2722 && xml_value(document, "Enabled").as_deref() != Some("false")
2723}
2724
2725fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2727 let marker = format!("<key>{key}</key>");
2728 let start = text.find(&marker)? + marker.len();
2729 Some(text[start..].trim_start())
2730}
2731
2732pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
2733 let rest = plist_value_after_key(text, key)?;
2734 if !rest.starts_with("<string>") {
2735 return None;
2736 }
2737 xml_value(rest, "string")
2738}
2739
2740fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2741 let rest = plist_value_after_key(text, key)?;
2742 if rest.starts_with("<true/>") {
2743 Some(true)
2744 } else if rest.starts_with("<false/>") {
2745 Some(false)
2746 } else {
2747 None
2748 }
2749}
2750
2751pub const RECORD_SCHEMA_VERSION: u32 = 1;
2762
2763#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2775pub struct InstallRecord {
2776 pub schema_version: u32,
2778 pub service_name: String,
2780 pub manager: String,
2782 pub start_mode: StartMode,
2784 pub account: ServiceAccount,
2786 pub binary: PathBuf,
2788 #[serde(default)]
2796 pub source_binary: Option<PathBuf>,
2797 pub arguments: Vec<String>,
2799 pub restart_delay_secs: u64,
2801 pub restart_reset_secs: u64,
2803 pub log_file: PathBuf,
2805 #[serde(default)]
2813 pub starts_on_demand: bool,
2814 pub definition_path: Option<PathBuf>,
2816 pub installed_at: DateTime<Utc>,
2818 pub installed_by_version: String,
2820 pub directories: ServiceDirectories,
2825}
2826
2827impl InstallRecord {
2828 #[must_use]
2830 pub fn path(paths: &AppPaths) -> PathBuf {
2831 paths.config_dir().join(RECORD_FILE)
2832 }
2833
2834 #[must_use]
2836 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2837 Self {
2838 schema_version: RECORD_SCHEMA_VERSION,
2839 service_name: plan.identity().name().to_string(),
2840 manager: definition.kind().manager().to_string(),
2841 start_mode: plan.start_mode(),
2842 account: plan.account().clone(),
2843 binary: plan.binary().to_path_buf(),
2844 arguments: plan
2845 .arguments()
2846 .iter()
2847 .map(|argument| argument.to_string_lossy().into_owned())
2848 .collect(),
2849 restart_delay_secs: plan.restart().delay().as_secs(),
2850 restart_reset_secs: plan.restart().reset_after().as_secs(),
2851 starts_on_demand: plan.is_on_demand(),
2852 source_binary: plan.source_binary().map(Path::to_path_buf),
2853 log_file: plan.directories().log_file(),
2854 definition_path: definition.install_path().map(Path::to_path_buf),
2855 installed_at: at,
2856 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2857 directories: plan.directories().clone(),
2858 }
2859 }
2860
2861 #[must_use]
2869 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2870 let plan = InstallPlan::unchecked(
2871 identity,
2872 self.start_mode,
2873 self.binary.clone(),
2874 self.directories.clone(),
2875 )
2876 .with_arguments(self.arguments.clone())
2877 .with_restart(self.restart());
2878 let plan = if self.starts_on_demand {
2879 plan.started_on_demand()
2880 } else {
2881 plan
2882 };
2883 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2884 Ok(store) => plan.with_secret_guard(store.guard()),
2885 Err(_) => plan,
2886 }
2887 }
2888
2889 #[must_use]
2893 pub fn restart(&self) -> RestartPolicy {
2894 RestartPolicy::new(
2895 Duration::from_secs(self.restart_delay_secs),
2896 Duration::from_secs(self.restart_reset_secs),
2897 )
2898 .unwrap_or_default()
2899 }
2900
2901 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2912 let path = Self::path(paths);
2913 let text = match std::fs::read_to_string(&path) {
2914 Ok(text) => text,
2915 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2916 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2919 return Err(ServiceError::RecordNotPermitted {
2920 path,
2921 detail: error.to_string(),
2922 });
2923 }
2924 Err(error) => {
2925 return Err(ServiceError::Record {
2926 operation: "read",
2927 path,
2928 detail: error.to_string(),
2929 });
2930 }
2931 };
2932 let record: Self =
2933 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2934 path: path.clone(),
2935 detail: error.to_string(),
2936 })?;
2937 if record.schema_version != RECORD_SCHEMA_VERSION {
2938 return Err(ServiceError::RecordUnreadable {
2939 path,
2940 detail: format!(
2941 "it declares schema version {} and this build reads version {}",
2942 record.schema_version, RECORD_SCHEMA_VERSION
2943 ),
2944 });
2945 }
2946 Ok(Some(record))
2947 }
2948
2949 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2955 use std::io::Write as _;
2956
2957 let path = Self::path(paths);
2958 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2959 operation: "encode",
2960 path: path.clone(),
2961 detail: error.to_string(),
2962 })?;
2963 if let Some(parent) = path.parent() {
2964 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2965 operation: "write",
2966 path: path.clone(),
2967 detail: error.to_string(),
2968 })?;
2969 }
2970 let parent = path.parent().ok_or_else(|| ServiceError::Record {
2971 operation: "write",
2972 path: path.clone(),
2973 detail: "the record path has no parent directory".to_string(),
2974 })?;
2975 let mut temporary =
2976 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2977 operation: "write",
2978 path: path.clone(),
2979 detail: error.to_string(),
2980 })?;
2981 temporary
2982 .write_all(text.as_bytes())
2983 .and_then(|()| temporary.as_file().sync_all())
2984 .map_err(|error| ServiceError::Record {
2985 operation: "write",
2986 path: path.clone(),
2987 detail: error.to_string(),
2988 })?;
2989 #[cfg(unix)]
3005 {
3006 use std::os::unix::fs::PermissionsExt as _;
3007
3008 temporary
3009 .as_file()
3010 .set_permissions(std::fs::Permissions::from_mode(0o644))
3011 .map_err(|error| ServiceError::Record {
3012 operation: "write",
3013 path: path.clone(),
3014 detail: error.to_string(),
3015 })?;
3016 }
3017 temporary
3018 .persist(&path)
3019 .map(|_| ())
3020 .map_err(|error| ServiceError::Record {
3021 operation: "write",
3022 path,
3023 detail: error.error.to_string(),
3024 })
3025 }
3026
3027 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3033 let path = Self::path(paths);
3034 match std::fs::remove_file(&path) {
3035 Ok(()) => Ok(true),
3036 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3037 Err(error) => Err(ServiceError::Record {
3038 operation: "remove",
3039 path,
3040 detail: error.to_string(),
3041 }),
3042 }
3043 }
3044}
3045
3046const CONTACT_SCHEMA_VERSION: u32 = 1;
3052
3053#[derive(Debug, Clone, Serialize, Deserialize)]
3054struct ContactRecord {
3055 schema_version: u32,
3056 last_success: DateTime<Utc>,
3057}
3058
3059pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3080 write_state_record(
3081 &contact_path(paths),
3082 &ContactRecord {
3083 schema_version: CONTACT_SCHEMA_VERSION,
3084 last_success: at,
3085 },
3086 )
3087}
3088
3089pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3101 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3102}
3103
3104#[must_use]
3106pub fn contact_path(paths: &AppPaths) -> PathBuf {
3107 paths.state_dir().join(CONTACT_FILE)
3108}
3109
3110fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3113 let failed = |detail: String| ServiceError::Record {
3114 operation: "write",
3115 path: path.to_path_buf(),
3116 detail,
3117 };
3118 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3119 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3120 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3121 let temporary = path.with_extension("toml.new");
3122 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3123 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3124}
3125
3126fn read_state_record<T: serde::de::DeserializeOwned>(
3129 path: &Path,
3130) -> Result<Option<T>, ServiceError> {
3131 let failed = |detail: String| ServiceError::Record {
3132 operation: "read",
3133 path: path.to_path_buf(),
3134 detail,
3135 };
3136 let text = match std::fs::read_to_string(path) {
3137 Ok(text) => text,
3138 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3139 Err(error) => return Err(failed(error.to_string())),
3140 };
3141 toml::from_str(&text)
3142 .map(Some)
3143 .map_err(|error| failed(error.to_string()))
3144}
3145
3146fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3148 match std::fs::remove_file(path) {
3149 Ok(()) => Ok(()),
3150 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3151 Err(error) => Err(ServiceError::Record {
3152 operation: "remove",
3153 path: path.to_path_buf(),
3154 detail: error.to_string(),
3155 }),
3156 }
3157}
3158
3159#[derive(Debug, Serialize, Deserialize)]
3164#[serde(deny_unknown_fields)]
3165struct CredentialRejectionRecord {
3166 schema_version: u32,
3167 rejected_since: DateTime<Utc>,
3168}
3169
3170pub fn record_github_credential_rejected(
3186 paths: &AppPaths,
3187 at: DateTime<Utc>,
3188) -> Result<(), ServiceError> {
3189 if github_credential_rejected_since(paths)
3190 .ok()
3191 .flatten()
3192 .is_some()
3193 {
3194 return Ok(());
3195 }
3196 write_state_record(
3197 &credential_rejection_path(paths),
3198 &CredentialRejectionRecord {
3199 schema_version: CONTACT_SCHEMA_VERSION,
3200 rejected_since: at,
3201 },
3202 )
3203}
3204
3205pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3211 remove_state_record(&credential_rejection_path(paths))
3212}
3213
3214pub fn github_credential_rejected_since(
3220 paths: &AppPaths,
3221) -> Result<Option<DateTime<Utc>>, ServiceError> {
3222 Ok(
3223 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3224 .map(|record| record.rejected_since),
3225 )
3226}
3227
3228#[must_use]
3230pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3231 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3232}
3233
3234#[derive(Debug, Serialize, Deserialize)]
3235#[serde(deny_unknown_fields)]
3236struct CredentialUnreadableRecord {
3237 schema_version: u32,
3238 unreadable_since: DateTime<Utc>,
3239}
3240
3241pub fn record_credential_unreadable(
3260 paths: &AppPaths,
3261 at: DateTime<Utc>,
3262) -> Result<(), ServiceError> {
3263 if credential_unreadable_since(paths).ok().flatten().is_some() {
3264 return Ok(());
3265 }
3266 write_state_record(
3267 &credential_unreadable_path(paths),
3268 &CredentialUnreadableRecord {
3269 schema_version: CONTACT_SCHEMA_VERSION,
3270 unreadable_since: at,
3271 },
3272 )
3273}
3274
3275pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3281 remove_state_record(&credential_unreadable_path(paths))
3282}
3283
3284pub fn credential_unreadable_since(
3291 paths: &AppPaths,
3292) -> Result<Option<DateTime<Utc>>, ServiceError> {
3293 Ok(
3294 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3295 .map(|record| record.unreadable_since),
3296 )
3297}
3298
3299#[must_use]
3301pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3302 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3303}
3304
3305#[must_use]
3313pub fn sign_in_instruction(mode: StartMode) -> String {
3314 let (command, place) = match mode {
3315 StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
3316 StartMode::Boot => (
3317 "runner-manager auth login --start-at boot",
3318 " from an elevated terminal",
3319 ),
3320 StartMode::Login => (
3321 "runner-manager auth login --start-at login",
3322 MACOS_LOGIN_PLACE,
3323 ),
3324 };
3325 format!("`{command}`{place}")
3326}
3327
3328pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3331 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3332 keychain)"
3333} else {
3334 ""
3335};
3336
3337#[derive(Debug, Clone, PartialEq, Eq)]
3354pub struct DefinitionDrift {
3355 pub path: PathBuf,
3357 pub installed_by_version: String,
3359 pub rendered: String,
3361 pub start_mode: StartMode,
3363 pub manager: DefinitionKind,
3365}
3366
3367fn definition_drift(
3375 controls: &dyn ControlFactory,
3376 identity: &ServiceIdentity,
3377 record: &InstallRecord,
3378) -> Option<DefinitionDrift> {
3379 let compare = match host_definition_kind(record.start_mode) {
3380 DefinitionKind::SystemdUnit => systemd_definition_drift,
3381 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3382 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3383 };
3384 let path = record.definition_path.clone()?;
3385 let installed = controls.installed_definition(&path)?;
3386 compare(identity, record, path, &installed)
3387}
3388
3389fn launchd_definition_drift(
3398 identity: &ServiceIdentity,
3399 record: &InstallRecord,
3400 path: PathBuf,
3401 installed: &str,
3402) -> Option<DefinitionDrift> {
3403 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3404 || DefinitionDrift {
3405 path,
3406 installed_by_version: record.installed_by_version.clone(),
3407 rendered: launchd_plist(&record.plan(identity.clone())),
3408 start_mode: record.start_mode,
3409 manager: DefinitionKind::LaunchdPlist,
3410 },
3411 )
3412}
3413
3414fn systemd_definition_drift(
3415 identity: &ServiceIdentity,
3416 record: &InstallRecord,
3417 path: PathBuf,
3418 installed: &str,
3419) -> Option<DefinitionDrift> {
3420 let rendered = systemd_unit(&record.plan(identity.clone()));
3421 (installed != rendered).then(|| DefinitionDrift {
3422 path,
3423 installed_by_version: record.installed_by_version.clone(),
3424 rendered,
3425 start_mode: record.start_mode,
3426 manager: DefinitionKind::SystemdUnit,
3427 })
3428}
3429
3430const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3435
3436#[derive(Debug, Clone, Serialize, Deserialize)]
3437struct RootRefusalFile {
3438 schema_version: u32,
3439 #[serde(default)]
3441 refusals: BTreeMap<String, RootRefusalEntry>,
3442}
3443
3444#[derive(Debug, Clone, Serialize, Deserialize)]
3445struct RootRefusalEntry {
3446 at: DateTime<Utc>,
3447 kind: String,
3448 root: String,
3449 detail: String,
3450}
3451
3452#[derive(Debug, Clone, PartialEq, Eq)]
3454pub struct RunnerRootRefusal {
3455 pub policy: String,
3457 pub at: DateTime<Utc>,
3459 pub kind: String,
3461 pub root: String,
3463 pub detail: String,
3466}
3467
3468pub fn record_runner_root_refusal(
3502 paths: &AppPaths,
3503 policy: &str,
3504 at: DateTime<Utc>,
3505 kind: &str,
3506 root: &str,
3507 detail: &str,
3508) -> Result<(), ServiceError> {
3509 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3510 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3511 refusals: BTreeMap::new(),
3512 });
3513 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3514 file.refusals.insert(
3515 policy.to_owned(),
3516 RootRefusalEntry {
3517 at,
3518 kind: kind.to_owned(),
3519 root: root.to_owned(),
3520 detail: detail.to_owned(),
3521 },
3522 );
3523 write_refusal_file(paths, &file)
3524}
3525
3526pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3537 let Some(mut file) = read_refusal_file(paths)? else {
3538 return Ok(());
3539 };
3540 if file.refusals.remove(policy).is_none() {
3541 return Ok(());
3542 }
3543 if file.refusals.is_empty() {
3544 let path = root_refusal_path(paths);
3545 return match std::fs::remove_file(&path) {
3546 Ok(()) => Ok(()),
3547 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3548 Err(error) => Err(ServiceError::Record {
3549 operation: "remove",
3550 path,
3551 detail: error.to_string(),
3552 }),
3553 };
3554 }
3555 write_refusal_file(paths, &file)
3556}
3557
3558pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3570 Ok(read_refusal_file(paths)?
3571 .map(|file| {
3572 file.refusals
3573 .into_iter()
3574 .map(|(policy, entry)| RunnerRootRefusal {
3575 policy,
3576 at: entry.at,
3577 kind: entry.kind,
3578 root: entry.root,
3579 detail: entry.detail,
3580 })
3581 .collect()
3582 })
3583 .unwrap_or_default())
3584}
3585
3586fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3587 read_state_record(&root_refusal_path(paths))
3588}
3589
3590fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3591 write_state_record(&root_refusal_path(paths), file)
3592}
3593
3594#[must_use]
3596pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3597 paths.state_dir().join(ROOT_REFUSAL_FILE)
3598}
3599
3600#[derive(Debug, Clone, PartialEq, Eq)]
3611pub enum BinaryPath {
3612 Current {
3614 path: PathBuf,
3616 },
3617 Missing {
3624 recorded: PathBuf,
3626 },
3627 NotExecutable {
3630 recorded: PathBuf,
3632 detail: String,
3634 },
3635 Diverged {
3637 recorded: PathBuf,
3639 registered: PathBuf,
3641 },
3642}
3643
3644impl BinaryPath {
3645 #[must_use]
3647 pub const fn is_error(&self) -> bool {
3648 !matches!(self, Self::Current { .. })
3649 }
3650
3651 #[must_use]
3653 pub fn recorded(&self) -> &Path {
3654 match self {
3655 Self::Current { path } => path,
3656 Self::Missing { recorded }
3657 | Self::NotExecutable { recorded, .. }
3658 | Self::Diverged { recorded, .. } => recorded,
3659 }
3660 }
3661}
3662
3663impl fmt::Display for BinaryPath {
3664 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3665 match self {
3666 Self::Current { path } => write!(f, "{}", path.display()),
3667 Self::Missing { recorded } => write!(
3668 f,
3669 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3670 package manager that moved the binary is the usual cause; an `npm i -g` \
3671 installation moves with the active Node version. Run `service install` again \
3672 from the binary that is now installed.",
3673 recorded.display()
3674 ),
3675 Self::NotExecutable { recorded, detail } => write!(
3676 f,
3677 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3678 from the installed binary.",
3679 recorded.display()
3680 ),
3681 Self::Diverged {
3682 recorded,
3683 registered,
3684 } => write!(
3685 f,
3686 "{} -- STALE: the service manager is registered to start {} instead. Something \
3687 has edited the registration since it was installed. Run `service uninstall` and \
3688 `service install`; neither touches configuration, secrets, or the cache.",
3689 recorded.display(),
3690 registered.display()
3691 ),
3692 }
3693 }
3694}
3695
3696#[must_use]
3704pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3705 match std::fs::metadata(recorded) {
3706 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3707 return BinaryPath::Missing {
3708 recorded: recorded.to_path_buf(),
3709 };
3710 }
3711 Err(error) => {
3712 return BinaryPath::NotExecutable {
3713 recorded: recorded.to_path_buf(),
3714 detail: format!("it cannot be inspected ({error})"),
3715 };
3716 }
3717 Ok(metadata) if !metadata.is_file() => {
3718 return BinaryPath::NotExecutable {
3719 recorded: recorded.to_path_buf(),
3720 detail: "what is there is not a file".to_string(),
3721 };
3722 }
3723 Ok(_) => {}
3724 }
3725 if let Some(registered) = registered
3726 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3727 {
3728 return BinaryPath::Diverged {
3729 recorded: recorded.to_path_buf(),
3730 registered: registered.to_path_buf(),
3731 };
3732 }
3733 BinaryPath::Current {
3734 path: recorded.to_path_buf(),
3735 }
3736}
3737
3738#[derive(Debug, Clone, PartialEq, Eq)]
3744pub struct Registration {
3745 pub manager: DefinitionKind,
3750 pub start_mode: StartMode,
3752 pub command_line: String,
3754 pub account: Option<String>,
3756 pub running: bool,
3758 pub starts_automatically: bool,
3765 pub restart_delay: Option<Duration>,
3774}
3775
3776impl Registration {
3777 #[must_use]
3779 pub fn binary(&self) -> Option<PathBuf> {
3780 executable_from_command_line(&self.command_line)
3781 }
3782}
3783
3784pub trait ServiceControl: fmt::Debug {
3790 fn manager(&self) -> DefinitionKind;
3792
3793 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3799
3800 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3811
3812 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3818
3819 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3826
3827 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3833}
3834
3835pub trait ControlFactory: fmt::Debug + Send + Sync {
3841 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3847
3848 fn installed_definition(&self, _path: &Path) -> Option<String> {
3852 None
3853 }
3854}
3855
3856#[derive(Debug, Clone, Copy, Default)]
3858pub struct HostControls;
3859
3860#[derive(Debug, Clone)]
3866pub struct Installed {
3867 pub plan: InstallPlan,
3869 pub definition: ServiceDefinition,
3871 pub record: InstallRecord,
3873 pub review: PrivilegeReview,
3875 pub runner_root: RootAccessSummary,
3880 pub replaced_existing: bool,
3888}
3889
3890#[derive(Debug, Clone, PartialEq, Eq)]
3892pub struct Uninstalled {
3893 pub removed_registration: bool,
3895 pub removed_record: bool,
3897 pub removed_definition: Option<PathBuf>,
3899 pub preserved: Vec<PathBuf>,
3906}
3907
3908impl fmt::Display for Uninstalled {
3909 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3910 if self.removed_registration {
3911 writeln!(f, "The service registration was removed.")?;
3912 } else {
3913 writeln!(f, "There was no service registration to remove.")?;
3914 }
3915 writeln!(f, "Nothing else was deleted. These are untouched:")?;
3916 for path in &self.preserved {
3917 writeln!(f, " {}", path.display())?;
3918 }
3919 write!(
3920 f,
3921 "The stored GitHub token is untouched too; `auth logout` is what purges it."
3922 )
3923 }
3924}
3925
3926#[derive(Debug, Clone, PartialEq, Eq)]
3928pub struct StartModeChange {
3929 pub from: StartMode,
3931 pub to: StartMode,
3933 pub changed: bool,
3935 pub store_scope: crate::secrets::SecretScope,
3938 pub runner_root: RootAccessSummary,
3944}
3945
3946impl fmt::Display for StartModeChange {
3947 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3948 if !self.changed {
3949 return write!(f, "The service already starts at {}.", self.to);
3950 }
3951 write!(
3952 f,
3953 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3954 if the token was stored under the other scope, run `auth login` again. {}",
3955 self.to, self.from, self.store_scope, self.runner_root
3956 )
3957 }
3958}
3959
3960#[derive(Debug, Clone)]
3968pub struct ServiceOperations {
3969 paths: AppPaths,
3970 identity: ServiceIdentity,
3971 controls: std::sync::Arc<dyn ControlFactory>,
3972 runner_root: Option<LocalAbsolutePath>,
3973}
3974
3975impl ServiceOperations {
3976 #[must_use]
3978 pub fn on_this_host(paths: AppPaths) -> Self {
3979 Self::with_controls(
3980 paths,
3981 ServiceIdentity::product(),
3982 std::sync::Arc::new(HostControls),
3983 )
3984 }
3985
3986 #[must_use]
3994 pub fn with_controls(
3995 paths: AppPaths,
3996 identity: ServiceIdentity,
3997 controls: std::sync::Arc<dyn ControlFactory>,
3998 ) -> Self {
3999 Self {
4000 paths,
4001 identity,
4002 controls,
4003 runner_root: None,
4004 }
4005 }
4006
4007 #[must_use]
4026 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4027 self.runner_root = Some(root);
4028 self
4029 }
4030
4031 #[must_use]
4033 pub const fn paths(&self) -> &AppPaths {
4034 &self.paths
4035 }
4036
4037 #[must_use]
4039 pub const fn identity(&self) -> &ServiceIdentity {
4040 &self.identity
4041 }
4042
4043 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4063 self.paths
4064 .create_all()
4065 .map_err(|source| ServiceError::Paths {
4066 source: Box::new(source),
4067 })?;
4068
4069 let _guard = self.refuse_while_an_agent_runs()?;
4072
4073 let (replacing, previous_was_running) = match self.find_registration()? {
4102 Some((existing, registration)) if existing == request.start_mode() => {
4103 (true, registration.running)
4104 }
4105 Some((existing, _)) => {
4106 return Err(ServiceError::AlreadyInstalled {
4107 name: self.identity.name().to_string(),
4108 existing,
4109 requested: request.start_mode(),
4110 });
4111 }
4112 None => (false, false),
4113 };
4114
4115 let plan = InstallPlan::resolve(
4116 self.identity.clone(),
4117 request,
4118 ServiceDirectories::of(&self.paths),
4119 )?;
4120
4121 let control = self.controls.control(plan.start_mode())?;
4126
4127 let root = self.prepare_runner_root(plan.start_mode())?;
4133
4134 let previous = if replacing {
4139 InstallRecord::read(&self.paths).ok().flatten()
4140 } else {
4141 None
4142 };
4143 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4144 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4145 }
4146
4147 let definition = match control.install(&plan) {
4148 Ok(definition) => definition,
4149 Err(cause) => {
4150 let restored =
4153 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4154 return Err(rolled_back(
4155 retained_runner_root(&root),
4156 restored,
4157 "install",
4158 &self.identity,
4159 cause,
4160 ));
4161 }
4162 };
4163 let review = review_least_privilege(&definition, &plan);
4164 let record = InstallRecord::of(&plan, &definition, Utc::now());
4165 if let Err(cause) = record.write(&self.paths) {
4166 let rollback = control
4167 .uninstall(&self.identity)
4168 .map(|_| ())
4169 .and_then(|()| {
4170 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4171 });
4172 return Err(rolled_back(
4173 retained_runner_root(&root),
4174 rollback,
4175 "install",
4176 &self.identity,
4177 cause,
4178 ));
4179 }
4180
4181 if let Err(cause) = control.start(&self.identity) {
4188 let remove_new = control.uninstall(&self.identity).map(|_| ());
4189 let restore_registration = remove_new.and_then(|()| {
4190 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4191 });
4192 let restore_record = restore_registration.and_then(|()| match &previous {
4193 Some(previous) => previous.write(&self.paths),
4194 None => InstallRecord::remove(&self.paths).map(|_| ()),
4195 });
4196 return Err(rolled_back(
4197 retained_runner_root(&root),
4198 restore_record,
4199 "install and start",
4200 &self.identity,
4201 cause,
4202 ));
4203 }
4204 Ok(Installed {
4205 plan,
4206 definition,
4207 record,
4208 review,
4209 runner_root: root.summary().clone(),
4210 replaced_existing: replacing,
4211 })
4212 }
4213
4214 fn reinstate(
4225 &self,
4226 control: &dyn ServiceControl,
4227 previous: Option<&InstallRecord>,
4228 was_running: bool,
4229 ) -> Result<(), ServiceError> {
4230 let Some(record) = previous else {
4231 return Ok(());
4232 };
4233 control.install(&record.plan(self.identity.clone()))?;
4234 if was_running {
4235 control.start(&self.identity)?;
4236 }
4237 Ok(())
4238 }
4239
4240 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4249 let record = InstallRecord::read(&self.paths).ok().flatten();
4250 let removed_definition = record
4251 .as_ref()
4252 .and_then(|record| record.definition_path.clone());
4253
4254 let mut removed_registration = false;
4255 for mode in [StartMode::Boot, StartMode::Login] {
4259 let control = self.controls.control(mode)?;
4260 if control.uninstall(&self.identity)? {
4261 removed_registration = true;
4262 }
4263 }
4264 let removed_record = InstallRecord::remove(&self.paths)?;
4265 Ok(Uninstalled {
4266 removed_registration,
4267 removed_record,
4268 removed_definition: removed_definition.filter(|_| removed_registration),
4269 preserved: self
4270 .paths
4271 .all()
4272 .iter()
4273 .map(|(_, path)| (*path).to_path_buf())
4274 .collect(),
4275 })
4276 }
4277
4278 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4300 let Some(record) = InstallRecord::read(&self.paths)? else {
4301 return Err(ServiceError::NotInstalled {
4302 name: self.identity.name().to_string(),
4303 operation: "switch the start mode of",
4304 });
4305 };
4306 let from = record.start_mode;
4307 if from == to {
4308 return Ok(StartModeChange {
4312 from,
4313 to,
4314 changed: false,
4315 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4316 runner_root: RootAccessSummary::NotApplicable,
4317 });
4318 }
4319
4320 #[cfg(windows)]
4321 let arguments = {
4322 let mut arguments = record.arguments.clone();
4323 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4324 if to == StartMode::Boot {
4325 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4326 }
4327 arguments
4328 };
4329 #[cfg(not(windows))]
4330 let arguments = record.arguments.clone();
4331 let plan = InstallPlan::unchecked(
4332 self.identity.clone(),
4333 to,
4334 record.binary.clone(),
4335 record.directories.clone(),
4336 )
4337 .with_arguments(arguments)
4338 .with_restart(record.restart());
4339 let plan = if record.starts_on_demand {
4340 plan.started_on_demand()
4341 } else {
4342 plan
4343 };
4344 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4345 Ok(store) => plan.with_secret_guard(store.guard()),
4346 Err(_) => plan,
4347 };
4348
4349 let target = self.controls.control(to)?;
4356 let previous = self.controls.control(from)?;
4361
4362 let root = self.prepare_runner_root(to)?;
4370
4371 let definition = match target.install(&plan) {
4372 Ok(definition) => definition,
4373 Err(cause) => {
4374 return Err(undo_runner_root(
4375 &root,
4376 "switch start mode",
4377 &self.identity,
4378 cause,
4379 ));
4380 }
4381 };
4382 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4383 if let Err(cause) = next_record.write(&self.paths) {
4384 return Err(rolled_back(
4385 retained_runner_root(&root),
4386 target.uninstall(&self.identity),
4387 "switch start mode",
4388 &self.identity,
4389 cause,
4390 ));
4391 }
4392
4393 if let Err(cause) = previous.uninstall(&self.identity) {
4397 let target_rollback = target.uninstall(&self.identity);
4398 let record_rollback = record.write(&self.paths);
4399 return Err(rolled_back(
4400 retained_runner_root(&root),
4401 target_rollback.and(record_rollback),
4402 "switch start mode",
4403 &self.identity,
4404 cause,
4405 ));
4406 }
4407 Ok(StartModeChange {
4408 from,
4409 to,
4410 changed: true,
4411 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4412 runner_root: root.summary().clone(),
4413 })
4414 }
4415
4416 pub fn start(&self) -> Result<(), ServiceError> {
4422 let Some((mode, _)) = self.find_registration()? else {
4423 return Err(ServiceError::NotInstalled {
4424 name: self.identity.name().to_string(),
4425 operation: "start",
4426 });
4427 };
4428 self.controls.control(mode)?.start(&self.identity)
4429 }
4430
4431 pub fn stop(&self) -> Result<bool, ServiceError> {
4437 let Some((mode, _)) = self.find_registration()? else {
4438 return Err(ServiceError::NotInstalled {
4439 name: self.identity.name().to_string(),
4440 operation: "stop",
4441 });
4442 };
4443 self.controls.control(mode)?.stop(&self.identity)
4444 }
4445
4446 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4465 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4466 Ok(record) => (record, None),
4467 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4468 Err(error) => return Err(error),
4469 };
4470 let found = self.find_registration()?;
4471 let last_github_contact = last_github_contact(&self.paths)?;
4472 let drift = record
4473 .as_ref()
4474 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4475 let credential_rejected_since =
4476 github_credential_rejected_since(&self.paths).ok().flatten();
4477 let credential_unreadable_since = credential_unreadable_since(&self.paths).ok().flatten();
4478 Ok(ServiceStatus::compose(
4479 self.identity.clone(),
4480 record,
4481 record_refused.as_ref(),
4482 found.map(|(_, registration)| registration),
4483 last_github_contact,
4484 &self.paths,
4485 )
4486 .with_definition_drift(drift)
4487 .with_credential_rejection(credential_rejected_since)
4488 .with_credential_unreadable(credential_unreadable_since))
4489 }
4490
4491 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4498 Ok(InstallRecord::read(&self.paths)?
4499 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4500 }
4501
4502 #[cfg(target_os = "macos")]
4524 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4525 let root = unsafe { libc::getuid() } == 0;
4531 if root != (drift.start_mode == StartMode::Boot) {
4532 return Err(ServiceError::Control {
4533 operation: "rewrite",
4534 name: self.identity.name().to_string(),
4535 manager: "launchd",
4536 detail: format!(
4537 "the install record says --start-at {}, but this daemon runs as {}; the plist \
4538 is left as it is",
4539 drift.start_mode,
4540 if root { "root" } else { "the operator" }
4541 ),
4542 });
4543 }
4544 replace_definition(self.identity.name(), drift)?;
4545 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4549 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4550 let _ = record.write(&self.paths);
4551 }
4552 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4553 }
4554
4555 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4565 #[cfg(not(windows))]
4566 {
4567 let _ = (mode, &self.runner_root);
4570 Ok(RootAccessChange::not_applicable())
4571 }
4572 #[cfg(windows)]
4573 {
4574 let wrap = |source| ServiceError::RunnerRoot {
4575 source: Box::new(source),
4576 };
4577 if let Some(root) = self
4595 .runner_root
4596 .as_ref()
4597 .filter(|_| self.identity.is_fixture() || cfg!(test))
4598 {
4599 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4600 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4601 .map_err(wrap);
4602 }
4603
4604 let admission = match ServiceAccount::for_start_mode(mode) {
4605 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4608 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4612 RootAdmission::of_this_account().map_err(wrap)?
4613 }
4614 };
4615 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4616 }
4617 }
4618
4619 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4621 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4622 |source| match source {
4623 held @ LockError::Held { .. } => ServiceError::LockHeld {
4624 source: Box::new(held),
4625 },
4626 other => ServiceError::LockUnreadable {
4627 source: Box::new(other),
4628 },
4629 },
4630 )
4631 }
4632
4633 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4635 for mode in [StartMode::Boot, StartMode::Login] {
4636 let control = self.controls.control(mode)?;
4637 if let Some(registration) = control.query(&self.identity)? {
4638 return Ok(Some((registration.start_mode, registration)));
4639 }
4640 }
4641 Ok(None)
4642 }
4643}
4644
4645#[derive(Debug, Clone, PartialEq, Eq)]
4651pub struct StatusProblem {
4652 pub subject: &'static str,
4654 pub detail: String,
4656}
4657
4658impl fmt::Display for StatusProblem {
4659 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4660 write!(f, "{}: {}", self.subject, self.detail)
4661 }
4662}
4663
4664#[derive(Debug, Clone)]
4673pub struct ServiceStatus {
4674 identity: ServiceIdentity,
4675 record: Option<InstallRecord>,
4676 registration: Option<Registration>,
4677 binary: Option<BinaryPath>,
4678 log_file: PathBuf,
4679 store: Option<crate::secrets::ActiveStore>,
4680 last_github_contact: Option<DateTime<Utc>>,
4681 runner_root: Option<(PathBuf, RootAccessReport)>,
4682 credential_rejected_since: Option<DateTime<Utc>>,
4683 problems: Vec<StatusProblem>,
4684 notes: Vec<String>,
4685}
4686
4687impl ServiceStatus {
4688 fn compose(
4693 identity: ServiceIdentity,
4694 record: Option<InstallRecord>,
4695 record_refused: Option<&ServiceError>,
4696 registration: Option<Registration>,
4697 last_github_contact: Option<DateTime<Utc>>,
4698 paths: &AppPaths,
4699 ) -> Self {
4700 let mut problems = Vec::new();
4701 let mut notes = Vec::new();
4702
4703 if let Some(refusal) = record_refused {
4704 problems.push(StatusProblem {
4709 subject: "install record",
4710 detail: refusal.to_string(),
4711 });
4712 }
4713
4714 let log_file = record.as_ref().map_or_else(
4715 || ServiceDirectories::of(paths).log_file(),
4716 |record| record.log_file.clone(),
4717 );
4718
4719 let binary = record.as_ref().map(|record| {
4720 inspect_binary(
4721 &record.binary,
4722 registration
4723 .as_ref()
4724 .and_then(Registration::binary)
4725 .as_deref(),
4726 )
4727 });
4728 if let Some(state) = &binary
4729 && state.is_error()
4730 {
4731 problems.push(StatusProblem {
4732 subject: "binary",
4733 detail: state.to_string(),
4734 });
4735 }
4736
4737 match (&record, ®istration) {
4738 (Some(_), None) => problems.push(StatusProblem {
4739 subject: "registration",
4740 detail: "this host has a service record but no service manager knows the \
4741 registration. Run `service install` again; it deletes nothing."
4742 .to_string(),
4743 }),
4744 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4749 subject: "record",
4750 detail: format!(
4751 "{} holds a registration for this service but there is no install record, so \
4752 the path it was installed from and the directories it was installed against \
4753 are unknown. Run `service uninstall` and `service install`.",
4754 found.manager
4755 ),
4756 }),
4757 (None, Some(_)) => {}
4758 (Some(record), Some(found)) => {
4759 if record.start_mode != found.start_mode {
4760 problems.push(StatusProblem {
4761 subject: "start mode",
4762 detail: format!(
4763 "the record says {} and {} holds a {} registration. Switch the start \
4764 mode again to make them agree.",
4765 record.start_mode, found.manager, found.start_mode
4766 ),
4767 });
4768 }
4769 if record.start_mode == StartMode::Boot && !found.starts_automatically {
4770 problems.push(StatusProblem {
4771 subject: "start mode",
4772 detail: format!(
4773 "{} holds the registration but will not start it by itself, so this \
4774 host does not resume work after a reboot.",
4775 found.manager
4776 ),
4777 });
4778 }
4779 if let Some(actual) = found.restart_delay {
4780 let expected = record.restart().effective_delay(found.manager);
4781 if actual != expected {
4782 problems.push(StatusProblem {
4783 subject: "restart policy",
4784 detail: format!(
4785 "the record says the service restarts after {}s and {} reports \
4786 {}s. Something has edited the registration since it was \
4787 installed.",
4788 expected.as_secs(),
4789 found.manager,
4790 actual.as_secs()
4791 ),
4792 });
4793 } else if expected != record.restart().delay() {
4794 notes.push(format!(
4799 "{} expresses the restart delay in whole minutes, so the {}s asked \
4800 for is enforced as {}s. The service therefore never restarts faster \
4801 than the configured bound.",
4802 found.manager,
4803 record.restart().delay().as_secs(),
4804 expected.as_secs()
4805 ));
4806 }
4807 }
4808 if found.starts_automatically && !found.running {
4809 problems.push(StatusProblem {
4810 subject: "runtime",
4811 detail: format!(
4812 "{} holds an automatic registration, but the daemon is stopped. Run \
4813 `runner-manager service install --start-at {}`; if the service \
4814 manager denies replacement, retry from an elevated terminal. If it \
4815 stops again, inspect {}.",
4816 found.manager,
4817 found.start_mode,
4818 log_file.display()
4819 ),
4820 });
4821 }
4822 }
4823 (None, None) => {}
4824 }
4825
4826 let store = record.as_ref().and_then(|record| {
4831 let registered_mode = registration
4832 .as_ref()
4833 .map_or(record.start_mode, |found| found.start_mode);
4834 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4835 .ok()
4836 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4837 });
4838 if let Some(store) = &store
4839 && !store.agrees_with_start_mode()
4840 {
4841 problems.push(StatusProblem {
4842 subject: "secret store",
4843 detail: format!(
4844 "{store}. Run `auth login` again so the token is stored where the registered \
4845 start mode can read it."
4846 ),
4847 });
4848 }
4849
4850 if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4851 notes.push(
4852 "This registration starts at login, so the agent does not run until the operator \
4853 signs in; this host does not resume work after an unattended reboot."
4854 .to_string(),
4855 );
4856 }
4857 if last_github_contact.is_none() {
4858 notes.push(
4859 "GitHub has not been reached successfully since this host's state directory was \
4860 created."
4861 .to_string(),
4862 );
4863 }
4864
4865 let runner_root = crate::runner_root::default_runner_root(paths)
4872 .ok()
4873 .map(|root| {
4874 let path = root.as_path().to_path_buf();
4875 let report = crate::runner_root_access::report(&path);
4876 (path, report)
4877 });
4878 if let Some((
4888 path,
4889 RootAccessReport::Present {
4890 broad_write: true, ..
4891 },
4892 )) = &runner_root
4893 {
4894 notes.push(format!(
4895 "the platform default runner root {} can be written by ordinary local users, so \
4896 it is not a safe place to run jobs. `service install` refuses it rather than \
4897 tightening it, because the contents of a directory anybody could write cannot \
4898 be trusted: remove or empty it, or choose another root with `runner-manager \
4899 host set-runtime-root --path <PATH>`.",
4900 path.display()
4901 ));
4902 }
4903
4904 match runner_root_refusals(paths) {
4927 Ok(refusals) => {
4928 for refusal in refusals {
4929 notes.push(format!(
4930 "policy {} started no runner: its runner root {} refused the launch \
4931 ({}), last at {}. {} This clears when that policy next places a \
4932 runner.",
4933 refusal.policy,
4934 refusal.root,
4935 refusal.kind,
4936 refusal.at.to_rfc3339(),
4937 refusal.detail,
4938 ));
4939 }
4940 }
4941 Err(error) => notes.push(format!(
4942 "whether the agent could use its runner roots could not be read: {error}"
4943 )),
4944 }
4945
4946 Self {
4947 identity,
4948 record,
4949 registration,
4950 binary,
4951 log_file,
4952 store,
4953 last_github_contact,
4954 runner_root,
4955 credential_rejected_since: None,
4956 problems,
4957 notes,
4958 }
4959 }
4960
4961 #[must_use]
4963 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
4964 if let Some(drift) = drift {
4965 if drift.manager == DefinitionKind::LaunchdPlist {
4966 let restart = match drift.start_mode {
4970 StartMode::Boot => format!(
4971 "sudo launchctl kickstart -k system/{}",
4972 self.identity.launchd_label()
4973 ),
4974 StartMode::Login => format!(
4975 "launchctl kickstart -k gui/$(id -u)/{}",
4976 self.identity.launchd_label()
4977 ),
4978 };
4979 self.problems.push(StatusProblem {
4980 subject: "definition",
4981 detail: format!(
4982 "{} was written by runner-manager {} with a `ProcessType` other than \
4983 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
4984 daemon starts, so their jobs run below normal priority. The daemon \
4985 rewrites it and reloads itself when it starts with no runner, which it \
4986 does after every upgrade; to do it now, restart the service while no \
4987 job is running: `{restart}`. Configuration and credentials are kept.",
4988 drift.path.display(),
4989 drift.installed_by_version,
4990 ),
4991 });
4992 return self;
4993 }
4994 let repair = match drift.start_mode {
4995 StartMode::Boot => {
4996 "`sudo systemctl stop runner-manager && sudo runner-manager service install \
4997 --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
4998 --distribution <name>` from Windows)"
4999 }
5000 StartMode::Login => {
5001 "`systemctl --user stop runner-manager && runner-manager service install \
5002 --start-at login`"
5003 }
5004 };
5005 self.problems.push(StatusProblem {
5006 subject: "definition",
5007 detail: format!(
5008 "{} was written by runner-manager {} and differs from what {} renders, so \
5009 the daemon runs under outdated settings (for example a credential frozen \
5010 at service start). Re-register it with {repair}; configuration and \
5011 credentials are kept.",
5012 drift.path.display(),
5013 drift.installed_by_version,
5014 env!("CARGO_PKG_VERSION"),
5015 ),
5016 });
5017 }
5018 self
5019 }
5020
5021 #[must_use]
5023 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
5024 if let Some(since) = since {
5025 self.credential_rejected_since = Some(since);
5026 self.problems.push(StatusProblem {
5027 subject: "github credential",
5028 detail: format!(
5029 "GitHub has rejected the service's credential since {}, so the service \
5030 cannot see queued jobs or start runners. Run `runner-manager auth login` \
5031 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
5032 from Windows).",
5033 since.to_rfc3339()
5034 ),
5035 });
5036 }
5037 self
5038 }
5039
5040 #[must_use]
5042 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
5043 self.credential_rejected_since
5044 }
5045
5046 #[must_use]
5049 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
5050 if let Some(since) = since {
5051 let mode = self.start_mode().unwrap_or_default();
5052 self.problems.push(StatusProblem {
5053 subject: CREDENTIAL_UNREADABLE_SUBJECT,
5054 detail: format!(
5055 "the service has been unable to read its stored GitHub credential since {}, \
5056 so it starts no runner; the diagnostic log has the store's own answer.{} \
5057 Sign in again: {}.",
5058 since.to_rfc3339(),
5059 if cfg!(target_os = "macos") {
5060 " That is what a keychain item written by a different build looks like."
5061 } else {
5062 ""
5063 },
5064 sign_in_instruction(mode)
5065 ),
5066 });
5067 }
5068 self
5069 }
5070
5071 #[must_use]
5079 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
5080 self.runner_root
5081 .as_ref()
5082 .map(|(path, report)| (path.as_path(), report))
5083 }
5084
5085 #[must_use]
5087 pub const fn is_installed(&self) -> bool {
5088 self.registration.is_some() || self.record.is_some()
5089 }
5090
5091 #[must_use]
5093 pub fn is_running(&self) -> bool {
5094 self.registration
5095 .as_ref()
5096 .is_some_and(|registration| registration.running)
5097 }
5098
5099 #[must_use]
5101 pub fn is_healthy(&self) -> bool {
5102 self.problems.is_empty()
5103 }
5104
5105 #[must_use]
5107 pub fn problems(&self) -> &[StatusProblem] {
5108 &self.problems
5109 }
5110
5111 #[must_use]
5114 pub fn notes(&self) -> &[String] {
5115 &self.notes
5116 }
5117
5118 #[must_use]
5120 pub fn start_mode(&self) -> Option<StartMode> {
5121 self.record.as_ref().map(|record| record.start_mode)
5122 }
5123
5124 #[must_use]
5127 pub const fn binary(&self) -> Option<&BinaryPath> {
5128 self.binary.as_ref()
5129 }
5130
5131 #[must_use]
5133 pub fn log_file(&self) -> &Path {
5134 &self.log_file
5135 }
5136
5137 #[must_use]
5139 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
5140 self.last_github_contact
5141 }
5142
5143 #[must_use]
5146 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
5147 self.store.as_ref()
5148 }
5149
5150 #[must_use]
5152 pub const fn record(&self) -> Option<&InstallRecord> {
5153 self.record.as_ref()
5154 }
5155
5156 #[must_use]
5158 pub const fn registration(&self) -> Option<&Registration> {
5159 self.registration.as_ref()
5160 }
5161}
5162
5163impl fmt::Display for ServiceStatus {
5164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5165 writeln!(f, "Service: {}", self.identity)?;
5166 match (&self.record, &self.registration) {
5167 (None, None) => {
5168 writeln!(
5169 f,
5170 " installed no. `service install` registers `{} {}`.",
5171 SERVICE_NAME,
5172 DAEMON_ARGUMENTS.join(" ")
5173 )?;
5174 }
5175 _ => {
5176 let manager = self
5177 .registration
5178 .as_ref()
5179 .map(|registration| registration.manager.manager());
5180 writeln!(
5181 f,
5182 " installed {}",
5183 manager.unwrap_or("yes, but no service manager knows it")
5184 )?;
5185 writeln!(
5186 f,
5187 " state {}",
5188 if self.is_running() {
5189 "running"
5190 } else {
5191 "not running"
5192 }
5193 )?;
5194 }
5195 }
5196 if let Some(record) = &self.record {
5197 writeln!(f, " start mode {}", record.start_mode)?;
5198 writeln!(f, " account {}", record.account)?;
5199 writeln!(f, " restart on failure {}", record.restart())?;
5200 writeln!(
5201 f,
5202 " arguments {}",
5203 record.arguments.join(" ")
5204 )?;
5205 }
5206 if let Some(binary) = &self.binary {
5207 writeln!(f, " binary {binary}")?;
5208 }
5209 writeln!(f, " diagnostic log {}", self.log_file.display())?;
5210 if let Some((path, report)) = &self.runner_root {
5211 writeln!(f, " default runner root {}", path.display())?;
5218 if *report != RootAccessReport::NotApplicable {
5219 writeln!(f, " default root access {report}")?;
5220 }
5221 }
5222 if let Some(store) = &self.store {
5223 writeln!(f, " secret store {store}")?;
5224 }
5225 writeln!(
5226 f,
5227 " last GitHub contact {}",
5228 match self.last_github_contact {
5229 Some(at) => at.to_rfc3339(),
5230 None => "never".to_string(),
5231 }
5232 )?;
5233 for note in &self.notes {
5234 writeln!(f, " note {note}")?;
5235 }
5236 for problem in &self.problems {
5237 writeln!(f, " ERROR {problem}")?;
5238 }
5239 write!(
5240 f,
5241 " verdict {}",
5242 if self.is_healthy() {
5243 "healthy"
5244 } else {
5245 "NOT healthy"
5246 }
5247 )
5248 }
5249}
5250
5251#[derive(Debug, Clone, Default)]
5268pub struct RecordingControls {
5269 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5270}
5271
5272#[derive(Debug, Default)]
5273struct RecordingState {
5274 registrations: BTreeMap<(StartMode, String), Registration>,
5275 definitions: BTreeMap<String, ServiceDefinition>,
5276 calls: Vec<String>,
5277 #[cfg(test)]
5278 install_failures: BTreeMap<StartMode, String>,
5279 #[cfg(test)]
5280 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5281}
5282
5283#[cfg(test)]
5284#[derive(Debug, Clone)]
5285enum TestInstallSideEffect {
5286 HideDirectory { directory: PathBuf, hidden: PathBuf },
5287}
5288
5289impl RecordingControls {
5290 #[must_use]
5292 pub fn new() -> Self {
5293 Self::default()
5294 }
5295
5296 #[must_use]
5299 pub fn calls(&self) -> Vec<String> {
5300 self.state.lock().expect("not poisoned").calls.clone()
5301 }
5302
5303 #[must_use]
5305 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5306 self.state
5307 .lock()
5308 .expect("not poisoned")
5309 .registrations
5310 .iter()
5311 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5312 .collect()
5313 }
5314
5315 #[must_use]
5317 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5318 self.state
5319 .lock()
5320 .expect("not poisoned")
5321 .definitions
5322 .get(name)
5323 .cloned()
5324 }
5325
5326 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5336 let mut state = self.state.lock().expect("not poisoned");
5337 if let Some((_, registration)) = state
5338 .registrations
5339 .iter_mut()
5340 .find(|((_, held), _)| held == name)
5341 {
5342 edit(registration);
5343 }
5344 }
5345
5346 #[cfg(test)]
5347 fn fail_next_install(&self, mode: StartMode, detail: &str) {
5348 self.state
5349 .lock()
5350 .expect("not poisoned")
5351 .install_failures
5352 .insert(mode, detail.to_string());
5353 }
5354
5355 #[cfg(test)]
5356 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5357 self.state
5358 .lock()
5359 .expect("not poisoned")
5360 .after_install
5361 .insert(
5362 mode,
5363 TestInstallSideEffect::HideDirectory { directory, hidden },
5364 );
5365 }
5366}
5367
5368impl ControlFactory for RecordingControls {
5369 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5370 Ok(Box::new(RecordingControl {
5371 mode,
5372 state: std::sync::Arc::clone(&self.state),
5373 }))
5374 }
5375}
5376
5377#[derive(Debug)]
5378struct RecordingControl {
5379 mode: StartMode,
5380 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5381}
5382
5383impl RecordingControl {
5384 fn note(&self, operation: &str, name: &str) {
5385 self.state
5386 .lock()
5387 .expect("not poisoned")
5388 .calls
5389 .push(format!("{operation} {name} ({})", self.mode));
5390 }
5391}
5392
5393impl ServiceControl for RecordingControl {
5394 fn manager(&self) -> DefinitionKind {
5395 host_definition_kind(self.mode)
5399 }
5400
5401 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5402 self.note("install", plan.identity().name());
5403 #[cfg(test)]
5404 if let Some(detail) = self
5405 .state
5406 .lock()
5407 .expect("not poisoned")
5408 .install_failures
5409 .remove(&self.mode)
5410 {
5411 return Err(ServiceError::Control {
5412 operation: "install",
5413 name: plan.identity().name().to_string(),
5414 manager: "recording control",
5415 detail,
5416 });
5417 }
5418 let definition = ServiceDefinition::for_host(plan)?;
5423 let mut state = self.state.lock().expect("not poisoned");
5424 state.registrations.insert(
5425 (self.mode, plan.identity().name().to_string()),
5426 Registration {
5427 manager: host_definition_kind(self.mode),
5428 start_mode: self.mode,
5429 command_line: plan.command_line(),
5430 account: Some(plan.account().as_str().to_string()),
5431 running: false,
5432 starts_automatically: true,
5433 restart_delay: Some(plan.restart().delay()),
5434 },
5435 );
5436 state
5437 .definitions
5438 .insert(plan.identity().name().to_string(), definition.clone());
5439 #[cfg(test)]
5440 let side_effect = state.after_install.remove(&self.mode);
5441 drop(state);
5442 #[cfg(test)]
5443 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5444 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5445 std::fs::write(&directory, b"blocks recreation")
5446 .expect("test fault can block record directory recreation");
5447 }
5448 Ok(definition)
5449 }
5450
5451 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5452 self.note("uninstall", identity.name());
5453 let mut state = self.state.lock().expect("not poisoned");
5454 state.definitions.remove(identity.name());
5455 Ok(state
5456 .registrations
5457 .remove(&(self.mode, identity.name().to_string()))
5458 .is_some())
5459 }
5460
5461 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5462 self.note("query", identity.name());
5463 Ok(self
5464 .state
5465 .lock()
5466 .expect("not poisoned")
5467 .registrations
5468 .get(&(self.mode, identity.name().to_string()))
5469 .cloned())
5470 }
5471
5472 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5473 self.note("start", identity.name());
5474 let mut state = self.state.lock().expect("not poisoned");
5475 match state
5476 .registrations
5477 .get_mut(&(self.mode, identity.name().to_string()))
5478 {
5479 Some(registration) => {
5480 registration.running = true;
5481 Ok(())
5482 }
5483 None => Err(ServiceError::NotInstalled {
5484 name: identity.name().to_string(),
5485 operation: "start",
5486 }),
5487 }
5488 }
5489
5490 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5491 self.note("stop", identity.name());
5492 let mut state = self.state.lock().expect("not poisoned");
5493 match state
5494 .registrations
5495 .get_mut(&(self.mode, identity.name().to_string()))
5496 {
5497 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5498 None => Err(ServiceError::NotInstalled {
5499 name: identity.name().to_string(),
5500 operation: "stop",
5501 }),
5502 }
5503 }
5504}
5505
5506impl ControlFactory for HostControls {
5507 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5508 sys::control(mode)
5509 }
5510
5511 fn installed_definition(&self, path: &Path) -> Option<String> {
5512 std::fs::read_to_string(path).ok()
5513 }
5514}
5515
5516fn host_home() -> Option<PathBuf> {
5523 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5524}
5525
5526#[cfg(unix)]
5528fn home_directory() -> Option<PathBuf> {
5529 host_home()
5530}
5531
5532fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5541 let output = std::process::Command::new(program)
5542 .args(arguments)
5543 .output()?;
5544 Ok((
5545 output.status.success(),
5546 String::from_utf8_lossy(&output.stdout).into_owned(),
5547 String::from_utf8_lossy(&output.stderr).into_owned(),
5548 ))
5549}
5550
5551#[must_use]
5554pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5555 if cfg!(windows) {
5556 match mode {
5557 StartMode::Boot => DefinitionKind::WindowsService,
5558 StartMode::Login => DefinitionKind::WindowsScheduledTask,
5559 }
5560 } else if cfg!(target_os = "macos") {
5561 DefinitionKind::LaunchdPlist
5562 } else {
5563 DefinitionKind::SystemdUnit
5564 }
5565}
5566
5567#[cfg(any(target_os = "macos", test))]
5574fn enable_launchd_registration(
5575 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5576 domain: &str,
5577 service_target: &str,
5578 plist: &Path,
5579 name: &str,
5580 elevation_remedy: &'static str,
5581) -> Result<(), ServiceError> {
5582 let (enabled, cause) = launchctl(&[
5583 std::ffi::OsStr::new("enable"),
5584 std::ffi::OsStr::new(service_target),
5585 ]);
5586 if enabled {
5587 return Ok(());
5588 }
5589
5590 let (booted_out, bootout_detail) = launchctl(&[
5591 std::ffi::OsStr::new("bootout"),
5592 std::ffi::OsStr::new(service_target),
5593 ]);
5594 let removed = std::fs::remove_file(plist);
5595 if !booted_out || removed.is_err() {
5596 return Err(ServiceError::Rollback {
5597 operation: "enable launchd registration",
5598 name: name.to_string(),
5599 cause,
5600 rollback: format!(
5601 "launchctl bootout {domain}: {}; remove {}: {}",
5602 if booted_out {
5603 "succeeded".to_string()
5604 } else {
5605 bootout_detail
5606 },
5607 plist.display(),
5608 removed
5609 .err()
5610 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5611 ),
5612 });
5613 }
5614
5615 if cause.to_ascii_lowercase().contains("permission denied") {
5616 Err(ServiceError::NeedsElevation {
5617 operation: "enable",
5618 name: name.to_string(),
5619 detail: cause,
5620 remedy: elevation_remedy,
5621 })
5622 } else {
5623 Err(ServiceError::Control {
5624 operation: "enable",
5625 name: name.to_string(),
5626 manager: "launchd",
5627 detail: cause,
5628 })
5629 }
5630}
5631
5632#[derive(Debug, Clone)]
5641pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5642
5643impl ServiceShutdown {
5644 pub async fn wait(mut self) {
5646 if !*self.0.borrow() {
5647 let _ = self.0.changed().await;
5648 }
5649 }
5650}
5651
5652#[cfg(windows)]
5659pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5660where
5661 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5662{
5663 windows_host::run(Box::new(run))
5664}
5665
5666#[cfg(windows)]
5667mod windows_host {
5668 use std::ffi::OsString;
5669 use std::sync::{Arc, Mutex, OnceLock, mpsc};
5670 use std::time::Duration;
5671
5672 use windows_service::service::{
5673 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5674 ServiceType,
5675 };
5676 use windows_service::service_control_handler::{
5677 self, ServiceControlHandlerResult, ServiceStatusHandle,
5678 };
5679
5680 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5681
5682 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5683
5684 struct Invocation {
5685 run: Runner,
5686 result: mpsc::SyncSender<Result<u8, String>>,
5687 }
5688
5689 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5690
5691 windows_service::define_windows_service!(ffi_service_main, service_main);
5692
5693 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5694 let (result_tx, result_rx) = mpsc::sync_channel(1);
5695 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5696 let mut invocation = slot
5697 .lock()
5698 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5699 if invocation.is_some() {
5700 return Err(host_error(
5701 "prepare",
5702 "the service-host slot was already used",
5703 ));
5704 }
5705 *invocation = Some(Invocation {
5706 run,
5707 result: result_tx,
5708 });
5709 drop(invocation);
5710
5711 windows_service::service_dispatcher::start("", ffi_service_main)
5712 .map_err(|error| host_error("connect", &error.to_string()))?;
5713 result_rx
5714 .recv()
5715 .map_err(|error| host_error("finish", &error.to_string()))?
5716 .map_err(|detail| host_error("run", &detail))
5717 }
5718
5719 fn service_main(_arguments: Vec<OsString>) {
5720 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5721 return;
5722 };
5723 let result = run_service(invocation.run);
5724 let _ = invocation.result.send(result);
5725 }
5726
5727 fn run_service(run: Runner) -> Result<u8, String> {
5728 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5729 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5730 let handler_status = Arc::clone(&status);
5731 let handler = move |control| match control {
5732 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5733 ServiceControl::Stop | ServiceControl::Shutdown => {
5734 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5735 let _ = handle.set_service_status(service_status(
5736 ServiceState::StopPending,
5737 ServiceControlAccept::empty(),
5738 1,
5739 Duration::from_secs(300),
5740 0,
5741 ));
5742 }
5743 let _ = stop_tx.send(true);
5744 ServiceControlHandlerResult::NoError
5745 }
5746 _ => ServiceControlHandlerResult::NotImplemented,
5747 };
5748 let handle = service_control_handler::register("", handler)
5749 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5750 *status
5751 .lock()
5752 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5753 handle
5754 .set_service_status(service_status(
5755 ServiceState::Running,
5756 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5757 0,
5758 Duration::default(),
5759 0,
5760 ))
5761 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5762
5763 let exit = run(ServiceShutdown(stop_rx));
5764 handle
5765 .set_service_status(service_status(
5766 ServiceState::Stopped,
5767 ServiceControlAccept::empty(),
5768 0,
5769 Duration::default(),
5770 u32::from(exit),
5771 ))
5772 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5773 Ok(exit)
5774 }
5775
5776 fn service_status(
5777 state: ServiceState,
5778 accepted: ServiceControlAccept,
5779 checkpoint: u32,
5780 wait_hint: Duration,
5781 exit: u32,
5782 ) -> ServiceStatus {
5783 ServiceStatus {
5784 service_type: ServiceType::OWN_PROCESS,
5785 current_state: state,
5786 controls_accepted: accepted,
5787 exit_code: if exit == 0 {
5788 ServiceExitCode::Win32(0)
5789 } else {
5790 ServiceExitCode::ServiceSpecific(exit)
5791 },
5792 checkpoint,
5793 wait_hint,
5794 process_id: None,
5795 }
5796 }
5797
5798 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5799 ServiceError::Control {
5800 operation,
5801 name: SERVICE_NAME.to_string(),
5802 manager: "the Windows Service Control Manager",
5803 detail: detail.to_string(),
5804 }
5805 }
5806}
5807
5808#[cfg(windows)]
5809mod sys {
5810 use std::ffi::{OsStr, OsString};
5820 use std::time::{Duration, Instant};
5821
5822 use runner_manager_domain::model::StartMode;
5823 use windows_service::service::{
5824 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5825 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5826 ServiceState, ServiceType,
5827 };
5828 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5829
5830 use super::{
5831 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5832 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5833 };
5834
5835 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5838 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5842 const ACCESS_DENIED: i32 = 5;
5844 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5845 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5846
5847 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5848 choose \"Run as administrator\".";
5849
5850 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5851 Ok(match mode {
5852 StartMode::Boot => Box::new(ScmControl),
5853 StartMode::Login => Box::new(TaskControl),
5854 })
5855 }
5856
5857 #[derive(Debug)]
5860 struct ScmControl;
5861
5862 fn scm_error(
5866 operation: &'static str,
5867 name: &str,
5868 error: &windows_service::Error,
5869 ) -> ServiceError {
5870 let raw = match error {
5871 windows_service::Error::Winapi(io) => io.raw_os_error(),
5872 _ => None,
5873 };
5874 let detail = match error {
5875 windows_service::Error::Winapi(io) => io.to_string(),
5876 other => other.to_string(),
5877 };
5878 if raw == Some(ACCESS_DENIED) {
5879 return ServiceError::NeedsElevation {
5880 operation,
5881 name: name.to_string(),
5882 detail,
5883 remedy: ELEVATION_REMEDY,
5884 };
5885 }
5886 ServiceError::Control {
5887 operation,
5888 name: name.to_string(),
5889 manager: "the Windows Service Control Manager",
5890 detail,
5891 }
5892 }
5893
5894 fn open_manager(
5895 access: ServiceManagerAccess,
5896 operation: &'static str,
5897 name: &str,
5898 ) -> Result<ServiceManager, ServiceError> {
5899 ServiceManager::local_computer(None::<&OsStr>, access)
5900 .map_err(|error| scm_error(operation, name, &error))
5901 }
5902
5903 impl ServiceControl for ScmControl {
5904 fn manager(&self) -> DefinitionKind {
5905 DefinitionKind::WindowsService
5906 }
5907
5908 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5909 let spec = windows_service_spec(plan);
5910 let manager = open_manager(
5911 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5912 "install",
5913 &spec.name,
5914 )?;
5915 let info = ServiceInfo {
5916 name: OsString::from(&spec.name),
5917 display_name: OsString::from(&spec.display_name),
5918 service_type: ServiceType::OWN_PROCESS,
5920 start_type: if spec.automatic_start {
5921 ServiceStartType::AutoStart
5922 } else {
5923 ServiceStartType::OnDemand
5924 },
5925 error_control: ServiceErrorControl::Normal,
5926 executable_path: plan.binary().to_path_buf(),
5927 launch_arguments: plan.arguments().to_vec(),
5928 dependencies: Vec::new(),
5929 account_name: spec.account.as_ref().map(OsString::from),
5931 account_password: None,
5934 };
5935 let service = manager
5936 .create_service(
5937 &info,
5938 ServiceAccess::CHANGE_CONFIG
5939 | ServiceAccess::QUERY_CONFIG
5940 | ServiceAccess::QUERY_STATUS
5941 | ServiceAccess::START
5942 | ServiceAccess::STOP
5943 | ServiceAccess::DELETE,
5944 )
5945 .map_err(|error| scm_error("install", &spec.name, &error))?;
5946 service
5947 .set_description(&spec.description)
5948 .map_err(|error| scm_error("describe", &spec.name, &error))?;
5949 service
5950 .update_failure_actions(ServiceFailureActions {
5951 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5952 reboot_msg: None,
5953 command: None,
5954 actions: Some(vec![
5961 ServiceAction {
5962 action_type: ServiceActionType::Restart,
5963 delay: spec.restart.delay(),
5964 };
5965 3
5966 ]),
5967 })
5968 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5969 service
5972 .set_failure_actions_on_non_crash_failures(true)
5973 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5974 Ok(ServiceDefinition::windows_service(plan))
5975 }
5976
5977 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5978 let manager =
5979 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
5980 let service = match manager.open_service(
5981 identity.name(),
5982 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
5983 ) {
5984 Ok(service) => Some(service),
5985 Err(error) if is_missing(&error) => return Ok(false),
5986 Err(error) if is_marked_for_delete(&error) => None,
5991 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
5992 };
5993 if let Some(service) = service {
5994 if let Ok(status) = service.query_status()
5998 && status.current_state != ServiceState::Stopped
5999 {
6000 let _ = service.stop();
6001 }
6002 match service.delete() {
6003 Ok(()) => {}
6004 Err(error) if is_marked_for_delete(&error) => {}
6008 Err(error) => {
6009 return Err(scm_error("uninstall", identity.name(), &error));
6010 }
6011 }
6012 drop(service);
6013 }
6014
6015 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
6022 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
6023 Ok(service) => {
6024 drop(service);
6025 Ok(false)
6026 }
6027 Err(error) if is_missing(&error) => Ok(true),
6028 Err(error) if is_marked_for_delete(&error) => Ok(false),
6029 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
6030 }
6031 })?;
6032 if !absent {
6033 return Err(ServiceError::Control {
6034 operation: "verify uninstall of",
6035 name: identity.name().to_string(),
6036 manager: "the Windows Service Control Manager",
6037 detail: format!(
6038 "the registration was still visible {} seconds after DeleteService; \
6039 retry `service uninstall` from an elevated prompt",
6040 DELETE_TIMEOUT.as_secs()
6041 ),
6042 });
6043 }
6044 Ok(true)
6045 }
6046
6047 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6048 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
6049 let service = match manager.open_service(
6050 identity.name(),
6051 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
6052 ) {
6053 Ok(service) => service,
6054 Err(error) if is_missing(&error) => return Ok(None),
6055 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
6056 };
6057 let config = service
6058 .query_config()
6059 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6060 let status = service
6061 .query_status()
6062 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6063 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
6064 actions
6065 .actions
6066 .and_then(|actions| actions.into_iter().next())
6067 .filter(|action| action.action_type == ServiceActionType::Restart)
6068 .map(|action| action.delay)
6069 });
6070 Ok(Some(Registration {
6071 manager: DefinitionKind::WindowsService,
6072 start_mode: StartMode::Boot,
6073 command_line: config.executable_path.to_string_lossy().into_owned(),
6077 account: config
6078 .account_name
6079 .map(|account| account.to_string_lossy().into_owned()),
6080 running: status.current_state == ServiceState::Running,
6081 starts_automatically: config.start_type == ServiceStartType::AutoStart,
6082 restart_delay,
6083 }))
6084 }
6085
6086 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6087 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
6088 let service = manager
6089 .open_service(identity.name(), ServiceAccess::START)
6090 .map_err(|error| scm_error("start", identity.name(), &error))?;
6091 service
6092 .start::<&OsStr>(&[])
6093 .map_err(|error| scm_error("start", identity.name(), &error))
6094 }
6095
6096 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6097 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
6098 let service = manager
6099 .open_service(
6100 identity.name(),
6101 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
6102 )
6103 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6104 let status = service
6105 .query_status()
6106 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6107 if status.current_state == ServiceState::Stopped {
6108 return Ok(false);
6109 }
6110 service
6111 .stop()
6112 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6113 Ok(true)
6114 }
6115 }
6116
6117 fn is_missing(error: &windows_service::Error) -> bool {
6118 matches!(error, windows_service::Error::Winapi(io)
6119 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
6120 }
6121
6122 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
6123 matches!(error, windows_service::Error::Winapi(io)
6124 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
6125 }
6126
6127 pub(super) fn wait_until_scm_absent(
6128 timeout: Duration,
6129 poll_interval: Duration,
6130 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
6131 ) -> Result<bool, ServiceError> {
6132 let deadline = Instant::now() + timeout;
6133 loop {
6134 if probe_absent()? {
6135 return Ok(true);
6136 }
6137 if Instant::now() >= deadline {
6138 return Ok(false);
6139 }
6140 std::thread::sleep(poll_interval);
6141 }
6142 }
6143
6144 #[derive(Debug)]
6147 struct TaskControl;
6148
6149 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
6150 if detail.to_ascii_lowercase().contains("access is denied") {
6151 return ServiceError::NeedsElevation {
6152 operation,
6153 name: name.to_string(),
6154 detail,
6155 remedy: ELEVATION_REMEDY,
6156 };
6157 }
6158 ServiceError::Control {
6159 operation,
6160 name: name.to_string(),
6161 manager: "Windows Task Scheduler",
6162 detail,
6163 }
6164 }
6165
6166 fn schtasks(
6167 operation: &'static str,
6168 name: &str,
6169 arguments: &[&OsStr],
6170 ) -> Result<(bool, String), ServiceError> {
6171 match run("schtasks.exe", arguments) {
6172 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
6173 Err(error) => Err(task_error(operation, name, error.to_string())),
6174 }
6175 }
6176
6177 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
6180 let mut bytes = vec![0xFF, 0xFE];
6181 for unit in text.encode_utf16() {
6182 bytes.extend_from_slice(&unit.to_le_bytes());
6183 }
6184 std::fs::write(path, bytes)
6185 }
6186
6187 impl ServiceControl for TaskControl {
6188 fn manager(&self) -> DefinitionKind {
6189 DefinitionKind::WindowsScheduledTask
6190 }
6191
6192 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6193 let name = plan.identity().name().to_string();
6194 let principal = TaskPrincipal::current()?;
6195 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
6196 let directory = tempfile::tempdir()
6197 .map_err(|error| task_error("install", &name, error.to_string()))?;
6198 let document = directory.path().join("task.xml");
6199 write_utf16(&document, definition.text())
6200 .map_err(|error| task_error("install", &name, error.to_string()))?;
6201 let (ok, message) = schtasks(
6202 "install",
6203 &name,
6204 &[
6205 OsStr::new("/Create"),
6206 OsStr::new("/TN"),
6207 OsStr::new(&name),
6208 OsStr::new("/XML"),
6209 document.as_os_str(),
6210 ],
6211 )?;
6212 if !ok {
6213 return Err(task_error("install", &name, message));
6214 }
6215 Ok(definition)
6216 }
6217
6218 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6219 if self.query(identity)?.is_none() {
6220 return Ok(false);
6221 }
6222 let name = identity.name().to_string();
6223 let (ok, message) = schtasks(
6224 "uninstall",
6225 &name,
6226 &[
6227 OsStr::new("/Delete"),
6228 OsStr::new("/TN"),
6229 OsStr::new(&name),
6230 OsStr::new("/F"),
6231 ],
6232 )?;
6233 if !ok {
6234 return Err(task_error("uninstall", &name, message));
6235 }
6236 Ok(true)
6237 }
6238
6239 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6240 let name = identity.name().to_string();
6241 let (ok, document) = schtasks(
6242 "inspect",
6243 &name,
6244 &[
6245 OsStr::new("/Query"),
6246 OsStr::new("/TN"),
6247 OsStr::new(&name),
6248 OsStr::new("/XML"),
6249 OsStr::new("ONE"),
6250 ],
6251 )?;
6252 if !ok {
6253 return Ok(None);
6259 }
6260 let command = xml_value(&document, "Command").unwrap_or_default();
6261 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6262 let command_line = if arguments.is_empty() {
6263 super::quote_argument(&command)
6264 } else {
6265 format!("{} {arguments}", super::quote_argument(&command))
6266 };
6267 Ok(Some(Registration {
6268 manager: DefinitionKind::WindowsScheduledTask,
6269 start_mode: StartMode::Login,
6270 command_line,
6271 account: xml_value(&document, "UserId"),
6272 running: task_is_running(&name),
6273 starts_automatically: super::windows_login_task_starts_automatically(&document),
6274 restart_delay: xml_value(&document, "Interval")
6275 .as_deref()
6276 .and_then(parse_iso8601),
6277 }))
6278 }
6279
6280 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6281 let name = identity.name().to_string();
6282 let (ok, message) = schtasks(
6283 "start",
6284 &name,
6285 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6286 )?;
6287 if ok {
6288 Ok(())
6289 } else {
6290 Err(task_error("start", &name, message))
6291 }
6292 }
6293
6294 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6295 let running = self
6296 .query(identity)?
6297 .is_some_and(|registration| registration.running);
6298 if !running {
6299 return Ok(false);
6300 }
6301 let name = identity.name().to_string();
6302 let (ok, message) = schtasks(
6303 "stop",
6304 &name,
6305 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6306 )?;
6307 if ok {
6308 Ok(true)
6309 } else {
6310 Err(task_error("stop", &name, message))
6311 }
6312 }
6313 }
6314
6315 fn task_is_running(name: &str) -> bool {
6329 let Ok((true, stdout, _)) = run(
6330 "schtasks.exe",
6331 &[
6332 OsStr::new("/Query"),
6333 OsStr::new("/TN"),
6334 OsStr::new(name),
6335 OsStr::new("/FO"),
6336 OsStr::new("CSV"),
6337 OsStr::new("/NH"),
6338 ],
6339 ) else {
6340 return false;
6341 };
6342 stdout
6343 .lines()
6344 .filter_map(|line| line.rsplit(',').next())
6345 .any(|status| {
6346 status
6347 .trim()
6348 .trim_matches('"')
6349 .eq_ignore_ascii_case("running")
6350 })
6351 }
6352
6353 fn parse_iso8601(value: &str) -> Option<Duration> {
6357 let rest = value.strip_prefix("PT")?;
6358 if let Some(minutes) = rest.strip_suffix('M') {
6359 return minutes
6360 .parse::<u64>()
6361 .ok()
6362 .map(|minutes| Duration::from_secs(minutes * 60));
6363 }
6364 rest.strip_suffix('S')?
6365 .parse::<u64>()
6366 .ok()
6367 .map(Duration::from_secs)
6368 }
6369}
6370
6371#[cfg(unix)]
6383fn write_definition(
6384 operation: &'static str,
6385 name: &str,
6386 path: &Path,
6387 text: &str,
6388 remedy: &'static str,
6389) -> Result<(), ServiceError> {
6390 if let Some(parent) = path.parent()
6391 && let Err(error) = std::fs::create_dir_all(parent)
6392 && error.kind() != std::io::ErrorKind::AlreadyExists
6393 {
6394 return Err(definition_error(operation, name, error, remedy, parent));
6395 }
6396 std::fs::write(path, text)
6397 .map_err(|error| definition_error(operation, name, error, remedy, path))
6398}
6399
6400#[cfg(all(unix, any(target_os = "macos", test)))]
6407fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
6408 use std::io::Write as _;
6409
6410 let path = drift.path.as_path();
6411 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
6412 let failed = |error: std::io::Error, at: &Path| {
6413 definition_error("rewrite", name, error, SUDO_REMEDY, at)
6414 };
6415 let mut staged =
6416 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
6417 staged
6418 .write_all(drift.rendered.as_bytes())
6419 .and_then(|()| staged.as_file().sync_all())
6420 .map_err(|error| failed(error, staged.path()))?;
6421 std::fs::set_permissions(
6422 staged.path(),
6423 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
6424 )
6425 .map_err(|error| failed(error, path))?;
6426 staged
6427 .persist(path)
6428 .map(drop)
6429 .map_err(|error| failed(error.error, path))
6430}
6431
6432#[cfg(unix)]
6433fn definition_error(
6434 operation: &'static str,
6435 name: &str,
6436 error: std::io::Error,
6437 remedy: &'static str,
6438 path: &Path,
6439) -> ServiceError {
6440 let detail = format!("{}: {error}", path.display());
6441 if error.kind() == std::io::ErrorKind::PermissionDenied {
6442 ServiceError::NeedsElevation {
6443 operation,
6444 name: name.to_string(),
6445 detail,
6446 remedy,
6447 }
6448 } else {
6449 ServiceError::Control {
6450 operation,
6451 name: name.to_string(),
6452 manager: "the local service manager",
6453 detail,
6454 }
6455 }
6456}
6457
6458#[cfg(unix)]
6460const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6461 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6462 agent not running until you sign in.";
6463
6464#[cfg(target_os = "macos")]
6469mod sys {
6470 use std::ffi::OsStr;
6483 use std::path::PathBuf;
6484 use std::time::Duration;
6485
6486 use runner_manager_domain::model::StartMode;
6487
6488 use super::{
6489 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6490 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6491 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6492 write_definition, xml_value,
6493 };
6494
6495 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6496 Ok(Box::new(LaunchdControl { mode }))
6497 }
6498
6499 #[derive(Debug)]
6500 struct LaunchdControl {
6501 mode: StartMode,
6502 }
6503
6504 impl LaunchdControl {
6505 fn domain(&self) -> String {
6507 match self.mode {
6508 StartMode::Boot => "system".to_string(),
6509 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6512 }
6513 }
6514
6515 fn service_target(&self, identity: &ServiceIdentity) -> String {
6516 format!("{}/{}", self.domain(), identity.launchd_label())
6517 }
6518
6519 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6522 let file = format!("{}.plist", identity.launchd_label());
6523 match self.mode {
6524 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6525 StartMode::Login => {
6526 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6527 }
6528 }
6529 }
6530
6531 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6532 ServiceError::Control {
6533 operation,
6534 name: name.to_string(),
6535 manager: "launchd",
6536 detail,
6537 }
6538 }
6539
6540 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6541 match run("launchctl", arguments) {
6542 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6543 Err(error) => (false, error.to_string()),
6544 }
6545 }
6546 }
6547
6548 impl ServiceControl for LaunchdControl {
6549 fn manager(&self) -> DefinitionKind {
6550 DefinitionKind::LaunchdPlist
6551 }
6552
6553 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6554 let name = plan.identity().name().to_string();
6555 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6556 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6557 return Err(self.failed(
6558 "install",
6559 &name,
6560 "this account has no home directory, so there is nowhere to put a \
6561 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6562 /Library/LaunchDaemons."
6563 .to_string(),
6564 ));
6565 };
6566 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6567 let target = self.domain();
6568 let (ok, message) = self.launchctl(&[
6569 OsStr::new("bootstrap"),
6570 OsStr::new(&target),
6571 path.as_os_str(),
6572 ]);
6573 if !ok {
6574 let _ = std::fs::remove_file(&path);
6578 if message.to_ascii_lowercase().contains("permission denied") {
6579 return Err(ServiceError::NeedsElevation {
6580 operation: "install",
6581 name,
6582 detail: message,
6583 remedy: SUDO_REMEDY,
6584 });
6585 }
6586 return Err(self.failed("install", &name, message));
6587 }
6588 let service_target = self.service_target(plan.identity());
6591 enable_launchd_registration(
6592 |arguments| self.launchctl(arguments),
6593 &target,
6594 &service_target,
6595 &path,
6596 &name,
6597 SUDO_REMEDY,
6598 )?;
6599 Ok(definition)
6600 }
6601
6602 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6603 let Some(path) = self.plist_path(identity) else {
6604 return Ok(false);
6605 };
6606 if !path.exists() {
6607 return Ok(false);
6608 }
6609 let target = self.service_target(identity);
6610 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6611 if !ok
6612 && !message.to_ascii_lowercase().contains("no such process")
6613 && !message.contains("113")
6614 {
6615 if message.to_ascii_lowercase().contains("permission denied") {
6616 return Err(ServiceError::NeedsElevation {
6617 operation: "uninstall",
6618 name: identity.name().to_string(),
6619 detail: message,
6620 remedy: SUDO_REMEDY,
6621 });
6622 }
6623 return Err(self.failed("uninstall", identity.name(), message));
6624 }
6625 std::fs::remove_file(&path).map_err(|error| {
6628 super::definition_error(
6629 "uninstall",
6630 identity.name(),
6631 error,
6632 SUDO_REMEDY,
6633 path.as_path(),
6634 )
6635 })?;
6636 Ok(true)
6637 }
6638
6639 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6640 let Some(path) = self.plist_path(identity) else {
6641 return Ok(None);
6642 };
6643 let Ok(document) = std::fs::read_to_string(&path) else {
6644 return Ok(None);
6645 };
6646 let target = self.service_target(identity);
6647 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6648 Ok(Some(Registration {
6649 manager: DefinitionKind::LaunchdPlist,
6650 start_mode: self.mode,
6651 command_line: program_arguments(&document),
6652 account: plist_string_value(&document, "UserName")
6653 .or_else(|| Some("the invoking user".to_string())),
6654 running: loaded && printed.contains("state = running"),
6655 starts_automatically: document.contains("<key>RunAtLoad</key>")
6656 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6657 restart_delay: xml_value(
6658 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6659 "integer",
6660 )
6661 .and_then(|value| value.parse::<u64>().ok())
6662 .map(Duration::from_secs),
6663 }))
6664 }
6665
6666 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6667 let target = self.service_target(identity);
6668 let (ok, message) = self.launchctl(&[
6669 OsStr::new("kickstart"),
6670 OsStr::new("-k"),
6671 OsStr::new(&target),
6672 ]);
6673 if ok {
6674 Ok(())
6675 } else {
6676 Err(self.failed("start", identity.name(), message))
6677 }
6678 }
6679
6680 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6681 let running = self
6682 .query(identity)?
6683 .is_some_and(|registration| registration.running);
6684 if !running {
6685 return Ok(false);
6686 }
6687 let target = self.service_target(identity);
6688 let (ok, message) = self.launchctl(&[
6689 OsStr::new("kill"),
6690 OsStr::new("SIGTERM"),
6691 OsStr::new(&target),
6692 ]);
6693 if ok {
6694 Ok(true)
6695 } else {
6696 Err(self.failed("stop", identity.name(), message))
6697 }
6698 }
6699 }
6700
6701 pub(super) fn reload_detached(
6716 identity: &ServiceIdentity,
6717 mode: StartMode,
6718 plist: &std::path::Path,
6719 ) -> Result<(), ServiceError> {
6720 use std::os::unix::process::CommandExt;
6721
6722 const SCRIPT: &str = r#"(
6723 sleep 1
6724 /bin/launchctl bootout "$1"
6725 tries=0
6726 until /bin/launchctl bootstrap "$2" "$3"; do
6727 tries=$((tries + 1))
6728 if [ "$tries" -ge 60 ]; then
6729 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
6730 exit 1
6731 fi
6732 sleep 1
6733 done
6734) </dev/null &"#;
6735
6736 let control = LaunchdControl { mode };
6737 let reinstall = match mode {
6740 StartMode::Boot => "sudo runner-manager service install --start-at boot",
6741 StartMode::Login => "runner-manager service install --start-at login",
6742 };
6743 let mut command = std::process::Command::new("/bin/sh");
6744 command
6745 .args(["-c", SCRIPT, "runner-manager-reload"])
6746 .arg(control.service_target(identity))
6747 .arg(control.domain())
6748 .arg(plist)
6749 .arg(reinstall)
6750 .stdin(std::process::Stdio::null());
6751 unsafe {
6754 command.pre_exec(|| {
6755 if libc::setsid() == -1 {
6756 return Err(std::io::Error::last_os_error());
6757 }
6758 Ok(())
6759 });
6760 }
6761 let status = command.status().map_err(|error| {
6762 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
6763 })?;
6764 if status.success() {
6765 Ok(())
6766 } else {
6767 Err(control.failed(
6768 "reload",
6769 identity.name(),
6770 format!("the reload helper exited with {status}"),
6771 ))
6772 }
6773 }
6774
6775 fn program_arguments(document: &str) -> String {
6777 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6778 return String::new();
6779 };
6780 let Some(end) = rest.find("</array>") else {
6781 return String::new();
6782 };
6783 let mut out = Vec::new();
6784 let mut cursor = &rest[..end];
6785 while let Some(open) = cursor.find("<string>") {
6786 let after = &cursor[open + "<string>".len()..];
6787 let Some(close) = after.find("</string>") else {
6788 break;
6789 };
6790 out.push(quote_argument(&super::xml_unescape(&after[..close])));
6791 cursor = &after[close..];
6792 }
6793 out.join(" ")
6794 }
6795}
6796
6797#[cfg(all(unix, not(target_os = "macos")))]
6802mod sys {
6803 use std::ffi::OsStr;
6813 use std::path::PathBuf;
6814 use std::time::Duration;
6815
6816 use runner_manager_domain::model::StartMode;
6817
6818 use super::{
6819 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6820 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6821 home_directory, ini_directives, run, write_definition,
6822 };
6823
6824 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6825 Ok(Box::new(SystemdControl { mode }))
6826 }
6827
6828 #[derive(Debug)]
6829 struct SystemdControl {
6830 mode: StartMode,
6831 }
6832
6833 impl SystemdControl {
6834 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6835 let file = identity.systemd_unit();
6836 match self.mode {
6837 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6838 StartMode::Login => {
6839 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6840 }
6841 }
6842 }
6843
6844 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6846 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6847 if self.mode == StartMode::Login {
6848 all.push(OsStr::new("--user"));
6849 }
6850 all.extend(arguments.iter().map(OsStr::new));
6851 match run("systemctl", &all) {
6852 Ok((ok, stdout, stderr)) => (
6853 ok,
6854 if stdout.trim().is_empty() {
6855 stderr
6856 } else {
6857 stdout
6858 },
6859 ),
6860 Err(error) => (false, error.to_string()),
6861 }
6862 }
6863
6864 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6865 ServiceError::Control {
6866 operation,
6867 name: name.to_string(),
6868 manager: "systemd",
6869 detail,
6870 }
6871 }
6872 }
6873
6874 impl ServiceControl for SystemdControl {
6875 fn manager(&self) -> DefinitionKind {
6876 DefinitionKind::SystemdUnit
6877 }
6878
6879 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6880 let name = plan.identity().name().to_string();
6881 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6882 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6883 return Err(self.failed(
6884 "install",
6885 &name,
6886 "this account has no home directory, so there is nowhere to put a systemd \
6887 user unit. Use --start-at boot, which installs a system unit under \
6888 /etc/systemd/system."
6889 .to_string(),
6890 ));
6891 };
6892 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6893 let unit = plan.identity().systemd_unit();
6894 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6895 if !reloaded {
6896 let _ = std::fs::remove_file(&path);
6897 return Err(self.failed("install", &name, message));
6898 }
6899 let (enabled, message) = self.systemctl(&["enable", &unit]);
6900 if !enabled {
6901 let _ = std::fs::remove_file(&path);
6902 let _ = self.systemctl(&["daemon-reload"]);
6903 return Err(self.failed("install", &name, message));
6904 }
6905 Ok(definition)
6906 }
6907
6908 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6909 let Some(path) = self.unit_path(identity) else {
6910 return Ok(false);
6911 };
6912 if !path.exists() {
6913 return Ok(false);
6914 }
6915 let unit = identity.systemd_unit();
6916 let _ = self.systemctl(&["disable", "--now", &unit]);
6921 std::fs::remove_file(&path).map_err(|error| {
6922 super::definition_error(
6923 "uninstall",
6924 identity.name(),
6925 error,
6926 SUDO_REMEDY,
6927 path.as_path(),
6928 )
6929 })?;
6930 let _ = self.systemctl(&["daemon-reload"]);
6931 Ok(true)
6932 }
6933
6934 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6935 let Some(path) = self.unit_path(identity) else {
6936 return Ok(None);
6937 };
6938 let Ok(document) = std::fs::read_to_string(&path) else {
6939 return Ok(None);
6940 };
6941 let unit = identity.systemd_unit();
6942 let directives = ini_directives(&document, "Service");
6943 let (_, active) = self.systemctl(&["is-active", &unit]);
6944 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6945 Ok(Some(Registration {
6946 manager: DefinitionKind::SystemdUnit,
6947 start_mode: self.mode,
6948 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6949 account: directives.get("User").cloned().or_else(|| {
6950 Some(match self.mode {
6951 StartMode::Boot => "root".to_string(),
6952 StartMode::Login => "the invoking user".to_string(),
6953 })
6954 }),
6955 running: active.trim() == "active",
6956 starts_automatically: enabled.trim() == "enabled",
6957 restart_delay: directives
6958 .get("RestartSec")
6959 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6960 .map(Duration::from_secs),
6961 }))
6962 }
6963
6964 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6965 let unit = identity.systemd_unit();
6966 let (ok, message) = self.systemctl(&["start", &unit]);
6967 if ok {
6968 Ok(())
6969 } else {
6970 Err(self.failed("start", identity.name(), message))
6971 }
6972 }
6973
6974 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6975 let running = self
6976 .query(identity)?
6977 .is_some_and(|registration| registration.running);
6978 if !running {
6979 return Ok(false);
6980 }
6981 let unit = identity.systemd_unit();
6982 let (ok, message) = self.systemctl(&["stop", &unit]);
6983 if ok {
6984 Ok(true)
6985 } else {
6986 Err(self.failed("stop", identity.name(), message))
6987 }
6988 }
6989 }
6990}
6991
6992#[cfg(all(test, target_os = "macos"))]
6994pub(crate) mod launchd_fixture {
6995 use std::path::{Path, PathBuf};
6996
6997 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
7001
7002 pub(crate) fn enabled() -> bool {
7005 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
7006 if !enabled {
7007 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
7008 }
7009 enabled
7010 }
7011
7012 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
7020 let binary = directory.join("launch-agent-job");
7021 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
7022 .expect("test binary copied");
7023 binary
7024 }
7025
7026 pub(crate) struct LoadedAgent(String);
7028
7029 impl LoadedAgent {
7030 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
7033 let domain = format!("gui/{}", unsafe { libc::getuid() });
7036 let loaded = Self(format!("{domain}/{label}"));
7038 let bootstrap = std::process::Command::new("launchctl")
7039 .arg("bootstrap")
7040 .arg(&domain)
7041 .arg(plist)
7042 .output()
7043 .expect("launchctl runs");
7044 assert!(
7045 bootstrap.status.success(),
7046 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
7047 String::from_utf8_lossy(&bootstrap.stderr)
7048 );
7049 loaded
7050 }
7051
7052 pub(crate) fn target(&self) -> &str {
7054 &self.0
7055 }
7056 }
7057
7058 impl Drop for LoadedAgent {
7059 fn drop(&mut self) {
7060 let _ = std::process::Command::new("launchctl")
7061 .args(["bootout", &self.0])
7062 .output();
7063 }
7064 }
7065}
7066
7067#[cfg(test)]
7068mod tests {
7069 use super::*;
7070
7071 use std::collections::BTreeMap;
7072
7073 #[test]
7074 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
7075 assert!(windows_login_task_starts_automatically(
7076 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
7077 ));
7078 assert!(windows_login_task_starts_automatically(
7079 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
7080 ));
7081 assert!(!windows_login_task_starts_automatically(
7082 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
7083 ));
7084 assert!(!windows_login_task_starts_automatically(
7085 "<Task><BootTrigger></BootTrigger></Task>"
7086 ));
7087 }
7088
7089 fn linux_plan(mode: StartMode) -> InstallPlan {
7096 InstallPlan::unchecked(
7097 ServiceIdentity::product(),
7098 mode,
7099 "/opt/runner-manager/bin/runner-manager",
7100 ServiceDirectories {
7101 config: PathBuf::from("/var/lib/runner-manager/config"),
7102 state: PathBuf::from("/var/lib/runner-manager/state"),
7103 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
7104 logs: PathBuf::from("/var/log/runner-manager"),
7105 },
7106 )
7107 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
7108 }
7109
7110 fn windows_plan(mode: StartMode) -> InstallPlan {
7111 InstallPlan::unchecked(
7112 ServiceIdentity::product(),
7113 mode,
7114 "C:\\Program Files\\runner-manager\\runner-manager.exe",
7115 ServiceDirectories {
7116 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
7117 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
7118 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
7119 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
7120 },
7121 )
7122 }
7123
7124 fn edited(text: &str, from: &str, to: &str) -> String {
7132 assert!(
7133 text.contains(from),
7134 "the rendered definition does not contain `{from}`, so this test would assert \
7135 nothing about a widened one"
7136 );
7137 text.replace(from, to)
7138 }
7139
7140 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
7142 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
7143 let Ok(entries) = std::fs::read_dir(directory) else {
7144 return;
7145 };
7146 for entry in entries.flatten() {
7147 let path = entry.path();
7148 if path.is_dir() {
7149 walk(&path, out);
7150 } else if let Ok(bytes) = std::fs::read(&path) {
7151 out.insert(path, bytes);
7152 }
7153 }
7154 }
7155 let mut out = BTreeMap::new();
7156 for root in roots {
7157 walk(root, &mut out);
7158 }
7159 out
7160 }
7161
7162 struct Host {
7163 _root: tempfile::TempDir,
7164 paths: AppPaths,
7165 binary: PathBuf,
7166 runner_root: LocalAbsolutePath,
7175 controls: RecordingControls,
7176 }
7177
7178 impl Host {
7179 fn new() -> Self {
7180 let root = tempfile::tempdir().expect("a temporary directory");
7181 let paths = AppPaths::rooted_at(root.path());
7182 paths.create_all().expect("the four directories");
7183 let binary = root.path().join(if cfg!(windows) {
7184 "runner-manager.exe"
7185 } else {
7186 "runner-manager"
7187 });
7188 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
7189 let runner_root = LocalAbsolutePath::new(
7190 root.path()
7191 .join("runner-root")
7192 .to_str()
7193 .expect("a unicode temporary path"),
7194 )
7195 .expect("a local absolute path");
7196 Self {
7197 _root: root,
7198 paths,
7199 binary,
7200 runner_root,
7201 controls: RecordingControls::new(),
7202 }
7203 }
7204
7205 fn operations(&self) -> ServiceOperations {
7206 ServiceOperations::with_controls(
7207 self.paths.clone(),
7208 ServiceIdentity::product(),
7209 std::sync::Arc::new(self.controls.clone()),
7210 )
7211 .with_runner_root(self.runner_root.clone())
7212 }
7213
7214 fn request(&self, mode: StartMode) -> InstallRequest {
7215 InstallRequest::new(mode).for_binary(&self.binary)
7216 }
7217 }
7218
7219 #[cfg(windows)]
7220 #[test]
7221 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
7222 let probes = std::cell::Cell::new(0);
7223 let absent =
7224 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
7225 let next = probes.get() + 1;
7226 probes.set(next);
7227 Ok(next == 3)
7228 })
7229 .expect("the simulated SCM probe succeeds");
7230
7231 assert!(absent);
7232 assert_eq!(
7233 probes.get(),
7234 3,
7235 "uninstall must recheck after transient presence instead of treating it as a leak"
7236 );
7237 }
7238
7239 #[cfg(windows)]
7240 #[test]
7241 fn windows_recognises_an_already_pending_service_deletion() {
7242 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
7243
7244 assert!(super::sys::is_marked_for_delete(&error));
7245 }
7246
7247 #[test]
7248 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
7249 let root = tempfile::tempdir().expect("a temporary directory");
7250 let plist = root.path().join("fixture.plist");
7251 std::fs::write(&plist, b"fixture").expect("a plist fixture");
7252 let calls = std::cell::RefCell::new(Vec::new());
7253
7254 let error = enable_launchd_registration(
7255 |arguments| {
7256 let call = arguments
7257 .iter()
7258 .map(|argument| argument.to_string_lossy().into_owned())
7259 .collect::<Vec<_>>();
7260 let operation = call[0].clone();
7261 calls.borrow_mut().push(call);
7262 if operation == "enable" {
7263 (false, "label remains disabled".to_string())
7264 } else {
7265 (true, String::new())
7266 }
7267 },
7268 "system",
7269 "system/com.openai.runner-manager-selftest",
7270 &plist,
7271 "runner-manager-selftest",
7272 "rerun with administrative rights",
7273 )
7274 .expect_err("enable failure must fail the install");
7275
7276 assert!(
7277 matches!(
7278 error,
7279 ServiceError::Control {
7280 operation: "enable",
7281 ..
7282 }
7283 ),
7284 "{error}"
7285 );
7286 assert_eq!(calls.borrow().len(), 2);
7287 assert_eq!(calls.borrow()[0][0], "enable");
7288 assert_eq!(calls.borrow()[1][0], "bootout");
7289 assert!(!plist.exists(), "rollback must remove the plist");
7290 }
7291
7292 #[test]
7297 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
7298 let plan = windows_plan(StartMode::Boot);
7299 let command_line = plan.command_line();
7300 assert!(
7301 command_line.starts_with('"'),
7302 "a path with a space must be quoted, got {command_line}"
7303 );
7304 assert_eq!(
7305 executable_from_command_line(&command_line).as_deref(),
7306 Some(plan.binary())
7307 );
7308 }
7309
7310 #[test]
7311 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
7312 let plan = linux_plan(StartMode::Boot);
7313 let command_line = plan.command_line();
7314 assert!(!command_line.starts_with('"'), "got {command_line}");
7315 assert_eq!(
7316 executable_from_command_line(&command_line).as_deref(),
7317 Some(plan.binary())
7318 );
7319 }
7320
7321 #[test]
7322 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
7323 let awkward = r#"C:\odd "name"\rm.exe"#;
7326 let quoted = quote_argument(awkward);
7327 assert_eq!(
7328 executable_from_command_line(&format!("{quoted} daemon run"))
7329 .as_deref()
7330 .map(Path::to_string_lossy)
7331 .as_deref(),
7332 Some(awkward)
7333 );
7334 }
7335
7336 #[test]
7337 fn an_empty_command_line_has_no_executable() {
7338 assert_eq!(executable_from_command_line(" "), None);
7339 assert_eq!(executable_from_command_line(""), None);
7340 }
7341
7342 #[test]
7343 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
7344 let awkward = r#"DOMAIN\R&D <team> "ops""#;
7345 assert_eq!(
7346 xml_escape(awkward),
7347 "DOMAIN\\R&D <team> "ops""
7348 );
7349 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
7350 }
7351
7352 #[test]
7357 fn a_restart_delay_under_the_floor_is_refused() {
7358 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
7359 .expect_err("half a second is under the one-second floor");
7360 assert!(
7361 matches!(error, ServiceError::RestartDelay { .. }),
7362 "{error}"
7363 );
7364 }
7365
7366 #[test]
7367 fn a_restart_delay_over_the_ceiling_is_refused() {
7368 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
7369 .expect_err("an hour is over the five-minute ceiling");
7370 assert!(
7371 matches!(error, ServiceError::RestartDelay { .. }),
7372 "{error}"
7373 );
7374 }
7375
7376 #[test]
7377 fn a_reset_window_no_longer_than_the_delay_is_refused() {
7378 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
7379 .expect_err("a window equal to the delay can never elapse between restarts");
7380 assert!(
7381 matches!(error, ServiceError::RestartResetWindow { .. }),
7382 "{error}"
7383 );
7384 }
7385
7386 #[test]
7387 fn a_delay_inside_the_bound_is_accepted() {
7388 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
7389 .expect("twenty seconds is inside the bound");
7390 assert_eq!(policy.delay(), Duration::from_secs(20));
7391 assert_eq!(policy.reset_after(), Duration::from_secs(300));
7392 }
7393
7394 #[test]
7399 fn a_fixture_identity_can_never_be_the_product_identity() {
7400 let fixture = ServiceIdentity::fixture("abc123");
7401 assert!(fixture.is_fixture());
7402 assert!(!ServiceIdentity::product().is_fixture());
7403 assert_ne!(fixture.name(), ServiceIdentity::product().name());
7404 assert_ne!(
7405 fixture.launchd_label(),
7406 ServiceIdentity::product().launchd_label()
7407 );
7408 assert_ne!(
7409 fixture.systemd_unit(),
7410 ServiceIdentity::product().systemd_unit()
7411 );
7412 }
7413
7414 #[test]
7415 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
7416 let fixture = ServiceIdentity::fixture("A b/c:\\d");
7417 assert!(
7418 fixture
7419 .name()
7420 .chars()
7421 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7422 "got {}",
7423 fixture.name()
7424 );
7425 }
7426
7427 #[test]
7432 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7433 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7434 assert!(unit.contains("KillMode=process\n"), "{unit}");
7435 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7436 assert!(unit.contains("RestartSec=15\n"), "{unit}");
7437 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7438 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7439 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7440 }
7441
7442 #[test]
7443 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7444 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7445 assert!(
7446 !unit.contains("LoadCredential="),
7447 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7448 );
7449 }
7450
7451 #[test]
7452 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7453 let unit = systemd_unit(&linux_plan(StartMode::Login));
7454 assert!(
7455 !unit.contains("LoadCredential="),
7456 "a user unit must not name a root-owned credential file, got:\n{unit}"
7457 );
7458 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7459 }
7460
7461 #[test]
7462 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7463 let plan = linux_plan(StartMode::Boot);
7464 let unit = systemd_unit(&plan);
7465 let directives = ini_directives(&unit, "Service");
7466 let listed = split_quoted(
7467 directives
7468 .get("ReadWritePaths")
7469 .expect("the unit names its writable paths"),
7470 );
7471 assert_eq!(listed.len(), 5, "{listed:?}");
7472 for path in plan.directories().all() {
7473 assert!(
7474 listed.iter().any(|entry| entry == &path.to_string_lossy()),
7475 "{} is missing from {listed:?}",
7476 path.display()
7477 );
7478 }
7479 assert!(
7480 listed
7481 .iter()
7482 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7483 "atomic credential replacement needs its parent directory in {listed:?}"
7484 );
7485 }
7486
7487 #[test]
7488 fn the_unit_records_the_absolute_binary_path() {
7489 let plan = linux_plan(StartMode::Boot);
7490 let unit = systemd_unit(&plan);
7491 assert!(
7492 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7493 "{unit}"
7494 );
7495 }
7496
7497 #[test]
7502 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7503 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7504 assert!(
7507 plist.contains(
7508 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
7509 ),
7510 "{plist}"
7511 );
7512 assert!(
7513 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
7514 "{plist}"
7515 );
7516 }
7517
7518 #[test]
7519 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7520 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7521 assert_eq!(
7522 plist_string_value(&daemon, "UserName").as_deref(),
7523 Some("root")
7524 );
7525 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7526
7527 let agent = launchd_plist(&linux_plan(StartMode::Login));
7528 assert_eq!(
7529 plist_string_value(&agent, "UserName"),
7530 None,
7531 "a LaunchAgent already runs as the operator:\n{agent}"
7532 );
7533 }
7534
7535 #[test]
7536 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
7537 for mode in [StartMode::Boot, StartMode::Login] {
7538 let plan = linux_plan(mode);
7539 let plist = launchd_plist(&plan);
7540 assert_eq!(
7541 plist_string_value(&plist, "ProcessType").as_deref(),
7542 Some("Interactive"),
7543 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
7544 );
7545 let review = review_least_privilege(
7546 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
7547 &plan,
7548 );
7549 assert!(
7550 review
7551 .controls()
7552 .iter()
7553 .any(|control| control == "ProcessType=Interactive"),
7554 "{review}"
7555 );
7556 assert!(
7557 !review
7558 .findings()
7559 .iter()
7560 .any(|finding| finding.subject == "ProcessType"),
7561 "{review}"
7562 );
7563 }
7564 }
7565
7566 #[test]
7570 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
7571 let plan = linux_plan(StartMode::Login);
7572 let rendered = launchd_plist(&plan);
7573 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
7574 for (label, throttled) in [
7575 (
7576 "Background",
7577 edited(
7578 &rendered,
7579 interactive,
7580 "<key>ProcessType</key>\n <string>Background</string>\n",
7581 ),
7582 ),
7583 ("absent", edited(&rendered, interactive, "")),
7584 ] {
7585 let review = review_least_privilege(
7586 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
7587 &plan,
7588 );
7589 assert!(review.is_least_privilege(), "{label}: {review}");
7590 assert!(
7591 review.findings().iter().any(|finding| {
7592 finding.kind == FindingKind::Shortfall
7593 && finding.subject == "ProcessType"
7594 && finding.detail.contains(label)
7595 }),
7596 "{label}: {review}"
7597 );
7598 }
7599 }
7600
7601 #[test]
7602 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
7603 let host = Host::new();
7604 let identity = ServiceIdentity::product();
7605 let plan = InstallPlan::resolve(
7606 identity.clone(),
7607 &host.request(StartMode::Login),
7608 ServiceDirectories::of(&host.paths),
7609 )
7610 .expect("a resolvable plan");
7611 let installed = launchd_plist(&plan);
7612 let definition =
7613 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
7614 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7615 record.installed_by_version = "0.4.31".into();
7616 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
7617
7618 assert_eq!(
7619 launchd_definition_drift(&identity, &record, path.clone(), &installed),
7620 None
7621 );
7622 let edited_by_operator = edited(
7623 &installed,
7624 "</dict>\n</plist>\n",
7625 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
7626 );
7627 assert_eq!(
7628 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
7629 None,
7630 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
7631 );
7632
7633 let written_by_0_4_31 = edited(
7634 &installed,
7635 "<string>Interactive</string>",
7636 "<string>Background</string>",
7637 );
7638 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
7639 .expect("a Background plist is drift");
7640 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
7641 assert_eq!(drift.start_mode, StartMode::Login);
7642 assert_eq!(
7643 drift.rendered, installed,
7644 "the repair must write exactly what `service install` writes"
7645 );
7646
7647 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7648 .with_definition_drift(Some(drift));
7649 let problem = status
7650 .problems()
7651 .iter()
7652 .find(|problem| problem.subject == "definition")
7653 .expect("the drift is reported");
7654 assert!(
7657 problem.detail.contains("0.4.31")
7658 && problem.detail.contains(
7659 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
7660 )
7661 && !problem.detail.contains("service install")
7662 && !problem.detail.contains("systemctl")
7663 && !problem.detail.contains("sudo"),
7664 "{}",
7665 problem.detail
7666 );
7667 }
7668
7669 #[cfg(unix)]
7670 #[test]
7671 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
7672 use std::os::unix::fs::PermissionsExt as _;
7673
7674 let root = tempfile::tempdir().expect("a temporary directory");
7675 let path = root
7676 .path()
7677 .join("io.github.IvanMurzak.runner-manager.plist");
7678 std::fs::write(&path, "<string>Background</string>").expect("old plist");
7679 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
7680 let drift = DefinitionDrift {
7681 path: path.clone(),
7682 installed_by_version: "0.4.31".into(),
7683 rendered: launchd_plist(&linux_plan(StartMode::Login)),
7684 start_mode: StartMode::Login,
7685 manager: DefinitionKind::LaunchdPlist,
7686 };
7687
7688 replace_definition("runner-manager", &drift).expect("replaced");
7689
7690 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
7691 assert_eq!(
7692 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
7693 0o644
7694 );
7695 assert_eq!(
7696 std::fs::read_dir(root.path()).unwrap().count(),
7697 1,
7698 "no staging file is left beside the plist"
7699 );
7700 }
7701
7702 #[cfg(target_os = "macos")]
7711 #[test]
7712 #[ignore = "run only by launchd, as the job of the detached-reload test"]
7713 fn detached_reload_job() {
7714 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
7715 let tag = label
7716 .split_once("-selftest-")
7717 .map(|(_, tag)| tag.to_string())
7718 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
7719 let identity = ServiceIdentity::fixture(&tag);
7720 assert_eq!(identity.launchd_label(), label);
7721 let path = std::env::current_dir()
7722 .expect("working directory")
7723 .join(format!("{label}.plist"));
7724 let installed = std::fs::read_to_string(&path).expect("own plist");
7725 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
7726 let drift = DefinitionDrift {
7727 path: path.clone(),
7728 installed_by_version: "0.4.31".into(),
7729 rendered: installed.replace(
7730 "<string>Background</string>",
7731 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
7732 ),
7733 start_mode: StartMode::Login,
7734 manager: DefinitionKind::LaunchdPlist,
7735 };
7736 replace_definition(identity.name(), &drift).expect("plist replaced");
7737 super::sys::reload_detached(&identity, StartMode::Login, &path)
7738 .expect("helper started");
7739 }
7740 std::thread::sleep(Duration::from_secs(120));
7741 }
7742
7743 #[cfg(target_os = "macos")]
7753 #[test]
7754 fn a_job_reloads_itself_from_its_rewritten_plist() {
7755 if !super::launchd_fixture::enabled() {
7756 return;
7757 }
7758 let root = tempfile::tempdir().expect("a temporary directory");
7759 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
7760 let label = identity.launchd_label();
7761 let path = root.path().join(format!("{label}.plist"));
7762 let binary = super::launchd_fixture::job_binary(root.path());
7763 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
7764 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
7765 plist.push_str(&plist_string("Label", &label));
7766 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
7767 for argument in [
7768 binary.to_string_lossy().as_ref(),
7769 "--ignored",
7770 "--exact",
7771 "service::tests::detached_reload_job",
7772 "--test-threads=1",
7773 ] {
7774 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
7775 }
7776 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
7777 plist.push_str(&plist_string(
7778 "WorkingDirectory",
7779 &root.path().to_string_lossy(),
7780 ));
7781 plist.push_str(&plist_string("ProcessType", "Background"));
7782 plist.push_str("</dict>\n</plist>\n");
7783 std::fs::write(&path, plist).expect("plist written");
7784
7785 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
7786 let spawn_type = || {
7787 let printed = std::process::Command::new("launchctl")
7788 .args(["print", loaded.target()])
7789 .output()
7790 .expect("launchctl runs");
7791 String::from_utf8_lossy(&printed.stdout)
7792 .lines()
7793 .find_map(|line| {
7794 line.trim()
7795 .strip_prefix("spawn type = ")
7796 .map(str::to_string)
7797 })
7798 };
7799 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
7800
7801 let deadline = std::time::Instant::now() + Duration::from_secs(90);
7802 while spawn_type().as_deref() != Some("interactive (4)") {
7803 assert!(
7804 std::time::Instant::now() < deadline,
7805 "the job did not come back under its rewritten plist; launchd reports {:?}",
7806 spawn_type()
7807 );
7808 std::thread::sleep(Duration::from_millis(250));
7809 }
7810 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
7811 assert_eq!(
7812 plist_string_value(&rewritten, "ProcessType").as_deref(),
7813 Some(LAUNCHD_PROCESS_TYPE)
7814 );
7815 }
7816
7817 #[test]
7818 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
7819 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7820 assert!(
7821 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
7822 "{plist}"
7823 );
7824 assert!(plist.contains("<string>daemon</string>"), "{plist}");
7825 assert!(plist.contains("<string>run</string>"), "{plist}");
7826 }
7827
7828 #[test]
7829 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
7830 assert_eq!(
7831 ServiceIdentity::product().launchd_label(),
7832 "io.github.IvanMurzak.runner-manager"
7833 );
7834 }
7835
7836 #[test]
7841 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
7842 let xml = windows_scheduled_task_xml(
7843 &windows_plan(StartMode::Login),
7844 &TaskPrincipal::named("HOST\\operator"),
7845 );
7846 assert!(xml.contains("<LogonTrigger>"), "{xml}");
7847 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
7848 assert!(
7849 xml.contains("<LogonType>InteractiveToken</LogonType>"),
7850 "{xml}"
7851 );
7852 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
7853 assert!(
7854 xml.contains("<Interval>PT1M</Interval>"),
7855 "Task Scheduler takes whole minutes only, and rejects the registration outright \
7856 for anything finer:\n{xml}"
7857 );
7858 }
7859
7860 #[test]
7861 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
7862 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
7867 let policy =
7868 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
7869 .expect("inside the supported range");
7870 assert_eq!(
7871 policy
7872 .effective_delay(DefinitionKind::WindowsScheduledTask)
7873 .as_secs(),
7874 enforced,
7875 "a {asked}s delay must be enforced as {enforced}s"
7876 );
7877 assert!(
7878 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
7879 "rounding must never shorten the bound"
7880 );
7881 }
7882 }
7883
7884 #[test]
7885 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
7886 let policy = RestartPolicy::default();
7887 for kind in [
7888 DefinitionKind::WindowsService,
7889 DefinitionKind::LaunchdPlist,
7890 DefinitionKind::SystemdUnit,
7891 ] {
7892 assert_eq!(
7893 policy.effective_delay(kind),
7894 policy.delay(),
7895 "{kind:?} takes seconds and enforces exactly what it is given"
7896 );
7897 }
7898 }
7899
7900 #[test]
7901 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
7902 let host = Host::new();
7903 let operations = host.operations();
7904 operations
7905 .install(&host.request(StartMode::Login))
7906 .expect("an install at login");
7907 host.controls.edit("runner-manager", |registration| {
7909 registration.manager = DefinitionKind::WindowsScheduledTask;
7910 registration.restart_delay = Some(Duration::from_secs(60));
7911 });
7912
7913 let status = operations.status().expect("a status");
7914 assert!(
7915 status.is_healthy(),
7916 "minute granularity is the manager's, not a mis-registration: {status}"
7917 );
7918 assert!(
7919 status
7920 .notes()
7921 .iter()
7922 .any(|note| note.contains("whole minutes")),
7923 "but the operator must be told why 15 became 60: {status}"
7924 );
7925
7926 host.controls.edit("runner-manager", |registration| {
7929 registration.restart_delay = Some(Duration::from_secs(1));
7930 });
7931 assert!(
7932 !operations.status().expect("a status").is_healthy(),
7933 "a one-second delay is not what any manager was asked for"
7934 );
7935 }
7936
7937 #[test]
7938 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
7939 let plan = windows_plan(StartMode::Login);
7940 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
7941 assert_eq!(
7942 xml_value(&xml, "Command").as_deref(),
7943 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
7944 "{xml}"
7945 );
7946 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
7947 }
7948
7949 #[test]
7950 fn an_account_name_holding_xml_punctuation_is_escaped() {
7951 let xml = windows_scheduled_task_xml(
7952 &windows_plan(StartMode::Login),
7953 &TaskPrincipal::named("R&D\\ops"),
7954 );
7955 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
7956 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
7957 }
7958
7959 #[test]
7964 fn the_service_starts_automatically_under_the_account_the_store_admits() {
7965 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
7966 let directives = ini_directives(&text, "windows-service");
7967 assert_eq!(
7968 directives.get("StartType").map(String::as_str),
7969 Some("AutoStart")
7970 );
7971 assert_eq!(
7972 directives.get("Account").map(String::as_str),
7973 Some("NT AUTHORITY\\SYSTEM")
7974 );
7975 assert_eq!(
7976 directives.get("ServiceType").map(String::as_str),
7977 Some("OWN_PROCESS")
7978 );
7979 assert_eq!(
7980 directives
7981 .get("FailureActionRestartDelaySecs")
7982 .map(String::as_str),
7983 Some("15")
7984 );
7985 assert_eq!(
7986 directives
7987 .get("FailureActionsOnNonCrashFailures")
7988 .map(String::as_str),
7989 Some("true"),
7990 "without this flag a non-zero exit is not a failure the manager restarts"
7991 );
7992 }
7993
7994 #[test]
7995 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
7996 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
7997 assert_eq!(spec.account, None);
7998 assert!(spec.automatic_start);
7999 assert!(
8000 spec.command_line.contains("daemon run"),
8001 "{}",
8002 spec.command_line
8003 );
8004 }
8005
8006 #[test]
8011 fn each_definition_goes_where_its_platform_expects_it() {
8012 let home = PathBuf::from("/home/op");
8013 assert_eq!(
8014 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8015 Some(Path::new(
8016 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
8017 ))
8018 );
8019 assert_eq!(
8020 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8021 Some(Path::new(
8022 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
8023 ))
8024 );
8025 assert_eq!(
8026 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8027 Some(Path::new("/etc/systemd/system/runner-manager.service"))
8028 );
8029 assert_eq!(
8030 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8031 Some(Path::new(
8032 "/home/op/.config/systemd/user/runner-manager.service"
8033 ))
8034 );
8035 assert_eq!(
8036 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
8037 None,
8038 "the Service Control Manager has no file"
8039 );
8040 }
8041
8042 #[test]
8043 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
8044 assert_eq!(
8045 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
8046 None
8047 );
8048 assert_eq!(
8049 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
8050 None
8051 );
8052 }
8053
8054 #[test]
8059 fn the_rendered_definitions_are_all_least_privilege() {
8060 let linux = linux_plan(StartMode::Boot);
8061 let windows = windows_plan(StartMode::Boot);
8062 for (definition, plan) in [
8063 (ServiceDefinition::systemd(&linux, None), &linux),
8064 (ServiceDefinition::launchd(&linux, None), &linux),
8065 (ServiceDefinition::windows_service(&windows), &windows),
8066 ] {
8067 let review = review_least_privilege(&definition, plan);
8068 assert!(
8069 review.is_least_privilege(),
8070 "{:?} should be least privilege, got:\n{review}",
8071 definition.kind()
8072 );
8073 assert!(
8074 !review.controls().is_empty(),
8075 "a review that confirms nothing proves nothing: {:?}",
8076 definition.kind()
8077 );
8078 }
8079 }
8080
8081 #[test]
8082 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
8083 let plan = windows_plan(StartMode::Login);
8084 let definition =
8085 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
8086 let review = review_least_privilege(&definition, &plan);
8087 assert!(review.is_least_privilege(), "{review}");
8088 assert!(
8089 review
8090 .controls()
8091 .iter()
8092 .any(|control| control.contains("LeastPrivilege")),
8093 "{review}"
8094 );
8095 }
8096
8097 #[test]
8098 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
8099 let plan = linux_plan(StartMode::Boot);
8100 let rendered = systemd_unit(&plan);
8101 let widened = edited(
8102 &rendered,
8103 "ReadWritePaths=/var/lib/runner-manager/config",
8104 "ReadWritePaths=/etc /var/lib/runner-manager/config",
8105 );
8106 let review = review_least_privilege(
8107 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8108 &plan,
8109 );
8110 assert!(!review.is_least_privilege(), "{review}");
8111 assert!(
8112 review
8113 .excesses()
8114 .iter()
8115 .any(|finding| finding.detail.contains("/etc")),
8116 "the review must name the directory it objects to: {review}"
8117 );
8118 }
8119
8120 #[test]
8121 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
8122 let plan = linux_plan(StartMode::Boot);
8123 let rendered = systemd_unit(&plan);
8124 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
8125 let review = review_least_privilege(
8126 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
8127 &plan,
8128 );
8129 assert!(!review.is_least_privilege(), "{review}");
8130 assert!(
8131 review
8132 .excesses()
8133 .iter()
8134 .any(|finding| finding.subject == "NoNewPrivileges"),
8135 "{review}"
8136 );
8137 }
8138
8139 #[test]
8140 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
8141 let plan = linux_plan(StartMode::Boot);
8142 let rendered = systemd_unit(&plan);
8143 let widened = edited(
8144 &rendered,
8145 "CapabilityBoundingSet=\n",
8146 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
8147 );
8148 let review = review_least_privilege(
8149 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8150 &plan,
8151 );
8152 assert!(!review.is_least_privilege(), "{review}");
8153 assert!(
8154 review
8155 .excesses()
8156 .iter()
8157 .any(|finding| finding.subject == "CapabilityBoundingSet"),
8158 "{review}"
8159 );
8160 }
8161
8162 #[test]
8163 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
8164 let plan = linux_plan(StartMode::Boot);
8165 let rendered = systemd_unit(&plan);
8166 let widened = edited(
8167 &rendered,
8168 "[Install]",
8169 "ListenStream=127.0.0.1:9000\n\n[Install]",
8170 );
8171 let review = review_least_privilege(
8172 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8173 &plan,
8174 );
8175 assert!(
8176 !review.is_least_privilege(),
8177 "07-security.md rule 2 forbids any inbound surface: {review}"
8178 );
8179 }
8180
8181 #[test]
8182 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
8183 let plan = linux_plan(StartMode::Boot);
8184 let rendered = systemd_unit(&plan);
8185 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
8186 let review = review_least_privilege(
8187 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
8188 &plan,
8189 );
8190 assert!(
8191 review.is_least_privilege(),
8192 "too little authority is not an excess: {review}"
8193 );
8194 assert!(
8195 review
8196 .findings()
8197 .iter()
8198 .any(|finding| finding.kind == FindingKind::Shortfall
8199 && finding.detail.contains("runtime")),
8200 "{review}"
8201 );
8202 }
8203
8204 #[test]
8205 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
8206 let plan = linux_plan(StartMode::Login);
8207 let rendered = launchd_plist(&plan);
8208 let widened = edited(
8209 &rendered,
8210 "<key>ProcessType</key>",
8211 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
8212 );
8213 let review = review_least_privilege(
8214 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8215 &plan,
8216 );
8217 assert!(!review.is_least_privilege(), "{review}");
8218 assert!(
8219 review
8220 .excesses()
8221 .iter()
8222 .any(|finding| finding.subject == "UserName"),
8223 "{review}"
8224 );
8225 }
8226
8227 #[test]
8228 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
8229 let plan = linux_plan(StartMode::Boot);
8230 let rendered = launchd_plist(&plan);
8231 let widened = edited(
8232 &rendered,
8233 "<key>SessionCreate</key>\n <false/>",
8234 "<key>SessionCreate</key>\n <true/>",
8235 );
8236 let review = review_least_privilege(
8237 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8238 &plan,
8239 );
8240 assert!(!review.is_least_privilege(), "{review}");
8241 assert!(
8242 review
8243 .excesses()
8244 .iter()
8245 .any(|finding| finding.subject == "SessionCreate"),
8246 "{review}"
8247 );
8248 }
8249
8250 #[test]
8251 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
8252 let plan = linux_plan(StartMode::Boot);
8253 let rendered = launchd_plist(&plan);
8254 let widened = edited(
8255 &rendered,
8256 "<key>ProcessType</key>",
8257 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
8258 );
8259 let review = review_least_privilege(
8260 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8261 &plan,
8262 );
8263 assert!(!review.is_least_privilege(), "{review}");
8264 }
8265
8266 #[test]
8267 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
8268 let plan = windows_plan(StartMode::Login);
8269 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8270 let widened = edited(
8271 &rendered,
8272 "<RunLevel>LeastPrivilege</RunLevel>",
8273 "<RunLevel>HighestAvailable</RunLevel>",
8274 );
8275 let review = review_least_privilege(
8276 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8277 &plan,
8278 );
8279 assert!(!review.is_least_privilege(), "{review}");
8280 assert!(
8281 review
8282 .excesses()
8283 .iter()
8284 .any(|finding| finding.subject == "RunLevel"),
8285 "{review}"
8286 );
8287 }
8288
8289 #[test]
8290 fn a_task_that_would_store_a_password_is_not_least_privilege() {
8291 let plan = windows_plan(StartMode::Login);
8292 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8293 let widened = edited(
8294 &rendered,
8295 "<LogonType>InteractiveToken</LogonType>",
8296 "<LogonType>Password</LogonType>",
8297 );
8298 let review = review_least_privilege(
8299 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8300 &plan,
8301 );
8302 assert!(!review.is_least_privilege(), "{review}");
8303 }
8304
8305 #[test]
8306 fn an_interactive_windows_service_is_not_least_privilege() {
8307 let plan = windows_plan(StartMode::Boot);
8308 let rendered = windows_service_descriptor(&plan);
8309 let widened = edited(
8310 &rendered,
8311 "ServiceType=OWN_PROCESS",
8312 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
8313 );
8314 let review = review_least_privilege(
8315 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
8316 &plan,
8317 );
8318 assert!(!review.is_least_privilege(), "{review}");
8319 assert!(
8320 review
8321 .excesses()
8322 .iter()
8323 .any(|finding| finding.subject == "ServiceType"),
8324 "{review}"
8325 );
8326 }
8327
8328 #[test]
8329 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
8330 let plan = windows_plan(StartMode::Boot);
8331 let rendered = windows_service_descriptor(&plan);
8332 let changed = edited(
8333 &rendered,
8334 "Account=NT AUTHORITY\\SYSTEM",
8335 "Account=NT AUTHORITY\\LocalService",
8336 );
8337 let review = review_least_privilege(
8338 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
8339 &plan,
8340 );
8341 assert!(review.is_least_privilege(), "{review}");
8347 assert!(
8348 review.findings().iter().any(|finding| {
8349 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
8350 }),
8351 "{review}"
8352 );
8353 }
8354
8355 #[test]
8360 fn a_recorded_path_that_is_still_there_is_current() {
8361 let host = Host::new();
8362 let state = inspect_binary(&host.binary, Some(&host.binary));
8363 assert!(!state.is_error(), "{state}");
8364 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
8365 }
8366
8367 #[test]
8368 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
8369 let host = Host::new();
8370 let recorded = host.binary.clone();
8374 let healthy = inspect_binary(&recorded, Some(&recorded));
8375 assert!(
8376 !healthy.is_error(),
8377 "the discriminator: before the binary moves, this must be healthy"
8378 );
8379
8380 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
8381
8382 let state = inspect_binary(&recorded, Some(&recorded));
8383 assert!(state.is_error(), "{state}");
8384 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
8385 assert!(
8386 state.to_string().contains("npm"),
8387 "the message must name the cause an operator will not otherwise connect: {state}"
8388 );
8389 }
8390
8391 #[test]
8392 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
8393 let root = tempfile::tempdir().expect("a temporary directory");
8394 let state = inspect_binary(root.path(), None);
8395 assert!(state.is_error(), "{state}");
8396 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
8397 }
8398
8399 #[test]
8400 fn a_registration_naming_a_different_binary_is_a_divergence() {
8401 let host = Host::new();
8402 let other = host.binary.with_file_name("something-else");
8403 let state = inspect_binary(&host.binary, Some(&other));
8404 assert!(state.is_error(), "{state}");
8405 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
8406 }
8407
8408 #[test]
8409 fn absence_is_reported_before_divergence() {
8410 let host = Host::new();
8411 let recorded = host.binary.clone();
8412 std::fs::remove_file(&recorded).expect("removable");
8413 let other = recorded.with_file_name("something-else");
8414 assert!(matches!(
8418 inspect_binary(&recorded, Some(&other)),
8419 BinaryPath::Missing { .. }
8420 ));
8421 }
8422
8423 #[test]
8428 fn the_record_round_trips_through_toml() {
8429 let host = Host::new();
8430 let plan = InstallPlan::resolve(
8431 ServiceIdentity::product(),
8432 &host.request(StartMode::Boot),
8433 ServiceDirectories::of(&host.paths),
8434 )
8435 .expect("a resolvable plan");
8436 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8437 let record = InstallRecord::of(&plan, &definition, Utc::now());
8438 record.write(&host.paths).expect("a writable record");
8439 let read = InstallRecord::read(&host.paths)
8440 .expect("a readable record")
8441 .expect("a record is there");
8442 assert_eq!(read, record);
8443 assert_eq!(read.binary, host.binary);
8444 assert!(read.binary.is_absolute());
8445 }
8446
8447 #[test]
8448 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
8449 let host = Host::new();
8450 let identity = ServiceIdentity::product();
8451 let plan = InstallPlan::resolve(
8452 identity.clone(),
8453 &host.request(StartMode::Boot),
8454 ServiceDirectories::of(&host.paths),
8455 )
8456 .expect("a resolvable plan");
8457 let installed = systemd_unit(&plan);
8458 let definition =
8459 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
8460 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8461 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
8462
8463 assert_eq!(
8464 systemd_definition_drift(&identity, &record, path.clone(), &installed),
8465 None,
8466 "the record must rebuild exactly the unit `service install` wrote"
8467 );
8468
8469 record.installed_by_version = "0.4.7".into();
8470 let frozen = installed.replace(
8471 "[Service]\n",
8472 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
8473 );
8474 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
8475 .expect("an outdated unit is drift");
8476 assert_eq!(drift.installed_by_version, "0.4.7");
8477 assert_eq!(drift.rendered, installed);
8478
8479 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8480 .with_definition_drift(Some(drift));
8481 assert!(!status.is_healthy());
8482 assert!(
8483 status
8484 .problems()
8485 .iter()
8486 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
8487 "{status}"
8488 );
8489 }
8490
8491 #[test]
8492 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
8493 let host = Host::new();
8494 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8495 let first = Utc::now() - chrono::Duration::hours(9);
8496 record_github_credential_rejected(&host.paths, first).unwrap();
8497 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
8498 let since = github_credential_rejected_since(&host.paths).unwrap();
8499 assert_eq!(since, Some(first));
8500
8501 let status = ServiceStatus::compose(
8502 ServiceIdentity::product(),
8503 None,
8504 None,
8505 None,
8506 None,
8507 &host.paths,
8508 )
8509 .with_credential_rejection(since);
8510 assert!(!status.is_healthy());
8511 assert_eq!(status.credential_rejected_since(), Some(first));
8512 assert!(status.to_string().contains("auth login"), "{status}");
8513
8514 clear_github_credential_rejection(&host.paths).unwrap();
8515 clear_github_credential_rejection(&host.paths).unwrap();
8516 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8517 }
8518
8519 #[test]
8522 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
8523 let host = Host::new();
8524 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8525 let first = Utc::now() - chrono::Duration::minutes(40);
8526 record_credential_unreadable(&host.paths, first).unwrap();
8527 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
8528 let since = credential_unreadable_since(&host.paths).unwrap();
8529 assert_eq!(since, Some(first), "a restart loop must not move the start");
8530
8531 let status = ServiceStatus::compose(
8532 ServiceIdentity::product(),
8533 None,
8534 None,
8535 None,
8536 None,
8537 &host.paths,
8538 )
8539 .with_credential_unreadable(since);
8540 assert!(!status.is_healthy());
8541 let problem = status
8542 .problems()
8543 .iter()
8544 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
8545 .expect("reported under its own subject");
8546 assert!(
8548 problem
8549 .detail
8550 .contains(&sign_in_instruction(StartMode::default())),
8551 "{status}"
8552 );
8553
8554 clear_credential_unreadable(&host.paths).unwrap();
8555 clear_credential_unreadable(&host.paths).unwrap();
8556 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8557 let healthy = ServiceStatus::compose(
8558 ServiceIdentity::product(),
8559 None,
8560 None,
8561 None,
8562 None,
8563 &host.paths,
8564 )
8565 .with_credential_unreadable(None);
8566 assert!(
8567 !healthy
8568 .problems()
8569 .iter()
8570 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
8571 "{healthy}"
8572 );
8573 }
8574
8575 #[test]
8576 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
8577 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
8578 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
8579 assert_eq!(
8580 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
8581 cfg!(unix),
8582 "a machine-scoped store needs root on Unix"
8583 );
8584 assert_eq!(
8585 sign_in_instruction(StartMode::Login).contains("SSH"),
8586 cfg!(target_os = "macos"),
8587 "only the macOS login keychain is out of an SSH session's reach"
8588 );
8589 }
8590
8591 #[cfg(unix)]
8600 #[test]
8601 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
8602 use std::os::unix::fs::PermissionsExt as _;
8603
8604 let host = Host::new();
8605 let plan = InstallPlan::resolve(
8606 ServiceIdentity::product(),
8607 &host.request(StartMode::Boot),
8608 ServiceDirectories::of(&host.paths),
8609 )
8610 .expect("a resolvable plan");
8611 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8612 InstallRecord::of(&plan, &definition, Utc::now())
8613 .write(&host.paths)
8614 .expect("a writable record");
8615
8616 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
8617 .expect("the record is there")
8618 .permissions()
8619 .mode()
8620 & 0o777;
8621 assert_eq!(
8622 mode, 0o644,
8623 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
8624 service install` wrote, and `service status` fails on their own host. It holds no \
8625 credential and sits in a 0700 directory, so 0644 discloses nothing"
8626 );
8627 }
8628
8629 #[test]
8633 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
8634 let host = Host::new();
8635 let path = InstallRecord::path(&host.paths);
8636 std::fs::write(
8637 &path,
8638 format!(
8639 "schema_version = {RECORD_SCHEMA_VERSION}
8640service_name = \"runner-manager\"
8641 manager = \"systemd\"
8642start_mode = \"boot\"
8643account = \"root\"
8644 binary = \"/x\"
8645arguments = []
8646restart_delay_secs = 15
8647 restart_reset_secs = 600
8648log_file = \"/x\"
8649 installed_at = \"2026-01-01T00:00:00Z\"
8650installed_by_version = \"0.1.0\"
8651 [directories]
8652config = \"/a\"
8653state = \"/b\"
8654runtime = \"/c\"
8655logs = \"/d\"
8656"
8657 ),
8658 )
8659 .expect("a writable record");
8660 let read = InstallRecord::read(&host.paths)
8661 .expect("a record missing an optional field is still readable")
8662 .expect("a record is there");
8663 assert_eq!(
8664 read.source_binary, None,
8665 "the legacy layout has no source, and must not invent one"
8666 );
8667 }
8668
8669 #[test]
8671 fn a_registration_remembers_the_file_it_was_copied_from() {
8672 let host = Host::new();
8673 let source = host.binary.with_file_name("npm-installed-runner-manager");
8674 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
8675 let plan = InstallPlan::resolve(
8676 ServiceIdentity::product(),
8677 &host.request(StartMode::Boot).copied_from(&source),
8678 ServiceDirectories::of(&host.paths),
8679 )
8680 .expect("a resolvable plan");
8681 let definition = ServiceDefinition::from_text(
8682 DefinitionKind::SystemdUnit,
8683 "[Service]
8684",
8685 );
8686 let record = InstallRecord::of(&plan, &definition, Utc::now());
8687 record.write(&host.paths).expect("a writable record");
8688
8689 let read = InstallRecord::read(&host.paths)
8690 .expect("a readable record")
8691 .expect("a record is there");
8692 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
8693 assert_ne!(
8694 read.source_binary.as_deref(),
8695 Some(read.binary.as_path()),
8696 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
8697 );
8698 }
8699
8700 #[test]
8701 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
8702 let host = Host::new();
8703 let path = InstallRecord::path(&host.paths);
8704 std::fs::write(
8705 &path,
8706 format!(
8707 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
8708 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
8709 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
8710 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
8711 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
8712 RECORD_SCHEMA_VERSION + 1
8713 ),
8714 )
8715 .expect("a writable record");
8716 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
8717 assert!(
8718 matches!(error, ServiceError::RecordUnreadable { .. }),
8719 "{error}"
8720 );
8721 assert!(
8722 error.to_string().contains("service uninstall"),
8723 "the message must say how to recover: {error}"
8724 );
8725 }
8726
8727 #[test]
8728 fn no_record_is_not_an_error() {
8729 let host = Host::new();
8730 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
8731 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
8732 }
8733
8734 #[test]
8739 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
8740 let host = Host::new();
8741 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
8742 }
8743
8744 #[test]
8745 fn the_heartbeat_round_trips_to_the_second() {
8746 let host = Host::new();
8747 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
8748 .expect("a valid timestamp")
8749 .with_timezone(&Utc);
8750 record_github_contact(&host.paths, at).expect("a writable heartbeat");
8751 assert_eq!(
8752 last_github_contact(&host.paths).expect("readable"),
8753 Some(at)
8754 );
8755 }
8756
8757 #[test]
8758 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
8759 let host = Host::new();
8760 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
8761 let error = last_github_contact(&host.paths)
8762 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
8763 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8764 }
8765
8766 #[test]
8772 fn a_runner_root_refusal_round_trips_for_service_status() {
8773 let host = Host::new();
8774 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8775
8776 assert!(
8777 runner_root_refusals(&host.paths)
8778 .expect("readable")
8779 .is_empty(),
8780 "no record means every policy is placing runners"
8781 );
8782
8783 record_runner_root_refusal(
8784 &host.paths,
8785 "policy-a",
8786 at,
8787 "denied_by_privacy_policy",
8788 "/Volumes/NVME/runners",
8789 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
8790 )
8791 .expect("a writable record");
8792
8793 let refusals = runner_root_refusals(&host.paths).expect("readable");
8794 assert_eq!(refusals.len(), 1);
8795 assert_eq!(refusals[0].policy, "policy-a");
8796 assert_eq!(refusals[0].at, at);
8797 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
8798 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
8799 assert!(
8800 refusals[0].detail.contains("/Volumes/NVME/runners")
8801 && refusals[0].detail.contains("Full Disk Access"),
8802 "the path and the remediation are the whole point of this file: {refusals:?}"
8803 );
8804 }
8805
8806 #[test]
8814 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
8815 let host = Host::new();
8816 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8817 record_runner_root_refusal(
8818 &host.paths,
8819 "broken",
8820 at,
8821 "denied_by_privacy_policy",
8822 "/Volumes/NVME/runners",
8823 "detail",
8824 )
8825 .expect("a writable record");
8826 record_runner_root_refusal(
8827 &host.paths,
8828 "also-broken",
8829 at,
8830 "not_writable",
8831 "/srv/other",
8832 "detail",
8833 )
8834 .expect("a writable record");
8835
8836 clear_runner_root_refusal(&host.paths, "healthy")
8838 .expect("clearing an absent policy is fine");
8839 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
8840
8841 let refusals = runner_root_refusals(&host.paths).expect("readable");
8842 assert_eq!(
8843 refusals
8844 .iter()
8845 .map(|r| r.policy.as_str())
8846 .collect::<Vec<_>>(),
8847 vec!["broken"],
8848 "the policy that is still refused must keep its record"
8849 );
8850 }
8851
8852 #[test]
8855 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
8856 let host = Host::new();
8857 record_runner_root_refusal(
8858 &host.paths,
8859 "p",
8860 Utc::now(),
8861 "not_writable",
8862 "/srv/x",
8863 "detail",
8864 )
8865 .expect("a writable record");
8866
8867 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
8868 assert!(
8869 !root_refusal_path(&host.paths).exists(),
8870 "a host with nothing refused leaves nothing behind"
8871 );
8872 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
8873 }
8874
8875 #[test]
8878 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
8879 let host = Host::new();
8880 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
8881 let error = runner_root_refusals(&host.paths)
8882 .expect_err("an unparseable record is not the same as no record");
8883 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8884 }
8885
8886 #[test]
8896 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
8897 let host = Host::new();
8898 record_runner_root_refusal(
8899 &host.paths,
8900 "policy-a",
8901 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
8902 "denied_by_privacy_policy",
8903 "/Volumes/NVME/runners",
8904 "Grant Full Disk Access to the program that runs the service",
8905 )
8906 .expect("a writable record");
8907
8908 let status = host.operations().status().expect("a readable status");
8909 let notes = status.notes().join("\n");
8910
8911 assert!(
8912 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
8913 "the directory and the remediation the log had to scrub must appear here: {notes}"
8914 );
8915 assert!(
8916 notes.contains("policy-a"),
8917 "the operator has to know which target placed no runner: {notes}"
8918 );
8919 assert!(
8920 !status
8921 .problems()
8922 .iter()
8923 .any(|problem| problem.subject == "runner root"),
8924 "a record nothing an operator types can clear must not drive the exit code"
8925 );
8926 }
8927
8928 #[test]
8934 fn install_records_the_absolute_binary_path_and_the_four_directories() {
8935 let host = Host::new();
8936 let installed = host
8937 .operations()
8938 .install(&host.request(StartMode::Boot))
8939 .expect("an install against the recording controls");
8940
8941 assert_eq!(installed.record.binary, host.binary);
8942 assert!(installed.record.binary.is_absolute());
8943 assert_eq!(installed.record.start_mode, StartMode::Boot);
8944 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
8945 assert_eq!(
8946 installed.record.directories,
8947 ServiceDirectories::of(&host.paths)
8948 );
8949 assert_eq!(
8950 installed.record.log_file,
8951 host.paths.logs_dir().join(LOG_FILE_STEM)
8952 );
8953 assert_eq!(installed.record.restart_delay_secs, 15);
8954
8955 let registrations = host.controls.registrations();
8956 assert_eq!(registrations.len(), 1);
8957 assert_eq!(registrations[0].0, StartMode::Boot);
8958 assert_eq!(registrations[0].1, "runner-manager");
8959 }
8960
8961 #[test]
8962 fn install_is_refused_while_the_single_instance_lock_is_held() {
8963 let host = Host::new();
8964 {
8966 let operations = host.operations();
8967 operations
8968 .install(&host.request(StartMode::Boot))
8969 .expect("an install with the lock free");
8970 operations.uninstall().expect("a clean slate");
8971 }
8972
8973 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
8974 .expect("this process takes the lock first");
8975
8976 let error = host
8977 .operations()
8978 .install(&host.request(StartMode::Boot))
8979 .expect_err("a second agent must not be registered while one is running");
8980 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
8981 assert!(
8982 error.to_string().contains("already running"),
8983 "the message must be actionable: {error}"
8984 );
8985 assert!(
8986 host.controls.registrations().is_empty(),
8987 "a refused install must register nothing"
8988 );
8989 assert_eq!(
8990 InstallRecord::read(&host.paths).expect("readable"),
8991 None,
8992 "a refused install must write no record"
8993 );
8994 }
8995
8996 #[test]
9004 fn installing_over_the_same_start_mode_replaces_the_registration() {
9005 let host = Host::new();
9006 let operations = host.operations();
9007 operations
9008 .install(&host.request(StartMode::Boot))
9009 .expect("the first install");
9010
9011 let again = operations
9012 .install(&host.request(StartMode::Boot))
9013 .expect("an install over the same mode replaces rather than refusing");
9014
9015 assert!(
9016 again.replaced_existing,
9017 "the operator is told this replaced something rather than made it"
9018 );
9019 assert_eq!(
9020 host.controls.registrations().len(),
9021 1,
9022 "replacing must not leave two registrations behind"
9023 );
9024 assert_eq!(
9025 InstallRecord::read(&host.paths)
9026 .expect("readable")
9027 .expect("a record")
9028 .start_mode,
9029 StartMode::Boot
9030 );
9031 }
9032
9033 #[test]
9037 fn installing_over_the_other_start_mode_is_refused() {
9038 let host = Host::new();
9039 let operations = host.operations();
9040 operations
9041 .install(&host.request(StartMode::Boot))
9042 .expect("the first install");
9043
9044 let error = operations
9045 .install(&host.request(StartMode::Login))
9046 .expect_err("a mode change is not an install");
9047 assert!(
9048 matches!(
9049 error,
9050 ServiceError::AlreadyInstalled {
9051 existing: StartMode::Boot,
9052 requested: StartMode::Login,
9053 ..
9054 }
9055 ),
9056 "{error}"
9057 );
9058 assert!(
9059 !error
9060 .to_string()
9061 .contains("switch the start mode in place,"),
9062 "the old remedy named a capability no command offers; the terminal UI is where \
9063 the start mode moves: {error}"
9064 );
9065 assert_eq!(host.controls.registrations().len(), 1);
9066 }
9067
9068 #[test]
9069 fn install_rolls_back_the_registration_when_record_persistence_fails() {
9070 let host = Host::new();
9071 let record_path = InstallRecord::path(&host.paths);
9072 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
9073
9074 let error = host
9075 .operations()
9076 .install(&host.request(StartMode::Boot))
9077 .expect_err("record persistence must fail");
9078
9079 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9080 assert!(
9081 host.controls.registrations().is_empty(),
9082 "a failed install must not leave a live unrecorded registration"
9083 );
9084 assert!(
9085 host.controls
9086 .calls()
9087 .iter()
9088 .any(|call| call == "uninstall runner-manager (boot)"),
9089 "the registration must be explicitly rolled back: {:?}",
9090 host.controls.calls()
9091 );
9092 assert!(
9093 !host.runner_root.as_path().exists(),
9094 "the rollback must take the runner root this install created with it; a directory \
9095 prepared for a registration that does not exist is litter, and on Windows it is \
9096 litter with a security descriptor"
9097 );
9098 }
9099
9100 #[test]
9105 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
9106 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
9107
9108 assert_eq!(
9112 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9113 ServiceAccount::LocalSystem
9114 );
9115 assert_eq!(
9116 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
9117 ServiceAccount::InvokingUser
9118 );
9119
9120 let boot = default_root_sddl(&RootAdmission::LocalSystem);
9121 assert!(!grants_broad_write(&boot), "{boot}");
9122 assert!(
9123 !boot.contains("S-1-5-21"),
9124 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
9125 );
9126
9127 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
9132 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
9133 assert!(!grants_broad_write(&login), "{login}");
9134 }
9135
9136 #[test]
9137 fn an_install_reports_the_runner_root_it_prepared() {
9138 let host = Host::new();
9139 let installed = host
9140 .operations()
9141 .install(&host.request(StartMode::Boot))
9142 .expect("an install");
9143 let rendered = installed.runner_root.to_string();
9144 assert!(
9145 !rendered.contains("S-1-5-21"),
9146 "the report must add no identity to the output: {rendered}"
9147 );
9148 if cfg!(windows) {
9149 assert_eq!(
9150 installed.runner_root.path(),
9151 Some(host.runner_root.as_path())
9152 );
9153 assert!(
9154 host.runner_root.as_path().is_dir(),
9155 "the directory jobs would run in has to exist once the service is registered"
9156 );
9157 } else {
9158 assert_eq!(
9159 installed.runner_root,
9160 crate::runner_root_access::RootAccessSummary::NotApplicable,
9161 "macOS and Linux keep the runtime directory they have always used"
9162 );
9163 }
9164 }
9165
9166 #[test]
9167 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
9168 let host = Host::new();
9169 let operations = host.operations();
9170 operations
9171 .install(&host.request(StartMode::Boot))
9172 .expect("an install at boot");
9173
9174 let change = operations
9175 .set_start_mode(StartMode::Login)
9176 .expect("a switch to login");
9177
9178 assert!(change.changed);
9179 if cfg!(windows) {
9180 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
9181 assert!(
9182 host.runner_root.as_path().is_dir(),
9183 "the switch must not remove the directory it reconciled"
9184 );
9185 }
9186 assert!(
9189 change.to_string().contains("runner root"),
9190 "{}",
9191 change.to_string()
9192 );
9193 }
9194
9195 #[test]
9196 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
9197 let host = Host::new();
9198 let operations = host.operations();
9199 operations
9200 .install(&host.request(StartMode::Boot))
9201 .expect("an install at boot");
9202
9203 let change = operations
9204 .set_start_mode(StartMode::Boot)
9205 .expect("a switch to the mode already in force");
9206
9207 assert!(!change.changed);
9208 assert_eq!(
9209 change.runner_root,
9210 crate::runner_root_access::RootAccessSummary::NotApplicable,
9211 "nothing moves, so nothing about the root's access control has to; reconciling here \
9212 would turn a no-op command into one that can fail on a permission it does not need"
9213 );
9214 }
9215
9216 #[cfg(windows)]
9217 #[test]
9218 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
9219 let host = Host::new();
9220 host.controls
9221 .fail_next_install(StartMode::Boot, "injected registration failure");
9222
9223 let error = host
9224 .operations()
9225 .install(&host.request(StartMode::Boot))
9226 .expect_err("the manager refuses the registration");
9227
9228 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9229 assert!(
9230 !host.runner_root.as_path().exists(),
9231 "the directory was created for a registration that does not exist"
9232 );
9233 }
9234
9235 #[cfg(windows)]
9236 #[test]
9237 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
9238 let host = Host::new();
9239 crate::runner_root_access::create_with_descriptor_for_tests(
9243 host.runner_root.as_path(),
9244 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
9245 )
9246 .expect("a deliberately open runner root");
9247 let before = crate::runner_root_access::report(host.runner_root.as_path());
9248
9249 let error = host
9250 .operations()
9251 .install(&host.request(StartMode::Boot))
9252 .expect_err("an open runner root is refused");
9253
9254 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
9255 assert!(
9256 error.to_string().contains("nothing was registered"),
9257 "{error}"
9258 );
9259 assert!(
9260 host.controls.registrations().is_empty(),
9261 "the refusal has to come before the platform is asked to register anything: {:?}",
9262 host.controls.calls()
9263 );
9264 assert_eq!(
9265 crate::runner_root_access::report(host.runner_root.as_path()),
9266 before,
9267 "an open directory is refused rather than tightened: its contents cannot be trusted, \
9268 so adopting it would be worse than declining it"
9269 );
9270 }
9271
9272 #[cfg(windows)]
9273 #[test]
9274 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
9275 let host = Host::new();
9276 let operations = host.operations();
9277 operations
9278 .install(&host.request(StartMode::Boot))
9279 .expect("an install");
9280 assert!(host.runner_root.as_path().is_dir());
9281
9282 operations.uninstall().expect("an uninstall");
9283
9284 assert!(
9285 host.runner_root.as_path().is_dir(),
9286 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
9287 runner root may hold an operator's retained workspaces."
9288 );
9289 }
9290
9291 #[test]
9292 fn install_reviews_what_it_registered() {
9293 let host = Host::new();
9294 let installed = host
9295 .operations()
9296 .install(&host.request(StartMode::Boot))
9297 .expect("an install");
9298 assert!(
9299 installed.review.is_least_privilege(),
9300 "{}",
9301 installed.review
9302 );
9303 assert!(
9304 !installed.review.controls().is_empty(),
9305 "a review that confirms nothing proves nothing: {}",
9306 installed.review
9307 );
9308 assert_eq!(
9309 installed.review.kind(),
9310 host_definition_kind(StartMode::Boot),
9311 "the review must be of the definition this host's manager was given"
9312 );
9313 assert!(
9314 !installed.review.account().justification().is_empty(),
9315 "a privileged account with no stated reason is an unreviewed one"
9316 );
9317 }
9318
9319 #[test]
9324 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
9325 let host = Host::new();
9326 let operations = host.operations();
9327 operations
9328 .install(&host.request(StartMode::Boot))
9329 .expect("an install");
9330
9331 let config = host.paths.config_dir();
9335 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
9336 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
9337 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
9338 std::fs::write(
9339 host.paths
9340 .state_dir()
9341 .join("packages/2.330.0/runner.tar.gz"),
9342 b"cached package",
9343 )
9344 .expect("writable");
9345 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
9346 std::fs::write(
9347 host.paths.state_dir().join("secrets/user-access-token"),
9348 b"a stand-in for the stored credential",
9349 )
9350 .expect("writable");
9351 std::fs::write(
9352 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
9353 b"diagnostics",
9354 )
9355 .expect("writable");
9356
9357 let roots: Vec<PathBuf> = host
9358 .paths
9359 .all()
9360 .iter()
9361 .map(|(_, path)| (*path).to_path_buf())
9362 .collect();
9363 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
9364 let before = snapshot(&roots);
9365
9366 assert!(
9369 before.len() >= 6,
9370 "the fixture must actually contain the files this test is about, got {before:#?}"
9371 );
9372 let record_path = InstallRecord::path(&host.paths);
9373 assert!(
9374 before.contains_key(&record_path),
9375 "the install record must be present before uninstall"
9376 );
9377
9378 let uninstalled = operations.uninstall().expect("an uninstall");
9379 assert!(uninstalled.removed_registration);
9380 assert!(uninstalled.removed_record);
9381
9382 let after = snapshot(&roots);
9383
9384 let mut expected = before.clone();
9386 expected.remove(&record_path);
9387 assert_eq!(
9388 after, expected,
9389 "uninstall must remove its own record and nothing else"
9390 );
9391 assert!(
9392 !record_path.exists(),
9393 "the record itself must go, or `uninstall` did nothing at all"
9394 );
9395 assert!(
9396 uninstalled
9397 .preserved
9398 .iter()
9399 .all(|path| roots.contains(&path.as_path())),
9400 "the preserved list must name the four directories: {uninstalled}"
9401 );
9402 }
9403
9404 #[test]
9405 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
9406 let host = Host::new();
9407 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
9408 assert!(!uninstalled.removed_registration);
9409 assert!(!uninstalled.removed_record);
9410 }
9411
9412 #[test]
9413 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
9414 let host = Host::new();
9415 let operations = host.operations();
9416 operations
9417 .install(&host.request(StartMode::Boot))
9418 .expect("an install");
9419 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
9420
9421 let uninstalled = operations.uninstall().expect("an uninstall");
9422 assert!(
9423 uninstalled.removed_registration,
9424 "a lost record must not strand a registration"
9425 );
9426 assert!(host.controls.registrations().is_empty());
9427 }
9428
9429 #[test]
9434 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
9435 let host = Host::new();
9436 let operations = host.operations();
9437 operations
9438 .install(&host.request(StartMode::Boot))
9439 .expect("an install at boot");
9440
9441 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
9445
9446 let change = operations
9447 .set_start_mode(StartMode::Login)
9448 .expect("a switch that does not reinstall the product");
9449 assert!(change.changed);
9450 assert_eq!(change.from, StartMode::Boot);
9451 assert_eq!(change.to, StartMode::Login);
9452 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
9453
9454 let record = InstallRecord::read(&host.paths)
9455 .expect("readable")
9456 .expect("a record");
9457 assert_eq!(record.start_mode, StartMode::Login);
9458 assert_eq!(
9459 record.binary, host.binary,
9460 "the recorded path must survive the switch untouched"
9461 );
9462
9463 let registrations = host.controls.registrations();
9464 assert_eq!(registrations.len(), 1, "{registrations:?}");
9465 assert_eq!(registrations[0].0, StartMode::Login);
9466 assert!(
9467 registrations[0]
9468 .2
9469 .command_line
9470 .contains(&host.binary.to_string_lossy().into_owned()),
9471 "{:?}",
9472 registrations[0].2
9473 );
9474 }
9475
9476 #[test]
9477 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
9478 let host = Host::new();
9479 let operations = host.operations();
9480 operations
9481 .install(&host.request(StartMode::Boot))
9482 .expect("an install at boot");
9483 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9484 host.controls
9485 .fail_next_install(StartMode::Login, "injected target failure");
9486
9487 let error = operations
9488 .set_start_mode(StartMode::Login)
9489 .expect_err("the target manager refuses the install");
9490
9491 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9492 assert_eq!(
9493 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9494 record_before
9495 );
9496 let registrations = host.controls.registrations();
9497 assert_eq!(registrations.len(), 1, "{registrations:?}");
9498 assert_eq!(registrations[0].0, StartMode::Boot);
9499 }
9500
9501 #[test]
9502 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
9503 let host = Host::new();
9504 let operations = host.operations();
9505 operations
9506 .install(&host.request(StartMode::Boot))
9507 .expect("an install at boot");
9508 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9509 let config = host.paths.config_dir().to_path_buf();
9510 let hidden = config.with_file_name("config-hidden-by-fault");
9511 host.controls.hide_directory_after_install(
9512 StartMode::Login,
9513 config.clone(),
9514 hidden.clone(),
9515 );
9516
9517 let error = operations
9518 .set_start_mode(StartMode::Login)
9519 .expect_err("the injected filesystem fault prevents persistence");
9520
9521 std::fs::remove_file(&config).expect("remove the injected blocker");
9522 std::fs::rename(&hidden, &config).expect("restore the record directory");
9523 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9524 assert_eq!(
9525 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9526 record_before
9527 );
9528 let registrations = host.controls.registrations();
9529 assert_eq!(registrations.len(), 1, "{registrations:?}");
9530 assert_eq!(registrations[0].0, StartMode::Boot);
9531 assert!(
9532 host.controls
9533 .calls()
9534 .iter()
9535 .any(|call| call == "uninstall runner-manager (login)"),
9536 "the target must be rolled back: {:?}",
9537 host.controls.calls()
9538 );
9539 }
9540
9541 #[test]
9542 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
9543 let host = Host::new();
9544 let operations = host.operations();
9545 operations
9546 .install(&host.request(StartMode::Boot))
9547 .expect("an install");
9548 let before = host.controls.calls().len();
9549
9550 let change = operations
9551 .set_start_mode(StartMode::Boot)
9552 .expect("a no-op switch");
9553 assert!(!change.changed);
9554 assert_eq!(
9555 host.controls.calls().len(),
9556 before,
9557 "a no-op switch must not touch the service manager"
9558 );
9559 }
9560
9561 #[test]
9562 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
9563 let host = Host::new();
9564 let error = host
9565 .operations()
9566 .set_start_mode(StartMode::Login)
9567 .expect_err("there is nothing to switch");
9568 assert!(
9569 matches!(error, ServiceError::NotInstalled { .. }),
9570 "{error}"
9571 );
9572 }
9573
9574 #[test]
9579 fn status_reports_the_four_facts_journey_five_asks_for() {
9580 let host = Host::new();
9581 let operations = host.operations();
9582 operations
9583 .install(&host.request(StartMode::Boot))
9584 .expect("an install");
9585 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
9586 .expect("a valid timestamp")
9587 .with_timezone(&Utc);
9588 record_github_contact(&host.paths, at).expect("a heartbeat");
9589
9590 let status = operations.status().expect("a status");
9591 assert_eq!(status.start_mode(), Some(StartMode::Boot));
9592 assert_eq!(
9593 status.binary().map(BinaryPath::recorded),
9594 Some(host.binary.as_path())
9595 );
9596 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
9597 assert_eq!(status.last_github_contact(), Some(at));
9598 assert!(status.is_installed());
9599 assert!(status.is_healthy(), "{status}");
9600
9601 let printed = status.to_string();
9602 for fragment in [
9603 "start mode",
9604 "diagnostic log",
9605 "last GitHub contact",
9606 "binary",
9607 ] {
9608 assert!(printed.contains(fragment), "{printed}");
9609 }
9610 }
9611
9612 #[cfg(unix)]
9624 #[test]
9625 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
9626 use std::os::unix::fs::PermissionsExt as _;
9627
9628 if unsafe { libc::geteuid() } == 0 {
9632 return;
9633 }
9634
9635 let host = Host::new();
9636 let operations = host.operations();
9637 operations
9638 .install(&host.request(StartMode::Boot))
9639 .expect("an install");
9640 assert!(
9641 operations.status().expect("a status").is_healthy(),
9642 "the discriminator: healthy before the record is made unreadable"
9643 );
9644
9645 let record = InstallRecord::path(&host.paths);
9646 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
9647 .expect("the mode is applied");
9648
9649 let status = operations
9650 .status()
9651 .expect("a record this account may not read is reported, not thrown");
9652 assert!(status.is_installed(), "{status}");
9653 assert!(!status.is_healthy(), "{status}");
9654
9655 let printed = status.to_string();
9656 assert!(
9657 printed.contains("this account may not read it"),
9658 "the operator is told which of the two states this is: {printed}"
9659 );
9660 assert!(
9661 !printed.contains("there is no install record"),
9662 "a record that is there and unreadable is not a record that is missing, and the \
9663 missing one's remedy starts with `service uninstall`: {printed}"
9664 );
9665
9666 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
9669 .expect("the mode is restored");
9670 }
9671
9672 #[test]
9673 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
9674 let host = Host::new();
9675 let operations = host.operations();
9676 operations
9677 .install(&host.request(StartMode::Boot))
9678 .expect("an install");
9679
9680 assert!(
9683 operations.status().expect("a status").is_healthy(),
9684 "the freshly installed host must be healthy"
9685 );
9686
9687 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
9688
9689 let status = operations.status().expect("a status");
9690 assert!(!status.is_healthy(), "{status}");
9691 assert!(
9692 status
9693 .problems()
9694 .iter()
9695 .any(|problem| problem.subject == "binary"),
9696 "{status}"
9697 );
9698 assert!(status.to_string().contains("STALE"), "{status}");
9699 }
9700
9701 #[test]
9702 fn status_reports_a_registration_that_would_not_start_at_boot() {
9703 let host = Host::new();
9704 let operations = host.operations();
9705 operations
9706 .install(&host.request(StartMode::Boot))
9707 .expect("an install");
9708 assert!(operations.status().expect("a status").is_healthy());
9709
9710 host.controls.edit("runner-manager", |registration| {
9711 registration.starts_automatically = false;
9712 });
9713
9714 let status = operations.status().expect("a status");
9715 assert!(!status.is_healthy(), "{status}");
9716 assert!(
9717 status
9718 .problems()
9719 .iter()
9720 .any(|problem| problem.detail.contains("after a reboot")),
9721 "{status}"
9722 );
9723 }
9724
9725 #[test]
9726 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
9727 let host = Host::new();
9728 let operations = host.operations();
9729 operations
9730 .install(&host.request(StartMode::Login))
9731 .expect("install and immediate start");
9732
9733 assert!(
9734 operations.status().expect("running status").is_running(),
9735 "install must not wait for the next login trigger"
9736 );
9737 operations.stop().expect("stop the registration");
9738 let status = operations.status().expect("stopped status");
9739 assert!(!status.is_healthy(), "{status}");
9740 assert!(
9741 status
9742 .problems()
9743 .iter()
9744 .any(|problem| problem.subject == "runtime"),
9745 "{status}"
9746 );
9747 let rendered = status.to_string();
9748 assert!(
9749 rendered.contains("runner-manager service install --start-at login"),
9750 "{rendered}"
9751 );
9752 assert!(
9753 !rendered.contains("runner-manager service start"),
9754 "{rendered}"
9755 );
9756 }
9757
9758 #[test]
9759 fn status_reports_a_restart_policy_something_else_edited() {
9760 let host = Host::new();
9761 let operations = host.operations();
9762 operations
9763 .install(&host.request(StartMode::Boot))
9764 .expect("an install");
9765 assert!(operations.status().expect("a status").is_healthy());
9766
9767 host.controls.edit("runner-manager", |registration| {
9768 registration.restart_delay = Some(Duration::from_secs(1));
9769 });
9770
9771 let status = operations.status().expect("a status");
9772 assert!(!status.is_healthy(), "{status}");
9773 assert!(
9774 status
9775 .problems()
9776 .iter()
9777 .any(|problem| problem.subject == "restart policy"),
9778 "{status}"
9779 );
9780 }
9781
9782 #[test]
9783 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
9784 let host = Host::new();
9785 let operations = host.operations();
9786 operations
9787 .install(&host.request(StartMode::Boot))
9788 .expect("an install");
9789 let other = host.binary.with_file_name("someone-elses.exe");
9790 std::fs::write(&other, b"x").expect("writable");
9791
9792 host.controls.edit("runner-manager", |registration| {
9793 registration.command_line = quote_argument(&other.to_string_lossy());
9794 });
9795
9796 let status = operations.status().expect("a status");
9797 assert!(!status.is_healthy(), "{status}");
9798 assert!(
9799 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
9800 "{status}"
9801 );
9802 }
9803
9804 #[test]
9805 fn status_reports_a_record_no_service_manager_knows_about() {
9806 let host = Host::new();
9807 let operations = host.operations();
9808 operations
9809 .install(&host.request(StartMode::Boot))
9810 .expect("an install");
9811 for mode in [StartMode::Boot, StartMode::Login] {
9813 host.controls
9814 .control(mode)
9815 .expect("a control")
9816 .uninstall(&ServiceIdentity::product())
9817 .expect("removed");
9818 }
9819
9820 let status = operations.status().expect("a status");
9821 assert!(!status.is_healthy(), "{status}");
9822 assert!(
9823 status
9824 .problems()
9825 .iter()
9826 .any(|problem| problem.subject == "registration"),
9827 "{status}"
9828 );
9829 }
9830
9831 #[test]
9832 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
9833 let host = Host::new();
9834 let status = host.operations().status().expect("a status");
9835 assert!(!status.is_installed());
9836 assert!(
9837 status.is_healthy(),
9838 "a host that never installed the service has no fault to report: {status}"
9839 );
9840 assert!(status.to_string().contains("installed"), "{status}");
9841 }
9842
9843 #[test]
9844 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
9845 let host = Host::new();
9846 let operations = host.operations();
9847 operations
9848 .install(&host.request(StartMode::Login))
9849 .expect("an install at login");
9850 let status = operations.status().expect("a status");
9851 assert!(
9852 status
9853 .notes()
9854 .iter()
9855 .any(|note| note.contains("does not run until the operator signs in")),
9856 "05-infrastructure.md requires `service status` to say so: {status}"
9857 );
9858 }
9859
9860 #[test]
9865 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
9866 let host = Host::new();
9867 let operations = host.operations();
9868 operations
9869 .install(&host.request(StartMode::Login))
9870 .expect("an install at login");
9871 operations.start().expect("a start");
9872 assert!(operations.status().expect("a status").is_running());
9873 assert!(operations.stop().expect("a stop"));
9874 assert!(!operations.status().expect("a status").is_running());
9875 }
9876
9877 #[test]
9878 fn starting_a_host_with_no_registration_is_refused() {
9879 let host = Host::new();
9880 let error = host.operations().start().expect_err("nothing to start");
9881 assert!(
9882 matches!(error, ServiceError::NotInstalled { .. }),
9883 "{error}"
9884 );
9885 }
9886
9887 #[cfg(windows)]
9903 #[test]
9904 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
9905 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
9906
9907 let root = tempfile::tempdir().expect("a temporary directory");
9908 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
9909 .expect("a rooted machine-scoped store");
9910 store
9911 .store(&secrecy::SecretString::from("a stand-in for the token"))
9912 .expect("the store accepts a value");
9913 let protection = store.protection().expect("the DACL can be read back");
9914
9915 assert!(
9916 protection.description().contains(";;;SY)"),
9917 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
9918 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
9919 protection.description()
9920 );
9921 assert_eq!(
9922 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9923 ServiceAccount::LocalSystem,
9924 "and that is the account this installer registers, which is why SY is what matters"
9925 );
9926 assert!(
9927 !protection.readable_by_other_local_users(),
9928 "the same DACL must still exclude ordinary local users: {}",
9929 protection.description()
9930 );
9931
9932 for rejected in [";;;LS)", ";;;NS)"] {
9936 assert!(
9937 !protection.description().contains(rejected),
9938 "if the store ever admitted {rejected}, the least-privilege analysis in \
9939 docs/service-account.md would need redoing: {}",
9940 protection.description()
9941 );
9942 }
9943 }
9944}