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 LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
170
171pub const DAEMON_STALLED_SUBJECT: &str = "daemon progress";
173
174pub const HOST_UNFIT_SUBJECT: &str = "host fitness";
177
178pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
182
183pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
194
195#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ServiceIdentity {
209 name: String,
210 display_name: String,
211 description: String,
212}
213
214impl ServiceIdentity {
215 #[must_use]
217 pub fn product() -> Self {
218 Self {
219 name: SERVICE_NAME.to_string(),
220 display_name: DISPLAY_NAME.to_string(),
221 description: DESCRIPTION.to_string(),
222 }
223 }
224
225 #[must_use]
232 pub fn fixture(tag: &str) -> Self {
233 let tag: String = tag
234 .chars()
235 .map(|c| {
236 if c.is_ascii_alphanumeric() {
237 c.to_ascii_lowercase()
238 } else {
239 '-'
240 }
241 })
242 .collect();
243 let name = format!("{SERVICE_NAME}-selftest-{tag}");
244 Self {
245 display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
246 description: "Disposable fixture created by runner-manager's own installer tests. \
247 Safe to remove."
248 .to_string(),
249 name,
250 }
251 }
252
253 #[must_use]
258 pub fn is_fixture(&self) -> bool {
259 self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
260 }
261
262 #[must_use]
265 pub fn name(&self) -> &str {
266 &self.name
267 }
268
269 #[must_use]
271 pub fn display_name(&self) -> &str {
272 &self.display_name
273 }
274
275 #[must_use]
277 pub fn description(&self) -> &str {
278 &self.description
279 }
280
281 #[must_use]
287 pub fn launchd_label(&self) -> String {
288 format!(
289 "{}.{}.{}",
290 crate::paths::QUALIFIER,
291 crate::paths::ORGANIZATION,
292 self.name
293 )
294 }
295
296 #[must_use]
298 pub fn systemd_unit(&self) -> String {
299 format!("{}.service", self.name)
300 }
301}
302
303impl fmt::Display for ServiceIdentity {
304 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305 f.write_str(&self.name)
306 }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct RestartPolicy {
324 delay: Duration,
325 reset_after: Duration,
326}
327
328impl RestartPolicy {
329 pub const MIN_DELAY: Duration = Duration::from_secs(1);
336
337 pub const MAX_DELAY: Duration = Duration::from_secs(300);
340
341 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
343
344 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
346
347 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
355 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
356 return Err(ServiceError::RestartDelay {
357 requested_secs: delay.as_secs(),
358 min_secs: Self::MIN_DELAY.as_secs(),
359 max_secs: Self::MAX_DELAY.as_secs(),
360 });
361 }
362 if reset_after <= delay {
363 return Err(ServiceError::RestartResetWindow {
364 reset_secs: reset_after.as_secs(),
365 delay_secs: delay.as_secs(),
366 });
367 }
368 Ok(Self { delay, reset_after })
369 }
370
371 #[must_use]
373 pub const fn delay(&self) -> Duration {
374 self.delay
375 }
376
377 #[must_use]
379 pub const fn reset_after(&self) -> Duration {
380 self.reset_after
381 }
382
383 #[must_use]
400 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
401 match kind {
402 DefinitionKind::WindowsScheduledTask => {
403 let seconds = self.delay.as_secs();
404 let minutes = seconds.div_ceil(60);
405 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
406 }
407 _ => self.delay,
408 }
409 }
410}
411
412impl Default for RestartPolicy {
413 fn default() -> Self {
414 Self {
415 delay: Self::DEFAULT_DELAY,
416 reset_after: Self::DEFAULT_RESET_AFTER,
417 }
418 }
419}
420
421impl fmt::Display for RestartPolicy {
422 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423 write!(
424 f,
425 "restart after {}s, failure count resets after {}s",
426 self.delay.as_secs(),
427 self.reset_after.as_secs()
428 )
429 }
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "snake_case")]
444pub enum ServiceAccount {
445 LocalSystem,
448 Root,
451 InvokingUser,
453}
454
455impl ServiceAccount {
456 #[must_use]
465 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
466 match (kind, mode) {
467 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
468 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
469 (_, StartMode::Boot) => Self::Root,
470 }
471 }
472
473 #[must_use]
476 pub const fn for_start_mode(mode: StartMode) -> Self {
477 Self::for_definition(host_definition_kind(mode), mode)
478 }
479
480 #[must_use]
482 pub const fn as_str(&self) -> &'static str {
483 match self {
484 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
485 Self::Root => "root",
486 Self::InvokingUser => "the invoking user",
487 }
488 }
489
490 #[must_use]
497 pub const fn justification(&self) -> &'static str {
498 match self {
499 Self::LocalSystem => {
500 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
501 NetworkService cannot read it, and widening the DACL to reach them would grant \
502 every service on this host read access to the one credential this product holds"
503 }
504 Self::Root => {
505 "a boot-time registration runs outside every login session: on macOS the System \
506 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
507 machine-scoped store is a 0600 file under /var/lib that only root can open \
508 before a session exists"
509 }
510 Self::InvokingUser => {
511 "a login-mode registration reads the user-scoped store, which is deliberately \
512 readable by exactly one account and needs no elevation at all"
513 }
514 }
515 }
516
517 #[must_use]
519 pub const fn needs_elevation(&self) -> bool {
520 matches!(self, Self::LocalSystem | Self::Root)
521 }
522}
523
524impl fmt::Display for ServiceAccount {
525 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526 f.write_str(self.as_str())
527 }
528}
529
530#[derive(Debug, thiserror::Error)]
541pub enum ServiceError {
542 #[error("cannot install the service while an agent is already running on this host: {source}")]
548 LockHeld {
549 #[source]
551 source: Box<LockError>,
552 },
553
554 #[error(
557 "cannot tell whether an agent is already running on this host, so the service was not \
558 installed: {source}. Fix the reported problem with the state directory and try again."
559 )]
560 LockUnreadable {
561 #[source]
563 source: Box<LockError>,
564 },
565
566 #[error(
574 "this registration would run jobs under the default runner root, and that root could \
575 not be prepared, so nothing was registered: {source}"
576 )]
577 RunnerRoot {
578 #[source]
580 source: Box<RootAccessError>,
581 },
582
583 #[error(
585 "cannot resolve the absolute path of this executable, so there is nothing to register: \
586 {detail}. Run the installer from the installed binary rather than through a shell \
587 function or a wrapper that replaces argv[0]."
588 )]
589 BinaryPath {
590 detail: String,
592 },
593
594 #[error(
596 "{} is not a file, so registering it would create a service that cannot start. Install \
597 the product first, then run `service install` from the installed binary.",
598 path.display()
599 )]
600 BinaryMissing {
601 path: PathBuf,
603 },
604
605 #[error(
607 "a restart delay of {requested_secs}s is outside the supported range \
608 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
609 value, so `service status` would report a delay that is not the one in force."
610 )]
611 RestartDelay {
612 requested_secs: u64,
614 min_secs: u64,
616 max_secs: u64,
618 },
619
620 #[error(
622 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
623 restart delay, so the count would reset between every pair of restarts and no \
624 start limit could ever apply."
625 )]
626 RestartResetWindow {
627 reset_secs: u64,
629 delay_secs: u64,
631 },
632
633 #[error(
639 "{name} is already registered to start at {existing}, and this asks for {requested}. \
640 Changing the start mode moves the registration between two different service \
641 managers, so it is not something an install does by itself: run `service uninstall` \
642 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
643 switches it in place and keeps the registration running throughout."
644 )]
645 AlreadyInstalled {
646 name: String,
648 existing: StartMode,
650 requested: StartMode,
652 },
653
654 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
656 NotInstalled {
657 name: String,
659 operation: &'static str,
661 },
662
663 #[error("cannot {operation} the service record {}: {detail}", path.display())]
665 Record {
666 operation: &'static str,
668 path: PathBuf,
670 detail: String,
672 },
673
674 #[error(
676 "the service record {} was not written by this product, or was written by a version \
677 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
678 neither touches configuration, secrets, or the cache.",
679 path.display()
680 )]
681 RecordUnreadable {
682 path: PathBuf,
684 detail: String,
686 },
687
688 #[error(
702 "the service record {} is there and this account may not read it: {detail}. It was \
703 written by whichever account installed the service -- on a boot-mode host, `sudo \
704 service install`. Running `service install` again rewrites it readable; until then \
705 only what the service manager itself reports is available.",
706 path.display()
707 )]
708 RecordNotPermitted {
709 path: PathBuf,
711 detail: String,
713 },
714
715 #[error("cannot prepare this host's application-data directories: {source}")]
717 Paths {
718 #[source]
720 source: Box<crate::paths::PathsError>,
721 },
722
723 #[error("cannot {operation} {name} through {manager}: {detail}")]
725 Control {
726 operation: &'static str,
728 name: String,
730 manager: &'static str,
732 detail: String,
734 },
735
736 #[error(
738 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
739 Inspect `service status` before retrying."
740 )]
741 Rollback {
742 operation: &'static str,
744 name: String,
746 cause: String,
748 rollback: String,
750 },
751
752 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
754 NeedsElevation {
755 operation: &'static str,
757 name: String,
759 detail: String,
761 remedy: &'static str,
763 },
764}
765
766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
778pub struct ServiceDirectories {
779 pub config: PathBuf,
781 pub state: PathBuf,
783 pub runtime: PathBuf,
785 pub logs: PathBuf,
787}
788
789impl ServiceDirectories {
790 #[must_use]
792 pub fn of(paths: &AppPaths) -> Self {
793 Self {
794 config: paths.config_dir().to_path_buf(),
795 state: paths.state_dir().to_path_buf(),
796 runtime: paths.runtime_dir().to_path_buf(),
797 logs: paths.logs_dir().to_path_buf(),
798 }
799 }
800
801 #[must_use]
803 pub fn all(&self) -> [&Path; 4] {
804 [
805 self.config.as_path(),
806 self.state.as_path(),
807 self.runtime.as_path(),
808 self.logs.as_path(),
809 ]
810 }
811
812 #[must_use]
815 pub fn log_file(&self) -> PathBuf {
816 self.logs.join(LOG_FILE_STEM)
817 }
818}
819
820#[derive(Debug, Clone, PartialEq, Eq)]
832pub struct InstallRequest {
833 start_mode: StartMode,
834 binary: Option<PathBuf>,
835 source_binary: Option<PathBuf>,
836 arguments: Vec<OsString>,
837 restart: RestartPolicy,
838 on_demand: bool,
839}
840
841impl InstallRequest {
842 #[must_use]
844 pub fn new(start_mode: StartMode) -> Self {
845 Self {
846 start_mode,
847 binary: None,
848 source_binary: None,
849 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
850 restart: RestartPolicy::default(),
851 on_demand: false,
852 }
853 }
854
855 #[must_use]
865 pub const fn started_on_demand(mut self) -> Self {
866 self.on_demand = true;
867 self
868 }
869
870 #[must_use]
877 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
878 self.binary = Some(binary.into());
879 self
880 }
881
882 #[must_use]
899 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
900 self.source_binary = Some(source.into());
901 self
902 }
903
904 #[must_use]
906 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
907 where
908 I: IntoIterator<Item = S>,
909 S: Into<OsString>,
910 {
911 self.arguments = arguments.into_iter().map(Into::into).collect();
912 self
913 }
914
915 #[must_use]
918 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
919 self.restart = restart;
920 self
921 }
922
923 #[must_use]
925 pub const fn start_mode(&self) -> StartMode {
926 self.start_mode
927 }
928}
929
930#[derive(Debug, Clone, PartialEq, Eq)]
937pub struct InstallPlan {
938 identity: ServiceIdentity,
939 start_mode: StartMode,
940 binary: PathBuf,
941 source_binary: Option<PathBuf>,
942 arguments: Vec<OsString>,
943 account: ServiceAccount,
944 restart: RestartPolicy,
945 directories: ServiceDirectories,
946 secret_guard: Option<PathBuf>,
947 on_demand: bool,
948}
949
950impl InstallPlan {
951 pub fn resolve(
959 identity: ServiceIdentity,
960 request: &InstallRequest,
961 directories: ServiceDirectories,
962 ) -> Result<Self, ServiceError> {
963 let binary = match &request.binary {
964 Some(named) => absolute(named)?,
965 None => running_executable()?,
966 };
967 if !binary.is_file() {
968 return Err(ServiceError::BinaryMissing { path: binary });
969 }
970 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
975 .ok()
976 .map(|store| store.guard());
977 Ok(Self {
978 identity,
979 start_mode: request.start_mode,
980 binary,
981 source_binary: request.source_binary.clone(),
982 arguments: request.arguments.clone(),
983 account: ServiceAccount::for_start_mode(request.start_mode),
984 restart: request.restart,
985 directories,
986 secret_guard,
987 on_demand: request.on_demand,
988 })
989 }
990
991 #[must_use]
998 pub fn unchecked(
999 identity: ServiceIdentity,
1000 start_mode: StartMode,
1001 binary: impl Into<PathBuf>,
1002 directories: ServiceDirectories,
1003 ) -> Self {
1004 Self {
1005 identity,
1006 start_mode,
1007 binary: binary.into(),
1008 source_binary: None,
1009 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1010 account: ServiceAccount::for_start_mode(start_mode),
1011 restart: RestartPolicy::default(),
1012 directories,
1013 secret_guard: None,
1014 on_demand: false,
1015 }
1016 }
1017
1018 #[must_use]
1021 pub const fn started_on_demand(mut self) -> Self {
1022 self.on_demand = true;
1023 self
1024 }
1025
1026 #[must_use]
1028 pub const fn is_on_demand(&self) -> bool {
1029 self.on_demand
1030 }
1031
1032 #[must_use]
1039 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1040 self.secret_guard = Some(guard.into());
1041 self
1042 }
1043
1044 #[must_use]
1046 pub fn secret_guard(&self) -> Option<&Path> {
1047 self.secret_guard.as_deref()
1048 }
1049
1050 #[must_use]
1052 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1053 self.restart = restart;
1054 self
1055 }
1056
1057 #[must_use]
1059 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1060 where
1061 I: IntoIterator<Item = S>,
1062 S: Into<OsString>,
1063 {
1064 self.arguments = arguments.into_iter().map(Into::into).collect();
1065 self
1066 }
1067
1068 #[must_use]
1070 pub const fn identity(&self) -> &ServiceIdentity {
1071 &self.identity
1072 }
1073
1074 #[must_use]
1076 pub const fn start_mode(&self) -> StartMode {
1077 self.start_mode
1078 }
1079
1080 #[must_use]
1082 pub fn binary(&self) -> &Path {
1083 &self.binary
1084 }
1085
1086 #[must_use]
1092 pub fn source_binary(&self) -> Option<&Path> {
1093 self.source_binary.as_deref()
1094 }
1095
1096 #[must_use]
1098 pub fn arguments(&self) -> &[OsString] {
1099 &self.arguments
1100 }
1101
1102 #[must_use]
1104 pub const fn account(&self) -> &ServiceAccount {
1105 &self.account
1106 }
1107
1108 #[must_use]
1110 pub const fn restart(&self) -> RestartPolicy {
1111 self.restart
1112 }
1113
1114 #[must_use]
1116 pub const fn directories(&self) -> &ServiceDirectories {
1117 &self.directories
1118 }
1119
1120 #[must_use]
1122 pub fn command_line(&self) -> String {
1123 let mut out = quote_argument(&self.binary.to_string_lossy());
1124 for argument in &self.arguments {
1125 out.push(' ');
1126 out.push_str("e_argument(&argument.to_string_lossy()));
1127 }
1128 out
1129 }
1130}
1131
1132fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1140 let reversal = change.revert();
1141 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1142}
1143
1144fn rolled_back<T>(
1157 retained: Option<String>,
1158 rollback: Result<T, ServiceError>,
1159 operation: &'static str,
1160 identity: &ServiceIdentity,
1161 cause: ServiceError,
1162) -> ServiceError {
1163 let left_behind = match (rollback.err(), retained) {
1164 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1165 (Some(rollback), None) => Some(rollback.to_string()),
1166 (None, retained) => retained,
1167 };
1168 match left_behind {
1169 Some(rollback) => ServiceError::Rollback {
1170 operation,
1171 name: identity.name().to_string(),
1172 cause: cause.to_string(),
1173 rollback,
1174 },
1175 None => cause,
1176 }
1177}
1178
1179fn undo_runner_root(
1182 change: &RootAccessChange,
1183 operation: &'static str,
1184 identity: &ServiceIdentity,
1185 cause: ServiceError,
1186) -> ServiceError {
1187 rolled_back(
1188 retained_runner_root(change),
1189 Ok(()),
1190 operation,
1191 identity,
1192 cause,
1193 )
1194}
1195
1196fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1210 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1211 detail: format!("{} could not be made absolute: {error}", path.display()),
1212 })
1213}
1214
1215fn running_executable() -> Result<PathBuf, ServiceError> {
1217 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1218 detail: error.to_string(),
1219 })?;
1220 absolute(&raw)
1221}
1222
1223pub(crate) fn quote_argument(argument: &str) -> String {
1232 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1233 return argument.to_string();
1234 }
1235 let mut out = String::with_capacity(argument.len() + 2);
1236 out.push('"');
1237 let mut backslashes = 0usize;
1238 for c in argument.chars() {
1239 match c {
1240 '\\' => {
1241 backslashes += 1;
1242 out.push('\\');
1243 }
1244 '"' => {
1245 for _ in 0..=backslashes {
1248 out.push('\\');
1249 }
1250 out.push('"');
1251 backslashes = 0;
1252 }
1253 other => {
1254 backslashes = 0;
1255 out.push(other);
1256 }
1257 }
1258 }
1259 for _ in 0..backslashes {
1261 out.push('\\');
1262 }
1263 out.push('"');
1264 out
1265}
1266
1267#[must_use]
1278pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1279 let trimmed = command_line.trim_start();
1280 if trimmed.is_empty() {
1281 return None;
1282 }
1283 let mut out = String::new();
1284 let mut chars = trimmed.chars().peekable();
1285 let quoted = chars.peek() == Some(&'"');
1286 if quoted {
1287 chars.next();
1288 let mut backslashes = 0usize;
1289 for c in chars {
1290 match c {
1291 '\\' => {
1292 backslashes += 1;
1293 }
1294 '"' => {
1295 out.extend(std::iter::repeat_n('\\', backslashes / 2));
1298 if backslashes.is_multiple_of(2) {
1299 break;
1300 }
1301 backslashes = 0;
1302 out.push('"');
1303 }
1304 other => {
1305 out.extend(std::iter::repeat_n('\\', backslashes));
1306 backslashes = 0;
1307 out.push(other);
1308 }
1309 }
1310 }
1311 } else {
1312 for c in chars {
1313 if c == ' ' || c == '\t' {
1314 break;
1315 }
1316 out.push(c);
1317 }
1318 }
1319 if out.is_empty() {
1320 None
1321 } else {
1322 Some(PathBuf::from(out))
1323 }
1324}
1325
1326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1332pub enum DefinitionKind {
1333 WindowsService,
1337 WindowsScheduledTask,
1339 LaunchdPlist,
1341 SystemdUnit,
1343}
1344
1345impl DefinitionKind {
1346 #[must_use]
1348 pub const fn manager(self) -> &'static str {
1349 match self {
1350 Self::WindowsService => "the Windows Service Control Manager",
1351 Self::WindowsScheduledTask => "Windows Task Scheduler",
1352 Self::LaunchdPlist => "launchd",
1353 Self::SystemdUnit => "systemd",
1354 }
1355 }
1356}
1357
1358impl fmt::Display for DefinitionKind {
1359 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1360 f.write_str(self.manager())
1361 }
1362}
1363
1364#[derive(Debug, Clone, PartialEq, Eq)]
1371pub struct ServiceDefinition {
1372 kind: DefinitionKind,
1373 text: String,
1374 install_path: Option<PathBuf>,
1375}
1376
1377impl ServiceDefinition {
1378 #[must_use]
1380 pub const fn kind(&self) -> DefinitionKind {
1381 self.kind
1382 }
1383
1384 #[must_use]
1386 pub fn text(&self) -> &str {
1387 &self.text
1388 }
1389
1390 #[must_use]
1397 pub fn install_path(&self) -> Option<&Path> {
1398 self.install_path.as_deref()
1399 }
1400
1401 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1414 Ok(match host_definition_kind(plan.start_mode()) {
1415 DefinitionKind::WindowsService => Self::windows_service(plan),
1416 DefinitionKind::WindowsScheduledTask => {
1417 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1418 }
1419 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1420 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1421 })
1422 }
1423
1424 #[must_use]
1426 pub fn windows_service(plan: &InstallPlan) -> Self {
1427 Self {
1428 kind: DefinitionKind::WindowsService,
1429 text: windows_service_descriptor(plan),
1430 install_path: None,
1431 }
1432 }
1433
1434 #[must_use]
1436 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1437 Self {
1438 kind: DefinitionKind::WindowsScheduledTask,
1439 text: windows_scheduled_task_xml(plan, principal),
1440 install_path: None,
1441 }
1442 }
1443
1444 #[must_use]
1449 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1450 let file = format!("{}.plist", plan.identity().launchd_label());
1451 let install_path = match plan.start_mode() {
1452 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1453 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1454 };
1455 Self {
1456 kind: DefinitionKind::LaunchdPlist,
1457 text: launchd_plist(plan),
1458 install_path,
1459 }
1460 }
1461
1462 #[must_use]
1470 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1471 Self {
1472 kind,
1473 text: text.into(),
1474 install_path: None,
1475 }
1476 }
1477
1478 #[must_use]
1483 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1484 let file = plan.identity().systemd_unit();
1485 let install_path = match plan.start_mode() {
1486 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1487 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1488 };
1489 Self {
1490 kind: DefinitionKind::SystemdUnit,
1491 text: systemd_unit(plan),
1492 install_path,
1493 }
1494 }
1495}
1496
1497pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1499pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1501pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1503pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1505
1506const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1509
1510pub const START_LIMIT_BURST: u32 = 5;
1518
1519#[must_use]
1540pub fn systemd_unit(plan: &InstallPlan) -> String {
1541 let identity = plan.identity();
1542 let restart = plan.restart();
1543 let directories = plan.directories();
1544
1545 let mut out = String::new();
1546 out.push_str("[Unit]\n");
1547 out.push_str(&format!("Description={}\n", identity.display_name()));
1548 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1549 out.push_str("After=network-online.target\n");
1550 out.push_str("Wants=network-online.target\n");
1551 out.push_str(&format!(
1554 "StartLimitIntervalSec={}\n",
1555 restart.reset_after().as_secs()
1556 ));
1557 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1558
1559 out.push_str("\n[Service]\n");
1560 out.push_str("Type=simple\n");
1561 out.push_str("KillMode=process\n");
1565 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1566 out.push_str(&format!(
1567 "WorkingDirectory={}\n",
1568 directories.state.display()
1569 ));
1570 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1571 out.push_str("Restart=on-failure\n");
1572 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1573
1574 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1575 for directive in SYSTEMD_HARDENING {
1576 out.push_str(directive);
1577 out.push('\n');
1578 }
1579 let mut writable = directories.all().to_vec();
1580 if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1581 writable.push(secret_directory);
1582 }
1583 out.push_str(&format!(
1584 "ReadWritePaths={}\n",
1585 writable
1586 .iter()
1587 .map(|path| quote_argument(&path.to_string_lossy()))
1588 .collect::<Vec<_>>()
1589 .join(" ")
1590 ));
1591
1592 out.push_str("\n[Install]\n");
1593 out.push_str(match plan.start_mode() {
1594 StartMode::Boot => "WantedBy=multi-user.target\n",
1595 StartMode::Login => "WantedBy=default.target\n",
1596 });
1597 out
1598}
1599
1600pub const SYSTEMD_HARDENING: [&str; 13] = [
1607 "NoNewPrivileges=yes",
1608 "CapabilityBoundingSet=",
1609 "AmbientCapabilities=",
1610 "PrivateTmp=yes",
1611 "PrivateDevices=yes",
1612 "ProtectSystem=strict",
1613 "ProtectKernelTunables=yes",
1614 "ProtectKernelModules=yes",
1615 "ProtectControlGroups=yes",
1616 "RestrictNamespaces=yes",
1617 "RestrictRealtime=yes",
1618 "RestrictSUIDSGID=yes",
1619 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1620];
1621
1622pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1646
1647#[must_use]
1654pub fn launchd_plist(plan: &InstallPlan) -> String {
1655 let identity = plan.identity();
1656 let directories = plan.directories();
1657 let mut out = String::new();
1658 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1659 out.push_str(
1660 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1661 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1662 );
1663 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1664 out.push_str(&plist_string("Label", &identity.launchd_label()));
1665
1666 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1667 out.push_str(&format!(
1668 " <string>{}</string>\n",
1669 xml_escape(&plan.binary().to_string_lossy())
1670 ));
1671 for argument in plan.arguments() {
1672 out.push_str(&format!(
1673 " <string>{}</string>\n",
1674 xml_escape(&argument.to_string_lossy())
1675 ));
1676 }
1677 out.push_str(" </array>\n");
1678
1679 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1680 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1681 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1682 out.push_str(" </dict>\n");
1683 out.push_str(&format!(
1684 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1685 plan.restart().delay().as_secs()
1686 ));
1687 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1688 out.push_str(&plist_string(
1689 "WorkingDirectory",
1690 &directories.state.to_string_lossy(),
1691 ));
1692 out.push_str(&plist_string(
1693 "StandardOutPath",
1694 &directories
1695 .logs
1696 .join("runner-manager.launchd.out.log")
1697 .to_string_lossy(),
1698 ));
1699 out.push_str(&plist_string(
1700 "StandardErrorPath",
1701 &directories
1702 .logs
1703 .join("runner-manager.launchd.err.log")
1704 .to_string_lossy(),
1705 ));
1706
1707 match plan.start_mode() {
1708 StartMode::Boot => {
1709 out.push_str(&plist_string(
1712 "UserName",
1713 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1714 .as_str(),
1715 ));
1716 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1718 }
1719 StartMode::Login => {
1720 }
1724 }
1725
1726 out.push_str("</dict>\n</plist>\n");
1727 out
1728}
1729
1730fn plist_string(key: &str, value: &str) -> String {
1732 format!(
1733 " <key>{}</key>\n <string>{}</string>\n",
1734 xml_escape(key),
1735 xml_escape(value)
1736 )
1737}
1738
1739#[derive(Debug, Clone, PartialEq, Eq)]
1747pub struct TaskPrincipal {
1748 user_id: String,
1749}
1750
1751impl TaskPrincipal {
1752 pub fn current() -> Result<Self, ServiceError> {
1760 let user = std::env::var("USERNAME")
1761 .ok()
1762 .filter(|value| !value.trim().is_empty());
1763 let Some(user) = user else {
1764 return Err(ServiceError::Control {
1765 operation: "identify the account for",
1766 name: SERVICE_NAME.to_string(),
1767 manager: "Windows Task Scheduler",
1768 detail: "this session reports no %USERNAME%, so there is no principal to \
1769 register a logon-triggered task for"
1770 .to_string(),
1771 });
1772 };
1773 let domain = std::env::var("USERDOMAIN")
1774 .ok()
1775 .filter(|value| !value.trim().is_empty());
1776 Ok(Self {
1777 user_id: match domain {
1778 Some(domain) => format!("{domain}\\{user}"),
1779 None => user,
1780 },
1781 })
1782 }
1783
1784 #[must_use]
1786 pub fn named(user_id: impl Into<String>) -> Self {
1787 Self {
1788 user_id: user_id.into(),
1789 }
1790 }
1791
1792 #[must_use]
1794 pub fn user_id(&self) -> &str {
1795 &self.user_id
1796 }
1797}
1798
1799#[must_use]
1806pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1807 let identity = plan.identity();
1808 let user = xml_escape(principal.user_id());
1809 let arguments = plan
1810 .arguments()
1811 .iter()
1812 .map(|argument| quote_argument(&argument.to_string_lossy()))
1813 .collect::<Vec<_>>()
1814 .join(" ");
1815 let mut out = String::new();
1816 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1817 out.push_str(
1818 "<Task version=\"1.4\" \
1819 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1820 );
1821 out.push_str(" <RegistrationInfo>\n");
1822 out.push_str(&format!(
1823 " <Description>{}</Description>\n",
1824 xml_escape(identity.description())
1825 ));
1826 out.push_str(&format!(
1827 " <URI>\\{}</URI>\n",
1828 xml_escape(identity.name())
1829 ));
1830 out.push_str(" </RegistrationInfo>\n");
1831
1832 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1833 out.push_str(" <Enabled>true</Enabled>\n");
1834 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1835 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1836
1837 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1838 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1839 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1840 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1841 out.push_str(" </Principal>\n </Principals>\n");
1842
1843 out.push_str(" <Settings>\n");
1844 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1848 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1849 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1850 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1851 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1852 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1853 out.push_str(" <IdleSettings>\n");
1854 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1855 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1856 out.push_str(" </IdleSettings>\n");
1857 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1858 out.push_str(" <Enabled>true</Enabled>\n");
1859 out.push_str(" <Hidden>false</Hidden>\n");
1860 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1861 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1862 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1864 out.push_str(" <Priority>7</Priority>\n");
1865 out.push_str(" <RestartOnFailure>\n");
1866 out.push_str(&format!(
1867 " <Interval>{}</Interval>\n",
1868 iso8601_minutes(
1869 plan.restart()
1870 .effective_delay(DefinitionKind::WindowsScheduledTask)
1871 )
1872 ));
1873 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1874 out.push_str(" </RestartOnFailure>\n");
1875 out.push_str(" </Settings>\n");
1876
1877 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1878 out.push_str(&format!(
1879 " <Command>{}</Command>\n",
1880 xml_escape(&plan.binary().to_string_lossy())
1881 ));
1882 if !arguments.is_empty() {
1883 out.push_str(&format!(
1884 " <Arguments>{}</Arguments>\n",
1885 xml_escape(&arguments)
1886 ));
1887 }
1888 out.push_str(&format!(
1889 " <WorkingDirectory>{}</WorkingDirectory>\n",
1890 xml_escape(&plan.directories().state.to_string_lossy())
1891 ));
1892 out.push_str(" </Exec>\n </Actions>\n");
1893 out.push_str("</Task>\n");
1894 out
1895}
1896
1897fn iso8601_minutes(duration: Duration) -> String {
1900 format!("PT{}M", duration.as_secs() / 60)
1901}
1902
1903pub(crate) fn xml_escape(value: &str) -> String {
1911 let mut out = String::with_capacity(value.len());
1912 for c in value.chars() {
1913 match c {
1914 '&' => out.push_str("&"),
1915 '<' => out.push_str("<"),
1916 '>' => out.push_str(">"),
1917 '"' => out.push_str("""),
1918 '\'' => out.push_str("'"),
1919 other => out.push(other),
1920 }
1921 }
1922 out
1923}
1924
1925fn xml_unescape(value: &str) -> String {
1931 value
1932 .replace("<", "<")
1933 .replace(">", ">")
1934 .replace(""", "\"")
1935 .replace("'", "'")
1936 .replace("&", "&")
1937}
1938
1939#[derive(Debug, Clone, PartialEq, Eq)]
1948pub struct WindowsServiceSpec {
1949 pub name: String,
1951 pub display_name: String,
1953 pub description: String,
1955 pub automatic_start: bool,
1957 pub account: Option<String>,
1960 pub command_line: String,
1962 pub restart: RestartPolicy,
1964}
1965
1966#[must_use]
1968pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1969 WindowsServiceSpec {
1970 name: plan.identity().name().to_string(),
1971 display_name: plan.identity().display_name().to_string(),
1972 description: plan.identity().description().to_string(),
1973 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1978 account: match ServiceAccount::for_definition(
1979 DefinitionKind::WindowsService,
1980 plan.start_mode(),
1981 ) {
1982 ServiceAccount::LocalSystem => None,
1985 other => Some(other.as_str().to_string()),
1986 },
1987 command_line: plan.command_line(),
1988 restart: plan.restart(),
1989 }
1990}
1991
1992#[must_use]
1995fn windows_service_descriptor(plan: &InstallPlan) -> String {
1996 let spec = windows_service_spec(plan);
1997 let mut out = String::new();
1998 out.push_str("[windows-service]\n");
1999 out.push_str(&format!("Name={}\n", spec.name));
2000 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2001 out.push_str(&format!("Description={}\n", spec.description));
2002 out.push_str("ServiceType=OWN_PROCESS\n");
2006 out.push_str(&format!(
2007 "StartType={}\n",
2008 if spec.automatic_start {
2009 "AutoStart"
2010 } else {
2011 "OnDemand"
2012 }
2013 ));
2014 out.push_str("ErrorControl=Normal\n");
2015 out.push_str(&format!(
2016 "Account={}\n",
2017 spec.account
2018 .as_deref()
2019 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2020 ));
2021 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2022 out.push_str(&format!(
2023 "FailureActionRestartDelaySecs={}\n",
2024 spec.restart.delay().as_secs()
2025 ));
2026 out.push_str(&format!(
2027 "FailureActionsResetPeriodSecs={}\n",
2028 spec.restart.reset_after().as_secs()
2029 ));
2030 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2034 out.push_str(&format!(
2035 "ReadWritePaths={}\n",
2036 plan.directories()
2037 .all()
2038 .iter()
2039 .map(|path| quote_argument(&path.to_string_lossy()))
2040 .collect::<Vec<_>>()
2041 .join(" ")
2042 ));
2043 out
2044}
2045
2046#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2052pub enum FindingKind {
2053 Excess,
2056 Shortfall,
2059}
2060
2061impl fmt::Display for FindingKind {
2062 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2063 f.write_str(match self {
2064 Self::Excess => "excess",
2065 Self::Shortfall => "shortfall",
2066 })
2067 }
2068}
2069
2070#[derive(Debug, Clone, PartialEq, Eq)]
2072pub struct PrivilegeFinding {
2073 pub kind: FindingKind,
2075 pub subject: String,
2077 pub detail: String,
2079}
2080
2081impl fmt::Display for PrivilegeFinding {
2082 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2083 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2084 }
2085}
2086
2087#[derive(Debug, Clone, PartialEq, Eq)]
2101pub struct PrivilegeReview {
2102 kind: DefinitionKind,
2103 account: ServiceAccount,
2104 controls: Vec<String>,
2105 findings: Vec<PrivilegeFinding>,
2106}
2107
2108impl PrivilegeReview {
2109 #[must_use]
2111 pub fn is_least_privilege(&self) -> bool {
2112 !self
2113 .findings
2114 .iter()
2115 .any(|finding| finding.kind == FindingKind::Excess)
2116 }
2117
2118 #[must_use]
2120 pub fn findings(&self) -> &[PrivilegeFinding] {
2121 &self.findings
2122 }
2123
2124 #[must_use]
2127 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2128 self.findings
2129 .iter()
2130 .filter(|finding| finding.kind == FindingKind::Excess)
2131 .collect()
2132 }
2133
2134 #[must_use]
2140 pub fn controls(&self) -> &[String] {
2141 &self.controls
2142 }
2143
2144 #[must_use]
2146 pub const fn account(&self) -> &ServiceAccount {
2147 &self.account
2148 }
2149
2150 #[must_use]
2152 pub const fn kind(&self) -> DefinitionKind {
2153 self.kind
2154 }
2155}
2156
2157impl fmt::Display for PrivilegeReview {
2158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2159 writeln!(
2160 f,
2161 "{} runs as {} ({})",
2162 self.kind,
2163 self.account,
2164 self.account.justification()
2165 )?;
2166 for control in &self.controls {
2167 writeln!(f, " confirmed {control}")?;
2168 }
2169 for finding in &self.findings {
2170 writeln!(f, " {finding}")?;
2171 }
2172 if self.is_least_privilege() {
2173 write!(f, " verdict least privilege")
2174 } else {
2175 write!(
2176 f,
2177 " verdict NOT least privilege: {} excess(es)",
2178 self.excesses().len()
2179 )
2180 }
2181 }
2182}
2183
2184#[must_use]
2190pub fn review_least_privilege(
2191 definition: &ServiceDefinition,
2192 plan: &InstallPlan,
2193) -> PrivilegeReview {
2194 let mut controls = Vec::new();
2195 let mut findings = Vec::new();
2196 match definition.kind() {
2197 DefinitionKind::SystemdUnit => {
2198 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2199 }
2200 DefinitionKind::LaunchdPlist => {
2201 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2202 }
2203 DefinitionKind::WindowsScheduledTask => {
2204 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2205 }
2206 DefinitionKind::WindowsService => {
2207 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2208 }
2209 }
2210 PrivilegeReview {
2211 kind: definition.kind(),
2212 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2217 controls,
2218 findings,
2219 }
2220}
2221
2222fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2224 let mut permitted = plan
2225 .directories()
2226 .all()
2227 .iter()
2228 .map(|path| path.to_string_lossy().into_owned())
2229 .collect::<Vec<_>>();
2230 if kind == DefinitionKind::SystemdUnit
2231 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2232 {
2233 permitted.push(secret_directory.to_string_lossy().into_owned());
2234 }
2235 permitted
2236}
2237
2238fn same_path_text(left: &str, right: &str) -> bool {
2245 if cfg!(windows) {
2246 left.eq_ignore_ascii_case(right)
2247 } else {
2248 left == right
2249 }
2250}
2251
2252fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2255 match kind {
2256 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2257 left.eq_ignore_ascii_case(right)
2258 }
2259 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2260 }
2261}
2262
2263fn review_writable_paths(
2265 kind: DefinitionKind,
2266 subject: &str,
2267 listed: &[String],
2268 plan: &InstallPlan,
2269 controls: &mut Vec<String>,
2270 findings: &mut Vec<PrivilegeFinding>,
2271) {
2272 let permitted = permitted_paths(kind, plan);
2273 for entry in listed {
2274 if !permitted
2275 .iter()
2276 .any(|allowed| same_path_for(kind, allowed, entry))
2277 {
2278 findings.push(PrivilegeFinding {
2279 kind: FindingKind::Excess,
2280 subject: subject.to_string(),
2281 detail: format!(
2282 "{entry} is writable but is not one of this registration's required paths"
2283 ),
2284 });
2285 }
2286 }
2287 for allowed in &permitted {
2288 if !listed
2289 .iter()
2290 .any(|entry| same_path_for(kind, allowed, entry))
2291 {
2292 findings.push(PrivilegeFinding {
2293 kind: FindingKind::Shortfall,
2294 subject: subject.to_string(),
2295 detail: format!(
2296 "{allowed} is one of this registration's directories but is not writable, \
2297 so the daemon cannot use it"
2298 ),
2299 });
2300 }
2301 }
2302 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2303 controls.push(format!(
2304 "{subject} names exactly the required application-data and credential paths"
2305 ));
2306 }
2307}
2308
2309fn review_inbound_surface(
2317 text: &str,
2318 markers: &[(&str, &str)],
2319 controls: &mut Vec<String>,
2320 findings: &mut Vec<PrivilegeFinding>,
2321) {
2322 let mut clean = true;
2323 for (marker, detail) in markers {
2324 if text.contains(marker) {
2325 clean = false;
2326 findings.push(PrivilegeFinding {
2327 kind: FindingKind::Excess,
2328 subject: (*marker).to_string(),
2329 detail: (*detail).to_string(),
2330 });
2331 }
2332 }
2333 if clean {
2334 controls.push(
2335 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2336 );
2337 }
2338}
2339
2340fn review_systemd(
2341 text: &str,
2342 plan: &InstallPlan,
2343 controls: &mut Vec<String>,
2344 findings: &mut Vec<PrivilegeFinding>,
2345) {
2346 let directives = ini_directives(text, "Service");
2347 for expected in SYSTEMD_HARDENING {
2348 let (key, value) = expected
2349 .split_once('=')
2350 .expect("every hardening directive is written as key=value");
2351 match directives.get(key) {
2352 Some(actual) if actual == value => controls.push((*expected).to_string()),
2353 Some(actual) => findings.push(PrivilegeFinding {
2354 kind: FindingKind::Excess,
2355 subject: key.to_string(),
2356 detail: format!(
2357 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2358 requirement does not ask for"
2359 ),
2360 }),
2361 None => findings.push(PrivilegeFinding {
2362 kind: FindingKind::Excess,
2363 subject: key.to_string(),
2364 detail: format!(
2365 "is absent, so the unit inherits systemd's default rather than `{value}`"
2366 ),
2367 }),
2368 }
2369 }
2370
2371 match directives.get("ReadWritePaths") {
2372 Some(value) => {
2373 let listed = split_quoted(value);
2374 review_writable_paths(
2375 DefinitionKind::SystemdUnit,
2376 "ReadWritePaths",
2377 &listed,
2378 plan,
2379 controls,
2380 findings,
2381 );
2382 }
2383 None => findings.push(PrivilegeFinding {
2384 kind: FindingKind::Shortfall,
2385 subject: "ReadWritePaths".to_string(),
2386 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2387 .to_string(),
2388 }),
2389 }
2390
2391 if directives.contains_key("PrivateUsers")
2394 && directives.get("PrivateUsers") == Some(&"no".to_string())
2395 {
2396 findings.push(PrivilegeFinding {
2397 kind: FindingKind::Excess,
2398 subject: "PrivateUsers".to_string(),
2399 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2400 .to_string(),
2401 });
2402 }
2403
2404 review_inbound_surface(
2405 text,
2406 &[
2407 (
2408 "ListenStream=",
2409 "asks systemd to open a listening socket for this service, which \
2410 07-security.md rule 2 forbids the product to have",
2411 ),
2412 (
2413 "ListenDatagram=",
2414 "asks systemd to open a listening socket for this service, which \
2415 07-security.md rule 2 forbids the product to have",
2416 ),
2417 ],
2418 controls,
2419 findings,
2420 );
2421}
2422
2423fn review_launchd(
2424 text: &str,
2425 plan: &InstallPlan,
2426 controls: &mut Vec<String>,
2427 findings: &mut Vec<PrivilegeFinding>,
2428) {
2429 match plist_string_value(text, "ProcessType") {
2433 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2434 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2435 }
2436 found => findings.push(PrivilegeFinding {
2437 kind: FindingKind::Shortfall,
2438 subject: "ProcessType".to_string(),
2439 detail: format!(
2440 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2441 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2442 when it next starts with no runner; `service status` says how to restart it",
2443 found.map_or_else(
2444 || "absent (launchd's `Standard`)".to_string(),
2445 |value| format!("`{value}`")
2446 )
2447 ),
2448 }),
2449 }
2450
2451 match plan.start_mode() {
2452 StartMode::Boot => {
2453 if plist_bool_value(text, "SessionCreate") == Some(true) {
2454 findings.push(PrivilegeFinding {
2455 kind: FindingKind::Excess,
2456 subject: "SessionCreate".to_string(),
2457 detail: "asks launchd to create a security session for a job that runs \
2458 outside every login session and has no use for one"
2459 .to_string(),
2460 });
2461 } else {
2462 controls.push("SessionCreate is not requested".to_string());
2463 }
2464 match plist_string_value(text, "UserName").as_deref() {
2465 Some("root") => {
2466 controls.push("UserName=root, stated rather than inherited".to_string())
2467 }
2468 Some(other) => findings.push(PrivilegeFinding {
2469 kind: FindingKind::Shortfall,
2470 subject: "UserName".to_string(),
2471 detail: format!(
2472 "is `{other}`, which cannot unlock the System Keychain: \
2473 /var/db/SystemKey is root-only, so the daemon would start and then \
2474 find no credential"
2475 ),
2476 }),
2477 None => findings.push(PrivilegeFinding {
2478 kind: FindingKind::Shortfall,
2479 subject: "UserName".to_string(),
2480 detail: "is absent, so the account is launchd's implicit default and this \
2481 review cannot confirm it"
2482 .to_string(),
2483 }),
2484 }
2485 }
2486 StartMode::Login => {
2487 if let Some(named) = plist_string_value(text, "UserName") {
2488 findings.push(PrivilegeFinding {
2489 kind: FindingKind::Excess,
2490 subject: "UserName".to_string(),
2491 detail: format!(
2492 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2493 naming an account here asks launchd for a switch a login-mode \
2494 registration has no reason to want"
2495 ),
2496 });
2497 } else {
2498 controls
2499 .push("no UserName: the agent runs as the operator and no other".to_string());
2500 }
2501 }
2502 }
2503
2504 review_inbound_surface(
2505 text,
2506 &[
2507 (
2508 "<key>Sockets</key>",
2509 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2510 forbids the product to have",
2511 ),
2512 (
2513 "<key>MachServices</key>",
2514 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2515 forbids the product to have",
2516 ),
2517 ],
2518 controls,
2519 findings,
2520 );
2521}
2522
2523fn review_scheduled_task(
2524 text: &str,
2525 controls: &mut Vec<String>,
2526 findings: &mut Vec<PrivilegeFinding>,
2527) {
2528 match xml_value(text, "RunLevel").as_deref() {
2529 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2530 Some(other) => findings.push(PrivilegeFinding {
2531 kind: FindingKind::Excess,
2532 subject: "RunLevel".to_string(),
2533 detail: format!(
2534 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2535 an administrator"
2536 ),
2537 }),
2538 None => findings.push(PrivilegeFinding {
2539 kind: FindingKind::Excess,
2540 subject: "RunLevel".to_string(),
2541 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2542 .to_string(),
2543 }),
2544 }
2545
2546 match xml_value(text, "LogonType").as_deref() {
2547 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2548 Some(other) => findings.push(PrivilegeFinding {
2549 kind: FindingKind::Excess,
2550 subject: "LogonType".to_string(),
2551 detail: format!(
2552 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2553 task; an interactive token needs neither"
2554 ),
2555 }),
2556 None => findings.push(PrivilegeFinding {
2557 kind: FindingKind::Shortfall,
2558 subject: "LogonType".to_string(),
2559 detail: "is absent, so this review cannot confirm that no credential is stored"
2560 .to_string(),
2561 }),
2562 }
2563}
2564
2565fn review_windows_service(
2566 text: &str,
2567 plan: &InstallPlan,
2568 controls: &mut Vec<String>,
2569 findings: &mut Vec<PrivilegeFinding>,
2570) {
2571 let directives = ini_directives(text, "windows-service");
2572
2573 match directives.get("ServiceType").map(String::as_str) {
2574 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2575 Some(other) => findings.push(PrivilegeFinding {
2576 kind: FindingKind::Excess,
2577 subject: "ServiceType".to_string(),
2578 detail: format!(
2579 "is `{other}`; an interactive or shared-process service reaches further than a \
2580 daemon that only talks to GitHub over HTTPS"
2581 ),
2582 }),
2583 None => findings.push(PrivilegeFinding {
2584 kind: FindingKind::Shortfall,
2585 subject: "ServiceType".to_string(),
2586 detail: "is absent, so this review cannot confirm the service is not interactive"
2587 .to_string(),
2588 }),
2589 }
2590
2591 match directives.get("Account").map(String::as_str) {
2595 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2596 controls.push(format!(
2597 "Account={account}: the only stock account the machine-scoped store's DACL \
2598 (SY, BA, OW) admits"
2599 ));
2600 }
2601 Some(other) => findings.push(PrivilegeFinding {
2602 kind: FindingKind::Shortfall,
2603 subject: "Account".to_string(),
2604 detail: format!(
2605 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2606 daemon would start and then find no credential. Widening that DACL is not this \
2607 registration's to do: an ACE reaching `{other}` would also reach every other \
2608 service running under it"
2609 ),
2610 }),
2611 None => findings.push(PrivilegeFinding {
2612 kind: FindingKind::Shortfall,
2613 subject: "Account".to_string(),
2614 detail: "is absent, so this review cannot confirm which account was registered"
2615 .to_string(),
2616 }),
2617 }
2618
2619 match directives.get("ReadWritePaths") {
2620 Some(value) => {
2621 let listed = split_quoted(value);
2622 review_writable_paths(
2623 DefinitionKind::WindowsService,
2624 "ReadWritePaths",
2625 &listed,
2626 plan,
2627 controls,
2628 findings,
2629 );
2630 }
2631 None => findings.push(PrivilegeFinding {
2632 kind: FindingKind::Shortfall,
2633 subject: "ReadWritePaths".to_string(),
2634 detail: "is absent, so the directories the service was installed against are not \
2635 recorded"
2636 .to_string(),
2637 }),
2638 }
2639}
2640
2641fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2649 let mut out = BTreeMap::new();
2650 let mut inside = false;
2651 for line in text.lines() {
2652 let line = line.trim();
2653 if line.starts_with('[') && line.ends_with(']') {
2654 inside = &line[1..line.len() - 1] == section;
2655 continue;
2656 }
2657 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2658 continue;
2659 }
2660 if let Some((key, value)) = line.split_once('=') {
2661 out.insert(key.trim().to_string(), value.trim().to_string());
2662 }
2663 }
2664 out
2665}
2666
2667fn split_quoted(value: &str) -> Vec<String> {
2670 let mut out = Vec::new();
2671 let mut rest = value.trim();
2672 while !rest.is_empty() {
2673 if rest.starts_with('"') {
2674 if let Some(parsed) = executable_from_command_line(rest) {
2677 out.push(parsed.to_string_lossy().into_owned());
2678 }
2679 let mut depth = 0usize;
2681 let mut end = rest.len();
2682 for (index, c) in rest.char_indices() {
2683 match c {
2684 '\\' => depth += 1,
2685 '"' => {
2686 if depth.is_multiple_of(2) && index > 0 {
2687 end = index + 1;
2688 break;
2689 }
2690 depth = 0;
2691 }
2692 _ => depth = 0,
2693 }
2694 }
2695 rest = rest[end..].trim_start();
2696 } else {
2697 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2698 out.push(rest[..end].to_string());
2699 rest = rest[end..].trim_start();
2700 }
2701 }
2702 out
2703}
2704
2705pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2717 let open = format!("<{tag}>");
2718 let close = format!("</{tag}>");
2719 let start = text.find(&open)? + open.len();
2720 let end = text[start..].find(&close)? + start;
2721 Some(xml_unescape(text[start..end].trim()))
2722}
2723
2724#[cfg(any(windows, test))]
2730fn windows_login_task_starts_automatically(document: &str) -> bool {
2731 document.contains("<LogonTrigger>")
2732 && xml_value(document, "Enabled").as_deref() != Some("false")
2733}
2734
2735fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2737 let marker = format!("<key>{key}</key>");
2738 let start = text.find(&marker)? + marker.len();
2739 Some(text[start..].trim_start())
2740}
2741
2742pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
2743 let rest = plist_value_after_key(text, key)?;
2744 if !rest.starts_with("<string>") {
2745 return None;
2746 }
2747 xml_value(rest, "string")
2748}
2749
2750fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2751 let rest = plist_value_after_key(text, key)?;
2752 if rest.starts_with("<true/>") {
2753 Some(true)
2754 } else if rest.starts_with("<false/>") {
2755 Some(false)
2756 } else {
2757 None
2758 }
2759}
2760
2761pub const RECORD_SCHEMA_VERSION: u32 = 1;
2772
2773#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2785pub struct InstallRecord {
2786 pub schema_version: u32,
2788 pub service_name: String,
2790 pub manager: String,
2792 pub start_mode: StartMode,
2794 pub account: ServiceAccount,
2796 pub binary: PathBuf,
2798 #[serde(default)]
2806 pub source_binary: Option<PathBuf>,
2807 pub arguments: Vec<String>,
2809 pub restart_delay_secs: u64,
2811 pub restart_reset_secs: u64,
2813 pub log_file: PathBuf,
2815 #[serde(default)]
2823 pub starts_on_demand: bool,
2824 pub definition_path: Option<PathBuf>,
2826 pub installed_at: DateTime<Utc>,
2828 pub installed_by_version: String,
2830 pub directories: ServiceDirectories,
2835}
2836
2837impl InstallRecord {
2838 #[must_use]
2840 pub fn path(paths: &AppPaths) -> PathBuf {
2841 paths.config_dir().join(RECORD_FILE)
2842 }
2843
2844 #[must_use]
2846 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2847 Self {
2848 schema_version: RECORD_SCHEMA_VERSION,
2849 service_name: plan.identity().name().to_string(),
2850 manager: definition.kind().manager().to_string(),
2851 start_mode: plan.start_mode(),
2852 account: plan.account().clone(),
2853 binary: plan.binary().to_path_buf(),
2854 arguments: plan
2855 .arguments()
2856 .iter()
2857 .map(|argument| argument.to_string_lossy().into_owned())
2858 .collect(),
2859 restart_delay_secs: plan.restart().delay().as_secs(),
2860 restart_reset_secs: plan.restart().reset_after().as_secs(),
2861 starts_on_demand: plan.is_on_demand(),
2862 source_binary: plan.source_binary().map(Path::to_path_buf),
2863 log_file: plan.directories().log_file(),
2864 definition_path: definition.install_path().map(Path::to_path_buf),
2865 installed_at: at,
2866 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2867 directories: plan.directories().clone(),
2868 }
2869 }
2870
2871 #[must_use]
2879 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2880 let plan = InstallPlan::unchecked(
2881 identity,
2882 self.start_mode,
2883 self.binary.clone(),
2884 self.directories.clone(),
2885 )
2886 .with_arguments(self.arguments.clone())
2887 .with_restart(self.restart());
2888 let plan = if self.starts_on_demand {
2889 plan.started_on_demand()
2890 } else {
2891 plan
2892 };
2893 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2894 Ok(store) => plan.with_secret_guard(store.guard()),
2895 Err(_) => plan,
2896 }
2897 }
2898
2899 #[must_use]
2903 pub fn restart(&self) -> RestartPolicy {
2904 RestartPolicy::new(
2905 Duration::from_secs(self.restart_delay_secs),
2906 Duration::from_secs(self.restart_reset_secs),
2907 )
2908 .unwrap_or_default()
2909 }
2910
2911 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2922 let path = Self::path(paths);
2923 let text = match std::fs::read_to_string(&path) {
2924 Ok(text) => text,
2925 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2926 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2929 return Err(ServiceError::RecordNotPermitted {
2930 path,
2931 detail: error.to_string(),
2932 });
2933 }
2934 Err(error) => {
2935 return Err(ServiceError::Record {
2936 operation: "read",
2937 path,
2938 detail: error.to_string(),
2939 });
2940 }
2941 };
2942 let record: Self =
2943 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2944 path: path.clone(),
2945 detail: error.to_string(),
2946 })?;
2947 if record.schema_version != RECORD_SCHEMA_VERSION {
2948 return Err(ServiceError::RecordUnreadable {
2949 path,
2950 detail: format!(
2951 "it declares schema version {} and this build reads version {}",
2952 record.schema_version, RECORD_SCHEMA_VERSION
2953 ),
2954 });
2955 }
2956 Ok(Some(record))
2957 }
2958
2959 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2965 use std::io::Write as _;
2966
2967 let path = Self::path(paths);
2968 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2969 operation: "encode",
2970 path: path.clone(),
2971 detail: error.to_string(),
2972 })?;
2973 if let Some(parent) = path.parent() {
2974 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2975 operation: "write",
2976 path: path.clone(),
2977 detail: error.to_string(),
2978 })?;
2979 }
2980 let parent = path.parent().ok_or_else(|| ServiceError::Record {
2981 operation: "write",
2982 path: path.clone(),
2983 detail: "the record path has no parent directory".to_string(),
2984 })?;
2985 let mut temporary =
2986 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2987 operation: "write",
2988 path: path.clone(),
2989 detail: error.to_string(),
2990 })?;
2991 temporary
2992 .write_all(text.as_bytes())
2993 .and_then(|()| temporary.as_file().sync_all())
2994 .map_err(|error| ServiceError::Record {
2995 operation: "write",
2996 path: path.clone(),
2997 detail: error.to_string(),
2998 })?;
2999 #[cfg(unix)]
3015 {
3016 use std::os::unix::fs::PermissionsExt as _;
3017
3018 temporary
3019 .as_file()
3020 .set_permissions(std::fs::Permissions::from_mode(0o644))
3021 .map_err(|error| ServiceError::Record {
3022 operation: "write",
3023 path: path.clone(),
3024 detail: error.to_string(),
3025 })?;
3026 }
3027 temporary
3028 .persist(&path)
3029 .map(|_| ())
3030 .map_err(|error| ServiceError::Record {
3031 operation: "write",
3032 path,
3033 detail: error.error.to_string(),
3034 })
3035 }
3036
3037 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3043 let path = Self::path(paths);
3044 match std::fs::remove_file(&path) {
3045 Ok(()) => Ok(true),
3046 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3047 Err(error) => Err(ServiceError::Record {
3048 operation: "remove",
3049 path,
3050 detail: error.to_string(),
3051 }),
3052 }
3053 }
3054}
3055
3056const CONTACT_SCHEMA_VERSION: u32 = 1;
3062
3063#[derive(Debug, Clone, Serialize, Deserialize)]
3064struct ContactRecord {
3065 schema_version: u32,
3066 last_success: DateTime<Utc>,
3067}
3068
3069pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3090 write_state_record(
3091 &contact_path(paths),
3092 &ContactRecord {
3093 schema_version: CONTACT_SCHEMA_VERSION,
3094 last_success: at,
3095 },
3096 )
3097}
3098
3099pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3111 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3112}
3113
3114#[must_use]
3116pub fn contact_path(paths: &AppPaths) -> PathBuf {
3117 paths.state_dir().join(CONTACT_FILE)
3118}
3119
3120pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3123 let failed = |detail: String| ServiceError::Record {
3124 operation: "write",
3125 path: path.to_path_buf(),
3126 detail,
3127 };
3128 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3129 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3130 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3131 let temporary = path.with_extension("toml.new");
3132 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3133 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3134}
3135
3136pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3139 path: &Path,
3140) -> Result<Option<T>, ServiceError> {
3141 let failed = |detail: String| ServiceError::Record {
3142 operation: "read",
3143 path: path.to_path_buf(),
3144 detail,
3145 };
3146 let text = match std::fs::read_to_string(path) {
3147 Ok(text) => text,
3148 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3149 Err(error) => return Err(failed(error.to_string())),
3150 };
3151 toml::from_str(&text)
3152 .map(Some)
3153 .map_err(|error| failed(error.to_string()))
3154}
3155
3156pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3158 match std::fs::remove_file(path) {
3159 Ok(()) => Ok(()),
3160 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3161 Err(error) => Err(ServiceError::Record {
3162 operation: "remove",
3163 path: path.to_path_buf(),
3164 detail: error.to_string(),
3165 }),
3166 }
3167}
3168
3169#[derive(Debug, Serialize, Deserialize)]
3174#[serde(deny_unknown_fields)]
3175struct CredentialRejectionRecord {
3176 schema_version: u32,
3177 rejected_since: DateTime<Utc>,
3178}
3179
3180pub fn record_github_credential_rejected(
3196 paths: &AppPaths,
3197 at: DateTime<Utc>,
3198) -> Result<(), ServiceError> {
3199 if github_credential_rejected_since(paths)
3200 .ok()
3201 .flatten()
3202 .is_some()
3203 {
3204 return Ok(());
3205 }
3206 write_state_record(
3207 &credential_rejection_path(paths),
3208 &CredentialRejectionRecord {
3209 schema_version: CONTACT_SCHEMA_VERSION,
3210 rejected_since: at,
3211 },
3212 )
3213}
3214
3215pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3221 remove_state_record(&credential_rejection_path(paths))
3222}
3223
3224pub fn github_credential_rejected_since(
3230 paths: &AppPaths,
3231) -> Result<Option<DateTime<Utc>>, ServiceError> {
3232 Ok(
3233 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3234 .map(|record| record.rejected_since),
3235 )
3236}
3237
3238#[must_use]
3240pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3241 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3242}
3243
3244#[derive(Debug, Serialize, Deserialize)]
3245#[serde(deny_unknown_fields)]
3246struct CredentialUnreadableRecord {
3247 schema_version: u32,
3248 unreadable_since: DateTime<Utc>,
3249}
3250
3251pub fn record_credential_unreadable(
3270 paths: &AppPaths,
3271 at: DateTime<Utc>,
3272) -> Result<(), ServiceError> {
3273 if credential_unreadable_since(paths).ok().flatten().is_some() {
3274 return Ok(());
3275 }
3276 write_state_record(
3277 &credential_unreadable_path(paths),
3278 &CredentialUnreadableRecord {
3279 schema_version: CONTACT_SCHEMA_VERSION,
3280 unreadable_since: at,
3281 },
3282 )
3283}
3284
3285pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3291 remove_state_record(&credential_unreadable_path(paths))
3292}
3293
3294pub fn credential_unreadable_since(
3301 paths: &AppPaths,
3302) -> Result<Option<DateTime<Utc>>, ServiceError> {
3303 Ok(
3304 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3305 .map(|record| record.unreadable_since),
3306 )
3307}
3308
3309#[must_use]
3311pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3312 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3313}
3314
3315#[must_use]
3323pub fn sign_in_instruction(mode: StartMode) -> String {
3324 let (command, place) = match mode {
3325 StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
3326 StartMode::Boot => (
3327 "runner-manager auth login --start-at boot",
3328 " from an elevated terminal",
3329 ),
3330 StartMode::Login => (
3331 "runner-manager auth login --start-at login",
3332 MACOS_LOGIN_PLACE,
3333 ),
3334 };
3335 format!("`{command}`{place}")
3336}
3337
3338pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3341 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3342 keychain)"
3343} else {
3344 ""
3345};
3346
3347#[derive(Debug, Clone, PartialEq, Eq)]
3364pub struct DefinitionDrift {
3365 pub path: PathBuf,
3367 pub installed_by_version: String,
3369 pub rendered: String,
3371 pub start_mode: StartMode,
3373 pub manager: DefinitionKind,
3375}
3376
3377fn definition_drift(
3385 controls: &dyn ControlFactory,
3386 identity: &ServiceIdentity,
3387 record: &InstallRecord,
3388) -> Option<DefinitionDrift> {
3389 let compare = match host_definition_kind(record.start_mode) {
3390 DefinitionKind::SystemdUnit => systemd_definition_drift,
3391 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3392 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3393 };
3394 let path = record.definition_path.clone()?;
3395 let installed = controls.installed_definition(&path)?;
3396 compare(identity, record, path, &installed)
3397}
3398
3399fn launchd_definition_drift(
3408 identity: &ServiceIdentity,
3409 record: &InstallRecord,
3410 path: PathBuf,
3411 installed: &str,
3412) -> Option<DefinitionDrift> {
3413 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3414 || DefinitionDrift {
3415 path,
3416 installed_by_version: record.installed_by_version.clone(),
3417 rendered: launchd_plist(&record.plan(identity.clone())),
3418 start_mode: record.start_mode,
3419 manager: DefinitionKind::LaunchdPlist,
3420 },
3421 )
3422}
3423
3424fn systemd_definition_drift(
3425 identity: &ServiceIdentity,
3426 record: &InstallRecord,
3427 path: PathBuf,
3428 installed: &str,
3429) -> Option<DefinitionDrift> {
3430 let rendered = systemd_unit(&record.plan(identity.clone()));
3431 (installed != rendered).then(|| DefinitionDrift {
3432 path,
3433 installed_by_version: record.installed_by_version.clone(),
3434 rendered,
3435 start_mode: record.start_mode,
3436 manager: DefinitionKind::SystemdUnit,
3437 })
3438}
3439
3440const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3445
3446#[derive(Debug, Clone, Serialize, Deserialize)]
3447struct RootRefusalFile {
3448 schema_version: u32,
3449 #[serde(default)]
3451 refusals: BTreeMap<String, RootRefusalEntry>,
3452}
3453
3454#[derive(Debug, Clone, Serialize, Deserialize)]
3455struct RootRefusalEntry {
3456 at: DateTime<Utc>,
3457 kind: String,
3458 root: String,
3459 detail: String,
3460}
3461
3462#[derive(Debug, Clone, PartialEq, Eq)]
3464pub struct RunnerRootRefusal {
3465 pub policy: String,
3467 pub at: DateTime<Utc>,
3469 pub kind: String,
3471 pub root: String,
3473 pub detail: String,
3476}
3477
3478pub fn record_runner_root_refusal(
3512 paths: &AppPaths,
3513 policy: &str,
3514 at: DateTime<Utc>,
3515 kind: &str,
3516 root: &str,
3517 detail: &str,
3518) -> Result<(), ServiceError> {
3519 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3520 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3521 refusals: BTreeMap::new(),
3522 });
3523 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3524 file.refusals.insert(
3525 policy.to_owned(),
3526 RootRefusalEntry {
3527 at,
3528 kind: kind.to_owned(),
3529 root: root.to_owned(),
3530 detail: detail.to_owned(),
3531 },
3532 );
3533 write_refusal_file(paths, &file)
3534}
3535
3536pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3547 let Some(mut file) = read_refusal_file(paths)? else {
3548 return Ok(());
3549 };
3550 if file.refusals.remove(policy).is_none() {
3551 return Ok(());
3552 }
3553 if file.refusals.is_empty() {
3554 let path = root_refusal_path(paths);
3555 return match std::fs::remove_file(&path) {
3556 Ok(()) => Ok(()),
3557 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3558 Err(error) => Err(ServiceError::Record {
3559 operation: "remove",
3560 path,
3561 detail: error.to_string(),
3562 }),
3563 };
3564 }
3565 write_refusal_file(paths, &file)
3566}
3567
3568pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3580 Ok(read_refusal_file(paths)?
3581 .map(|file| {
3582 file.refusals
3583 .into_iter()
3584 .map(|(policy, entry)| RunnerRootRefusal {
3585 policy,
3586 at: entry.at,
3587 kind: entry.kind,
3588 root: entry.root,
3589 detail: entry.detail,
3590 })
3591 .collect()
3592 })
3593 .unwrap_or_default())
3594}
3595
3596fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3597 read_state_record(&root_refusal_path(paths))
3598}
3599
3600fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3601 write_state_record(&root_refusal_path(paths), file)
3602}
3603
3604#[must_use]
3606pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3607 paths.state_dir().join(ROOT_REFUSAL_FILE)
3608}
3609
3610#[derive(Debug, Clone, PartialEq, Eq)]
3621pub enum BinaryPath {
3622 Current {
3624 path: PathBuf,
3626 },
3627 Missing {
3634 recorded: PathBuf,
3636 },
3637 NotExecutable {
3640 recorded: PathBuf,
3642 detail: String,
3644 },
3645 Diverged {
3647 recorded: PathBuf,
3649 registered: PathBuf,
3651 },
3652}
3653
3654impl BinaryPath {
3655 #[must_use]
3657 pub const fn is_error(&self) -> bool {
3658 !matches!(self, Self::Current { .. })
3659 }
3660
3661 #[must_use]
3663 pub fn recorded(&self) -> &Path {
3664 match self {
3665 Self::Current { path } => path,
3666 Self::Missing { recorded }
3667 | Self::NotExecutable { recorded, .. }
3668 | Self::Diverged { recorded, .. } => recorded,
3669 }
3670 }
3671}
3672
3673impl fmt::Display for BinaryPath {
3674 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3675 match self {
3676 Self::Current { path } => write!(f, "{}", path.display()),
3677 Self::Missing { recorded } => write!(
3678 f,
3679 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3680 package manager that moved the binary is the usual cause; an `npm i -g` \
3681 installation moves with the active Node version. Run `service install` again \
3682 from the binary that is now installed.",
3683 recorded.display()
3684 ),
3685 Self::NotExecutable { recorded, detail } => write!(
3686 f,
3687 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3688 from the installed binary.",
3689 recorded.display()
3690 ),
3691 Self::Diverged {
3692 recorded,
3693 registered,
3694 } => write!(
3695 f,
3696 "{} -- STALE: the service manager is registered to start {} instead. Something \
3697 has edited the registration since it was installed. Run `service uninstall` and \
3698 `service install`; neither touches configuration, secrets, or the cache.",
3699 recorded.display(),
3700 registered.display()
3701 ),
3702 }
3703 }
3704}
3705
3706#[must_use]
3714pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3715 match std::fs::metadata(recorded) {
3716 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3717 return BinaryPath::Missing {
3718 recorded: recorded.to_path_buf(),
3719 };
3720 }
3721 Err(error) => {
3722 return BinaryPath::NotExecutable {
3723 recorded: recorded.to_path_buf(),
3724 detail: format!("it cannot be inspected ({error})"),
3725 };
3726 }
3727 Ok(metadata) if !metadata.is_file() => {
3728 return BinaryPath::NotExecutable {
3729 recorded: recorded.to_path_buf(),
3730 detail: "what is there is not a file".to_string(),
3731 };
3732 }
3733 Ok(_) => {}
3734 }
3735 if let Some(registered) = registered
3736 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3737 {
3738 return BinaryPath::Diverged {
3739 recorded: recorded.to_path_buf(),
3740 registered: registered.to_path_buf(),
3741 };
3742 }
3743 BinaryPath::Current {
3744 path: recorded.to_path_buf(),
3745 }
3746}
3747
3748#[derive(Debug, Clone, PartialEq, Eq)]
3754pub struct Registration {
3755 pub manager: DefinitionKind,
3760 pub start_mode: StartMode,
3762 pub command_line: String,
3764 pub account: Option<String>,
3766 pub running: bool,
3768 pub starts_automatically: bool,
3775 pub restart_delay: Option<Duration>,
3784}
3785
3786impl Registration {
3787 #[must_use]
3789 pub fn binary(&self) -> Option<PathBuf> {
3790 executable_from_command_line(&self.command_line)
3791 }
3792}
3793
3794pub trait ServiceControl: fmt::Debug {
3800 fn manager(&self) -> DefinitionKind;
3802
3803 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3809
3810 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3821
3822 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3828
3829 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3836
3837 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3843}
3844
3845pub trait ControlFactory: fmt::Debug + Send + Sync {
3851 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3857
3858 fn installed_definition(&self, _path: &Path) -> Option<String> {
3862 None
3863 }
3864
3865 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
3870 None
3871 }
3872}
3873
3874#[derive(Debug, Clone, Copy, Default)]
3876pub struct HostControls;
3877
3878#[derive(Debug, Clone)]
3884pub struct Installed {
3885 pub plan: InstallPlan,
3887 pub definition: ServiceDefinition,
3889 pub record: InstallRecord,
3891 pub review: PrivilegeReview,
3893 pub runner_root: RootAccessSummary,
3898 pub replaced_existing: bool,
3906}
3907
3908#[derive(Debug, Clone, PartialEq, Eq)]
3910pub struct Uninstalled {
3911 pub removed_registration: bool,
3913 pub removed_record: bool,
3915 pub removed_definition: Option<PathBuf>,
3917 pub preserved: Vec<PathBuf>,
3924}
3925
3926impl fmt::Display for Uninstalled {
3927 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3928 if self.removed_registration {
3929 writeln!(f, "The service registration was removed.")?;
3930 } else {
3931 writeln!(f, "There was no service registration to remove.")?;
3932 }
3933 writeln!(f, "Nothing else was deleted. These are untouched:")?;
3934 for path in &self.preserved {
3935 writeln!(f, " {}", path.display())?;
3936 }
3937 write!(
3938 f,
3939 "The stored GitHub token is untouched too; `auth logout` is what purges it."
3940 )
3941 }
3942}
3943
3944#[derive(Debug, Clone, PartialEq, Eq)]
3946pub struct StartModeChange {
3947 pub from: StartMode,
3949 pub to: StartMode,
3951 pub changed: bool,
3953 pub store_scope: crate::secrets::SecretScope,
3956 pub runner_root: RootAccessSummary,
3962}
3963
3964impl fmt::Display for StartModeChange {
3965 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3966 if !self.changed {
3967 return write!(f, "The service already starts at {}.", self.to);
3968 }
3969 write!(
3970 f,
3971 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3972 if the token was stored under the other scope, run `auth login` again. {}",
3973 self.to, self.from, self.store_scope, self.runner_root
3974 )
3975 }
3976}
3977
3978#[derive(Debug, Clone)]
3986pub struct ServiceOperations {
3987 paths: AppPaths,
3988 identity: ServiceIdentity,
3989 controls: std::sync::Arc<dyn ControlFactory>,
3990 runner_root: Option<LocalAbsolutePath>,
3991}
3992
3993impl ServiceOperations {
3994 #[must_use]
3996 pub fn on_this_host(paths: AppPaths) -> Self {
3997 Self::with_controls(
3998 paths,
3999 ServiceIdentity::product(),
4000 std::sync::Arc::new(HostControls),
4001 )
4002 }
4003
4004 #[must_use]
4012 pub fn with_controls(
4013 paths: AppPaths,
4014 identity: ServiceIdentity,
4015 controls: std::sync::Arc<dyn ControlFactory>,
4016 ) -> Self {
4017 Self {
4018 paths,
4019 identity,
4020 controls,
4021 runner_root: None,
4022 }
4023 }
4024
4025 #[must_use]
4044 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4045 self.runner_root = Some(root);
4046 self
4047 }
4048
4049 #[must_use]
4051 pub const fn paths(&self) -> &AppPaths {
4052 &self.paths
4053 }
4054
4055 #[must_use]
4057 pub const fn identity(&self) -> &ServiceIdentity {
4058 &self.identity
4059 }
4060
4061 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4081 self.paths
4082 .create_all()
4083 .map_err(|source| ServiceError::Paths {
4084 source: Box::new(source),
4085 })?;
4086
4087 let _guard = self.refuse_while_an_agent_runs()?;
4090
4091 let (replacing, previous_was_running) = match self.find_registration()? {
4120 Some((existing, registration)) if existing == request.start_mode() => {
4121 (true, registration.running)
4122 }
4123 Some((existing, _)) => {
4124 return Err(ServiceError::AlreadyInstalled {
4125 name: self.identity.name().to_string(),
4126 existing,
4127 requested: request.start_mode(),
4128 });
4129 }
4130 None => (false, false),
4131 };
4132
4133 let plan = InstallPlan::resolve(
4134 self.identity.clone(),
4135 request,
4136 ServiceDirectories::of(&self.paths),
4137 )?;
4138
4139 let control = self.controls.control(plan.start_mode())?;
4144
4145 let root = self.prepare_runner_root(plan.start_mode())?;
4151
4152 let previous = if replacing {
4157 InstallRecord::read(&self.paths).ok().flatten()
4158 } else {
4159 None
4160 };
4161 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4162 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4163 }
4164
4165 let definition = match control.install(&plan) {
4166 Ok(definition) => definition,
4167 Err(cause) => {
4168 let restored =
4171 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4172 return Err(rolled_back(
4173 retained_runner_root(&root),
4174 restored,
4175 "install",
4176 &self.identity,
4177 cause,
4178 ));
4179 }
4180 };
4181 let review = review_least_privilege(&definition, &plan);
4182 let record = InstallRecord::of(&plan, &definition, Utc::now());
4183 if let Err(cause) = record.write(&self.paths) {
4184 let rollback = control
4185 .uninstall(&self.identity)
4186 .map(|_| ())
4187 .and_then(|()| {
4188 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4189 });
4190 return Err(rolled_back(
4191 retained_runner_root(&root),
4192 rollback,
4193 "install",
4194 &self.identity,
4195 cause,
4196 ));
4197 }
4198
4199 if let Err(cause) = control.start(&self.identity) {
4206 let remove_new = control.uninstall(&self.identity).map(|_| ());
4207 let restore_registration = remove_new.and_then(|()| {
4208 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4209 });
4210 let restore_record = restore_registration.and_then(|()| match &previous {
4211 Some(previous) => previous.write(&self.paths),
4212 None => InstallRecord::remove(&self.paths).map(|_| ()),
4213 });
4214 return Err(rolled_back(
4215 retained_runner_root(&root),
4216 restore_record,
4217 "install and start",
4218 &self.identity,
4219 cause,
4220 ));
4221 }
4222 Ok(Installed {
4223 plan,
4224 definition,
4225 record,
4226 review,
4227 runner_root: root.summary().clone(),
4228 replaced_existing: replacing,
4229 })
4230 }
4231
4232 fn reinstate(
4243 &self,
4244 control: &dyn ServiceControl,
4245 previous: Option<&InstallRecord>,
4246 was_running: bool,
4247 ) -> Result<(), ServiceError> {
4248 let Some(record) = previous else {
4249 return Ok(());
4250 };
4251 control.install(&record.plan(self.identity.clone()))?;
4252 if was_running {
4253 control.start(&self.identity)?;
4254 }
4255 Ok(())
4256 }
4257
4258 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4267 let record = InstallRecord::read(&self.paths).ok().flatten();
4268 let removed_definition = record
4269 .as_ref()
4270 .and_then(|record| record.definition_path.clone());
4271
4272 let mut removed_registration = false;
4273 for mode in [StartMode::Boot, StartMode::Login] {
4277 let control = self.controls.control(mode)?;
4278 if control.uninstall(&self.identity)? {
4279 removed_registration = true;
4280 }
4281 }
4282 let removed_record = InstallRecord::remove(&self.paths)?;
4283 Ok(Uninstalled {
4284 removed_registration,
4285 removed_record,
4286 removed_definition: removed_definition.filter(|_| removed_registration),
4287 preserved: self
4288 .paths
4289 .all()
4290 .iter()
4291 .map(|(_, path)| (*path).to_path_buf())
4292 .collect(),
4293 })
4294 }
4295
4296 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4318 let Some(record) = InstallRecord::read(&self.paths)? else {
4319 return Err(ServiceError::NotInstalled {
4320 name: self.identity.name().to_string(),
4321 operation: "switch the start mode of",
4322 });
4323 };
4324 let from = record.start_mode;
4325 if from == to {
4326 return Ok(StartModeChange {
4330 from,
4331 to,
4332 changed: false,
4333 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4334 runner_root: RootAccessSummary::NotApplicable,
4335 });
4336 }
4337
4338 #[cfg(windows)]
4339 let arguments = {
4340 let mut arguments = record.arguments.clone();
4341 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4342 if to == StartMode::Boot {
4343 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4344 }
4345 arguments
4346 };
4347 #[cfg(not(windows))]
4348 let arguments = record.arguments.clone();
4349 let plan = InstallPlan::unchecked(
4350 self.identity.clone(),
4351 to,
4352 record.binary.clone(),
4353 record.directories.clone(),
4354 )
4355 .with_arguments(arguments)
4356 .with_restart(record.restart());
4357 let plan = if record.starts_on_demand {
4358 plan.started_on_demand()
4359 } else {
4360 plan
4361 };
4362 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4363 Ok(store) => plan.with_secret_guard(store.guard()),
4364 Err(_) => plan,
4365 };
4366
4367 let target = self.controls.control(to)?;
4374 let previous = self.controls.control(from)?;
4379
4380 let root = self.prepare_runner_root(to)?;
4388
4389 let definition = match target.install(&plan) {
4390 Ok(definition) => definition,
4391 Err(cause) => {
4392 return Err(undo_runner_root(
4393 &root,
4394 "switch start mode",
4395 &self.identity,
4396 cause,
4397 ));
4398 }
4399 };
4400 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4401 if let Err(cause) = next_record.write(&self.paths) {
4402 return Err(rolled_back(
4403 retained_runner_root(&root),
4404 target.uninstall(&self.identity),
4405 "switch start mode",
4406 &self.identity,
4407 cause,
4408 ));
4409 }
4410
4411 if let Err(cause) = previous.uninstall(&self.identity) {
4415 let target_rollback = target.uninstall(&self.identity);
4416 let record_rollback = record.write(&self.paths);
4417 return Err(rolled_back(
4418 retained_runner_root(&root),
4419 target_rollback.and(record_rollback),
4420 "switch start mode",
4421 &self.identity,
4422 cause,
4423 ));
4424 }
4425 Ok(StartModeChange {
4426 from,
4427 to,
4428 changed: true,
4429 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4430 runner_root: root.summary().clone(),
4431 })
4432 }
4433
4434 pub fn start(&self) -> Result<(), ServiceError> {
4440 let Some((mode, _)) = self.find_registration()? else {
4441 return Err(ServiceError::NotInstalled {
4442 name: self.identity.name().to_string(),
4443 operation: "start",
4444 });
4445 };
4446 self.controls.control(mode)?.start(&self.identity)
4447 }
4448
4449 pub fn stop(&self) -> Result<bool, ServiceError> {
4455 let Some((mode, _)) = self.find_registration()? else {
4456 return Err(ServiceError::NotInstalled {
4457 name: self.identity.name().to_string(),
4458 operation: "stop",
4459 });
4460 };
4461 self.controls.control(mode)?.stop(&self.identity)
4462 }
4463
4464 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4483 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4484 Ok(record) => (record, None),
4485 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4486 Err(error) => return Err(error),
4487 };
4488 let found = self.find_registration()?;
4489 let last_github_contact = last_github_contact(&self.paths)?;
4490 let drift = record
4491 .as_ref()
4492 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4493 let credential_rejected_since =
4494 github_credential_rejected_since(&self.paths).ok().flatten();
4495 let credential_unreadable_since = credential_unreadable_since(&self.paths).ok().flatten();
4496 let probed = self.controls.unattended_login();
4497 let unattended = record.as_ref().and_then(|record| {
4498 let account = probed
4501 .as_ref()
4502 .map_or_else(String::new, |_| service_account_name(&record.account));
4503 unattended_gap(record.start_mode, &account, probed.as_ref())
4504 });
4505 let now = Utc::now();
4506 let installed = record.is_some() || found.is_some();
4507 Ok(ServiceStatus::compose(
4508 self.identity.clone(),
4509 record,
4510 record_refused.as_ref(),
4511 found.map(|(_, registration)| registration),
4512 last_github_contact,
4513 &self.paths,
4514 )
4515 .with_definition_drift(drift)
4516 .with_credential_rejection(credential_rejected_since)
4517 .with_credential_unreadable(credential_unreadable_since)
4518 .with_launches_blocked(crate::launch_health::launches_blocked(&self.paths, now))
4519 .with_unattended_gap(unattended)
4520 .with_host_unfit(
4521 crate::host_fitness::host_unfit_in_force(&self.paths, now).filter(|_| installed),
4522 )
4523 .with_stall(crate::daemon_heartbeat::liveness(&self.paths, now).stall(last_github_contact)))
4524 }
4525
4526 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4533 Ok(InstallRecord::read(&self.paths)?
4534 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4535 }
4536
4537 #[cfg(target_os = "macos")]
4559 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4560 let root = unsafe { libc::getuid() } == 0;
4566 if root != (drift.start_mode == StartMode::Boot) {
4567 return Err(ServiceError::Control {
4568 operation: "rewrite",
4569 name: self.identity.name().to_string(),
4570 manager: "launchd",
4571 detail: format!(
4572 "the install record says --start-at {}, but this daemon runs as {}; the plist \
4573 is left as it is",
4574 drift.start_mode,
4575 if root { "root" } else { "the operator" }
4576 ),
4577 });
4578 }
4579 replace_definition(self.identity.name(), drift)?;
4580 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4584 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4585 let _ = record.write(&self.paths);
4586 }
4587 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4588 }
4589
4590 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4600 #[cfg(not(windows))]
4601 {
4602 let _ = (mode, &self.runner_root);
4605 Ok(RootAccessChange::not_applicable())
4606 }
4607 #[cfg(windows)]
4608 {
4609 let wrap = |source| ServiceError::RunnerRoot {
4610 source: Box::new(source),
4611 };
4612 if let Some(root) = self
4630 .runner_root
4631 .as_ref()
4632 .filter(|_| self.identity.is_fixture() || cfg!(test))
4633 {
4634 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4635 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4636 .map_err(wrap);
4637 }
4638
4639 let admission = match ServiceAccount::for_start_mode(mode) {
4640 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4643 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4647 RootAdmission::of_this_account().map_err(wrap)?
4648 }
4649 };
4650 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4651 }
4652 }
4653
4654 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4656 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4657 |source| match source {
4658 held @ LockError::Held { .. } => ServiceError::LockHeld {
4659 source: Box::new(held),
4660 },
4661 other => ServiceError::LockUnreadable {
4662 source: Box::new(other),
4663 },
4664 },
4665 )
4666 }
4667
4668 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4670 for mode in [StartMode::Boot, StartMode::Login] {
4671 let control = self.controls.control(mode)?;
4672 if let Some(registration) = control.query(&self.identity)? {
4673 return Ok(Some((registration.start_mode, registration)));
4674 }
4675 }
4676 Ok(None)
4677 }
4678}
4679
4680#[derive(Debug, Clone, PartialEq, Eq)]
4686pub struct StatusProblem {
4687 pub subject: &'static str,
4689 pub detail: String,
4691}
4692
4693impl fmt::Display for StatusProblem {
4694 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4695 write!(f, "{}: {}", self.subject, self.detail)
4696 }
4697}
4698
4699#[derive(Debug, Clone)]
4708pub struct ServiceStatus {
4709 identity: ServiceIdentity,
4710 record: Option<InstallRecord>,
4711 registration: Option<Registration>,
4712 binary: Option<BinaryPath>,
4713 log_file: PathBuf,
4714 store: Option<crate::secrets::ActiveStore>,
4715 last_github_contact: Option<DateTime<Utc>>,
4716 runner_root: Option<(PathBuf, RootAccessReport)>,
4717 credential_rejected_since: Option<DateTime<Utc>>,
4718 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
4719 unattended_gap: Option<UnattendedGap>,
4720 problems: Vec<StatusProblem>,
4721 notes: Vec<String>,
4722}
4723
4724#[derive(Debug, Clone, PartialEq, Eq)]
4727pub struct UnattendedGap {
4728 pub reason: String,
4730 pub remedy: String,
4732}
4733
4734#[must_use]
4737pub fn service_account_name(account: &ServiceAccount) -> String {
4738 match account {
4739 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
4741 .ok()
4742 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
4743 .or_else(crate::unattended_login::current_account)
4744 .unwrap_or_else(|| "the operator".to_owned()),
4745 other => other.as_str().to_owned(),
4746 }
4747}
4748
4749#[must_use]
4758pub fn unattended_gap(
4759 mode: StartMode,
4760 account: &str,
4761 found: Option<&crate::unattended_login::UnattendedLogin>,
4762) -> Option<UnattendedGap> {
4763 let Some(found) = found else {
4764 return (mode == StartMode::Login).then(|| UnattendedGap {
4765 reason: "This registration starts at login, so the agent does not run until the \
4766 operator signs in; this host does not resume work after an unattended \
4767 reboot."
4768 .to_owned(),
4769 remedy: "For machine-on operation, move the service and credential to `--start-at \
4770 boot` from an elevated terminal."
4771 .to_owned(),
4772 });
4773 };
4774 let resume = crate::unattended_login::resume(found, mode, account);
4775 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
4776 return None;
4779 }
4780 Some(UnattendedGap {
4781 reason: resume.explain(account)?,
4782 remedy: resume.remedy().unwrap_or_else(|| {
4783 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
4784 FileVault can be read."
4785 .to_owned()
4786 }),
4787 })
4788}
4789
4790impl ServiceStatus {
4791 fn compose(
4796 identity: ServiceIdentity,
4797 record: Option<InstallRecord>,
4798 record_refused: Option<&ServiceError>,
4799 registration: Option<Registration>,
4800 last_github_contact: Option<DateTime<Utc>>,
4801 paths: &AppPaths,
4802 ) -> Self {
4803 let mut problems = Vec::new();
4804 let mut notes = Vec::new();
4805
4806 if let Some(refusal) = record_refused {
4807 problems.push(StatusProblem {
4812 subject: "install record",
4813 detail: refusal.to_string(),
4814 });
4815 }
4816
4817 let log_file = record.as_ref().map_or_else(
4818 || ServiceDirectories::of(paths).log_file(),
4819 |record| record.log_file.clone(),
4820 );
4821
4822 let binary = record.as_ref().map(|record| {
4823 inspect_binary(
4824 &record.binary,
4825 registration
4826 .as_ref()
4827 .and_then(Registration::binary)
4828 .as_deref(),
4829 )
4830 });
4831 if let Some(state) = &binary
4832 && state.is_error()
4833 {
4834 problems.push(StatusProblem {
4835 subject: "binary",
4836 detail: state.to_string(),
4837 });
4838 }
4839
4840 match (&record, ®istration) {
4841 (Some(_), None) => problems.push(StatusProblem {
4842 subject: "registration",
4843 detail: "this host has a service record but no service manager knows the \
4844 registration. Run `service install` again; it deletes nothing."
4845 .to_string(),
4846 }),
4847 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4852 subject: "record",
4853 detail: format!(
4854 "{} holds a registration for this service but there is no install record, so \
4855 the path it was installed from and the directories it was installed against \
4856 are unknown. Run `service uninstall` and `service install`.",
4857 found.manager
4858 ),
4859 }),
4860 (None, Some(_)) => {}
4861 (Some(record), Some(found)) => {
4862 if record.start_mode != found.start_mode {
4863 problems.push(StatusProblem {
4864 subject: "start mode",
4865 detail: format!(
4866 "the record says {} and {} holds a {} registration. Switch the start \
4867 mode again to make them agree.",
4868 record.start_mode, found.manager, found.start_mode
4869 ),
4870 });
4871 }
4872 if record.start_mode == StartMode::Boot && !found.starts_automatically {
4873 problems.push(StatusProblem {
4874 subject: "start mode",
4875 detail: format!(
4876 "{} holds the registration but will not start it by itself, so this \
4877 host does not resume work after a reboot.",
4878 found.manager
4879 ),
4880 });
4881 }
4882 if let Some(actual) = found.restart_delay {
4883 let expected = record.restart().effective_delay(found.manager);
4884 if actual != expected {
4885 problems.push(StatusProblem {
4886 subject: "restart policy",
4887 detail: format!(
4888 "the record says the service restarts after {}s and {} reports \
4889 {}s. Something has edited the registration since it was \
4890 installed.",
4891 expected.as_secs(),
4892 found.manager,
4893 actual.as_secs()
4894 ),
4895 });
4896 } else if expected != record.restart().delay() {
4897 notes.push(format!(
4902 "{} expresses the restart delay in whole minutes, so the {}s asked \
4903 for is enforced as {}s. The service therefore never restarts faster \
4904 than the configured bound.",
4905 found.manager,
4906 record.restart().delay().as_secs(),
4907 expected.as_secs()
4908 ));
4909 }
4910 }
4911 if found.starts_automatically && !found.running {
4912 problems.push(StatusProblem {
4913 subject: "runtime",
4914 detail: format!(
4915 "{} holds an automatic registration, but the daemon is stopped. Run \
4916 `runner-manager service install --start-at {}`; if the service \
4917 manager denies replacement, retry from an elevated terminal. If it \
4918 stops again, inspect {}.",
4919 found.manager,
4920 found.start_mode,
4921 log_file.display()
4922 ),
4923 });
4924 }
4925 }
4926 (None, None) => {}
4927 }
4928
4929 let store = record.as_ref().and_then(|record| {
4934 let registered_mode = registration
4935 .as_ref()
4936 .map_or(record.start_mode, |found| found.start_mode);
4937 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4938 .ok()
4939 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4940 });
4941 if let Some(store) = &store
4942 && !store.agrees_with_start_mode()
4943 {
4944 problems.push(StatusProblem {
4945 subject: "secret store",
4946 detail: format!(
4947 "{store}. Run `auth login` again so the token is stored where the registered \
4948 start mode can read it."
4949 ),
4950 });
4951 }
4952
4953 if last_github_contact.is_none() {
4954 notes.push(
4955 "GitHub has not been reached successfully since this host's state directory was \
4956 created."
4957 .to_string(),
4958 );
4959 }
4960
4961 let runner_root = crate::runner_root::default_runner_root(paths)
4968 .ok()
4969 .map(|root| {
4970 let path = root.as_path().to_path_buf();
4971 let report = crate::runner_root_access::report(&path);
4972 (path, report)
4973 });
4974 if let Some((
4984 path,
4985 RootAccessReport::Present {
4986 broad_write: true, ..
4987 },
4988 )) = &runner_root
4989 {
4990 notes.push(format!(
4991 "the platform default runner root {} can be written by ordinary local users, so \
4992 it is not a safe place to run jobs. `service install` refuses it rather than \
4993 tightening it, because the contents of a directory anybody could write cannot \
4994 be trusted: remove or empty it, or choose another root with `runner-manager \
4995 host set-runtime-root --path <PATH>`.",
4996 path.display()
4997 ));
4998 }
4999
5000 match runner_root_refusals(paths) {
5023 Ok(refusals) => {
5024 for refusal in refusals {
5025 notes.push(format!(
5026 "policy {} started no runner: its runner root {} refused the launch \
5027 ({}), last at {}. {} This clears when that policy next places a \
5028 runner.",
5029 refusal.policy,
5030 refusal.root,
5031 refusal.kind,
5032 refusal.at.to_rfc3339(),
5033 refusal.detail,
5034 ));
5035 }
5036 }
5037 Err(error) => notes.push(format!(
5038 "whether the agent could use its runner roots could not be read: {error}"
5039 )),
5040 }
5041
5042 Self {
5043 identity,
5044 record,
5045 registration,
5046 binary,
5047 log_file,
5048 store,
5049 last_github_contact,
5050 runner_root,
5051 credential_rejected_since: None,
5052 launches_blocked: None,
5053 unattended_gap: None,
5054 problems,
5055 notes,
5056 }
5057 }
5058
5059 #[must_use]
5061 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
5062 if let Some(drift) = drift {
5063 if drift.manager == DefinitionKind::LaunchdPlist {
5064 let restart = match drift.start_mode {
5068 StartMode::Boot => format!(
5069 "sudo launchctl kickstart -k system/{}",
5070 self.identity.launchd_label()
5071 ),
5072 StartMode::Login => format!(
5073 "launchctl kickstart -k gui/$(id -u)/{}",
5074 self.identity.launchd_label()
5075 ),
5076 };
5077 self.problems.push(StatusProblem {
5078 subject: "definition",
5079 detail: format!(
5080 "{} was written by runner-manager {} with a `ProcessType` other than \
5081 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
5082 daemon starts, so their jobs run below normal priority. The daemon \
5083 rewrites it and reloads itself when it starts with no runner, which it \
5084 does after every upgrade; to do it now, restart the service while no \
5085 job is running: `{restart}`. Configuration and credentials are kept.",
5086 drift.path.display(),
5087 drift.installed_by_version,
5088 ),
5089 });
5090 return self;
5091 }
5092 let repair = match drift.start_mode {
5093 StartMode::Boot => {
5094 "`sudo systemctl stop runner-manager && sudo runner-manager service install \
5095 --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
5096 --distribution <name>` from Windows)"
5097 }
5098 StartMode::Login => {
5099 "`systemctl --user stop runner-manager && runner-manager service install \
5100 --start-at login`"
5101 }
5102 };
5103 self.problems.push(StatusProblem {
5104 subject: "definition",
5105 detail: format!(
5106 "{} was written by runner-manager {} and differs from what {} renders, so \
5107 the daemon runs under outdated settings (for example a credential frozen \
5108 at service start). Re-register it with {repair}; configuration and \
5109 credentials are kept.",
5110 drift.path.display(),
5111 drift.installed_by_version,
5112 env!("CARGO_PKG_VERSION"),
5113 ),
5114 });
5115 }
5116 self
5117 }
5118
5119 #[must_use]
5121 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
5122 if let Some(since) = since {
5123 self.credential_rejected_since = Some(since);
5124 self.problems.push(StatusProblem {
5125 subject: "github credential",
5126 detail: format!(
5127 "GitHub has rejected the service's credential since {}, so the service \
5128 cannot see queued jobs or start runners. Run `runner-manager auth login` \
5129 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
5130 from Windows).",
5131 since.to_rfc3339()
5132 ),
5133 });
5134 }
5135 self
5136 }
5137
5138 #[must_use]
5140 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
5141 self.credential_rejected_since
5142 }
5143
5144 #[must_use]
5147 pub fn with_launches_blocked(
5148 mut self,
5149 blocked: Option<crate::launch_health::LaunchesBlocked>,
5150 ) -> Self {
5151 if let Some(blocked) = &blocked {
5152 self.problems.push(StatusProblem {
5153 subject: LAUNCHES_BLOCKED_SUBJECT,
5154 detail: format!(
5155 "the service has started no runner {blocked}. Fix: {}.",
5156 blocked.remedy
5157 ),
5158 });
5159 }
5160 self.launches_blocked = blocked;
5161 self
5162 }
5163
5164 #[must_use]
5166 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
5167 self.launches_blocked.as_ref()
5168 }
5169
5170 #[must_use]
5173 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
5174 if let Some(since) = since {
5175 let mode = self.start_mode().unwrap_or_default();
5176 self.problems.push(StatusProblem {
5177 subject: CREDENTIAL_UNREADABLE_SUBJECT,
5178 detail: format!(
5179 "the service has been unable to read its stored GitHub credential since {}, \
5180 so it starts no runner; the diagnostic log has the store's own answer.{} \
5181 Sign in again: {}.",
5182 since.to_rfc3339(),
5183 if cfg!(target_os = "macos") {
5184 " That is what a keychain item written by a different build looks like."
5185 } else {
5186 ""
5187 },
5188 sign_in_instruction(mode)
5189 ),
5190 });
5191 }
5192 self
5193 }
5194
5195 #[must_use]
5203 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
5204 self.runner_root
5205 .as_ref()
5206 .map(|(path, report)| (path.as_path(), report))
5207 }
5208
5209 #[must_use]
5211 pub const fn is_installed(&self) -> bool {
5212 self.registration.is_some() || self.record.is_some()
5213 }
5214
5215 #[must_use]
5217 pub fn is_running(&self) -> bool {
5218 self.registration
5219 .as_ref()
5220 .is_some_and(|registration| registration.running)
5221 }
5222
5223 #[must_use]
5225 pub fn is_healthy(&self) -> bool {
5226 self.problems.is_empty()
5227 }
5228
5229 #[must_use]
5231 pub fn problems(&self) -> &[StatusProblem] {
5232 &self.problems
5233 }
5234
5235 #[must_use]
5238 pub fn notes(&self) -> &[String] {
5239 &self.notes
5240 }
5241
5242 #[must_use]
5247 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
5248 if let Some(unfit) = unfit {
5249 self.problems.push(StatusProblem {
5250 subject: HOST_UNFIT_SUBJECT,
5251 detail: format!(
5252 "the service has started no runner since {}, because a required host check \
5253 fails from its own session: {}",
5254 unfit.since.to_rfc3339(),
5255 unfit.describe()
5256 ),
5257 });
5258 }
5259 self
5260 }
5261
5262 #[must_use]
5266 pub fn with_stall(mut self, stall: Option<String>) -> Self {
5267 if let Some(detail) = stall {
5268 self.problems.push(StatusProblem {
5269 subject: DAEMON_STALLED_SUBJECT,
5270 detail,
5271 });
5272 }
5273 self
5274 }
5275
5276 #[must_use]
5279 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
5280 if let Some(gap) = &gap {
5281 self.notes.push(gap.reason.clone());
5282 }
5283 self.unattended_gap = gap;
5284 self
5285 }
5286
5287 #[must_use]
5290 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
5291 self.unattended_gap.as_ref()
5292 }
5293
5294 #[must_use]
5296 pub fn start_mode(&self) -> Option<StartMode> {
5297 self.record.as_ref().map(|record| record.start_mode)
5298 }
5299
5300 #[must_use]
5303 pub const fn binary(&self) -> Option<&BinaryPath> {
5304 self.binary.as_ref()
5305 }
5306
5307 #[must_use]
5309 pub fn log_file(&self) -> &Path {
5310 &self.log_file
5311 }
5312
5313 #[must_use]
5315 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
5316 self.last_github_contact
5317 }
5318
5319 #[must_use]
5322 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
5323 self.store.as_ref()
5324 }
5325
5326 #[must_use]
5328 pub const fn record(&self) -> Option<&InstallRecord> {
5329 self.record.as_ref()
5330 }
5331
5332 #[must_use]
5334 pub const fn registration(&self) -> Option<&Registration> {
5335 self.registration.as_ref()
5336 }
5337}
5338
5339impl fmt::Display for ServiceStatus {
5340 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5341 writeln!(f, "Service: {}", self.identity)?;
5342 match (&self.record, &self.registration) {
5343 (None, None) => {
5344 writeln!(
5345 f,
5346 " installed no. `service install` registers `{} {}`.",
5347 SERVICE_NAME,
5348 DAEMON_ARGUMENTS.join(" ")
5349 )?;
5350 }
5351 _ => {
5352 let manager = self
5353 .registration
5354 .as_ref()
5355 .map(|registration| registration.manager.manager());
5356 writeln!(
5357 f,
5358 " installed {}",
5359 manager.unwrap_or("yes, but no service manager knows it")
5360 )?;
5361 writeln!(
5362 f,
5363 " state {}",
5364 if self.is_running() {
5365 "running"
5366 } else {
5367 "not running"
5368 }
5369 )?;
5370 }
5371 }
5372 if let Some(record) = &self.record {
5373 writeln!(f, " start mode {}", record.start_mode)?;
5374 writeln!(f, " account {}", record.account)?;
5375 writeln!(f, " restart on failure {}", record.restart())?;
5376 writeln!(
5377 f,
5378 " arguments {}",
5379 record.arguments.join(" ")
5380 )?;
5381 }
5382 if let Some(binary) = &self.binary {
5383 writeln!(f, " binary {binary}")?;
5384 }
5385 writeln!(f, " diagnostic log {}", self.log_file.display())?;
5386 if let Some((path, report)) = &self.runner_root {
5387 writeln!(f, " default runner root {}", path.display())?;
5394 if *report != RootAccessReport::NotApplicable {
5395 writeln!(f, " default root access {report}")?;
5396 }
5397 }
5398 if let Some(store) = &self.store {
5399 writeln!(f, " secret store {store}")?;
5400 }
5401 writeln!(
5402 f,
5403 " last GitHub contact {}",
5404 match self.last_github_contact {
5405 Some(at) => at.to_rfc3339(),
5406 None => "never".to_string(),
5407 }
5408 )?;
5409 for note in &self.notes {
5410 writeln!(f, " note {note}")?;
5411 }
5412 for problem in &self.problems {
5413 writeln!(f, " ERROR {problem}")?;
5414 }
5415 write!(
5416 f,
5417 " verdict {}",
5418 if self.is_healthy() {
5419 "healthy"
5420 } else {
5421 "NOT healthy"
5422 }
5423 )
5424 }
5425}
5426
5427#[derive(Debug, Clone, Default)]
5444pub struct RecordingControls {
5445 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5446}
5447
5448#[derive(Debug, Default)]
5449struct RecordingState {
5450 registrations: BTreeMap<(StartMode, String), Registration>,
5451 definitions: BTreeMap<String, ServiceDefinition>,
5452 calls: Vec<String>,
5453 #[cfg(test)]
5454 install_failures: BTreeMap<StartMode, String>,
5455 #[cfg(test)]
5456 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5457}
5458
5459#[cfg(test)]
5460#[derive(Debug, Clone)]
5461enum TestInstallSideEffect {
5462 HideDirectory { directory: PathBuf, hidden: PathBuf },
5463}
5464
5465impl RecordingControls {
5466 #[must_use]
5468 pub fn new() -> Self {
5469 Self::default()
5470 }
5471
5472 #[must_use]
5475 pub fn calls(&self) -> Vec<String> {
5476 self.state.lock().expect("not poisoned").calls.clone()
5477 }
5478
5479 #[must_use]
5481 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5482 self.state
5483 .lock()
5484 .expect("not poisoned")
5485 .registrations
5486 .iter()
5487 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5488 .collect()
5489 }
5490
5491 #[must_use]
5493 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5494 self.state
5495 .lock()
5496 .expect("not poisoned")
5497 .definitions
5498 .get(name)
5499 .cloned()
5500 }
5501
5502 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5512 let mut state = self.state.lock().expect("not poisoned");
5513 if let Some((_, registration)) = state
5514 .registrations
5515 .iter_mut()
5516 .find(|((_, held), _)| held == name)
5517 {
5518 edit(registration);
5519 }
5520 }
5521
5522 #[cfg(test)]
5523 fn fail_next_install(&self, mode: StartMode, detail: &str) {
5524 self.state
5525 .lock()
5526 .expect("not poisoned")
5527 .install_failures
5528 .insert(mode, detail.to_string());
5529 }
5530
5531 #[cfg(test)]
5532 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5533 self.state
5534 .lock()
5535 .expect("not poisoned")
5536 .after_install
5537 .insert(
5538 mode,
5539 TestInstallSideEffect::HideDirectory { directory, hidden },
5540 );
5541 }
5542}
5543
5544impl ControlFactory for RecordingControls {
5545 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5546 Ok(Box::new(RecordingControl {
5547 mode,
5548 state: std::sync::Arc::clone(&self.state),
5549 }))
5550 }
5551}
5552
5553#[derive(Debug)]
5554struct RecordingControl {
5555 mode: StartMode,
5556 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5557}
5558
5559impl RecordingControl {
5560 fn note(&self, operation: &str, name: &str) {
5561 self.state
5562 .lock()
5563 .expect("not poisoned")
5564 .calls
5565 .push(format!("{operation} {name} ({})", self.mode));
5566 }
5567}
5568
5569impl ServiceControl for RecordingControl {
5570 fn manager(&self) -> DefinitionKind {
5571 host_definition_kind(self.mode)
5575 }
5576
5577 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5578 self.note("install", plan.identity().name());
5579 #[cfg(test)]
5580 if let Some(detail) = self
5581 .state
5582 .lock()
5583 .expect("not poisoned")
5584 .install_failures
5585 .remove(&self.mode)
5586 {
5587 return Err(ServiceError::Control {
5588 operation: "install",
5589 name: plan.identity().name().to_string(),
5590 manager: "recording control",
5591 detail,
5592 });
5593 }
5594 let definition = ServiceDefinition::for_host(plan)?;
5599 let mut state = self.state.lock().expect("not poisoned");
5600 state.registrations.insert(
5601 (self.mode, plan.identity().name().to_string()),
5602 Registration {
5603 manager: host_definition_kind(self.mode),
5604 start_mode: self.mode,
5605 command_line: plan.command_line(),
5606 account: Some(plan.account().as_str().to_string()),
5607 running: false,
5608 starts_automatically: true,
5609 restart_delay: Some(plan.restart().delay()),
5610 },
5611 );
5612 state
5613 .definitions
5614 .insert(plan.identity().name().to_string(), definition.clone());
5615 #[cfg(test)]
5616 let side_effect = state.after_install.remove(&self.mode);
5617 drop(state);
5618 #[cfg(test)]
5619 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5620 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5621 std::fs::write(&directory, b"blocks recreation")
5622 .expect("test fault can block record directory recreation");
5623 }
5624 Ok(definition)
5625 }
5626
5627 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5628 self.note("uninstall", identity.name());
5629 let mut state = self.state.lock().expect("not poisoned");
5630 state.definitions.remove(identity.name());
5631 Ok(state
5632 .registrations
5633 .remove(&(self.mode, identity.name().to_string()))
5634 .is_some())
5635 }
5636
5637 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5638 self.note("query", identity.name());
5639 Ok(self
5640 .state
5641 .lock()
5642 .expect("not poisoned")
5643 .registrations
5644 .get(&(self.mode, identity.name().to_string()))
5645 .cloned())
5646 }
5647
5648 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5649 self.note("start", identity.name());
5650 let mut state = self.state.lock().expect("not poisoned");
5651 match state
5652 .registrations
5653 .get_mut(&(self.mode, identity.name().to_string()))
5654 {
5655 Some(registration) => {
5656 registration.running = true;
5657 Ok(())
5658 }
5659 None => Err(ServiceError::NotInstalled {
5660 name: identity.name().to_string(),
5661 operation: "start",
5662 }),
5663 }
5664 }
5665
5666 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5667 self.note("stop", identity.name());
5668 let mut state = self.state.lock().expect("not poisoned");
5669 match state
5670 .registrations
5671 .get_mut(&(self.mode, identity.name().to_string()))
5672 {
5673 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5674 None => Err(ServiceError::NotInstalled {
5675 name: identity.name().to_string(),
5676 operation: "stop",
5677 }),
5678 }
5679 }
5680}
5681
5682impl ControlFactory for HostControls {
5683 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5684 sys::control(mode)
5685 }
5686
5687 fn installed_definition(&self, path: &Path) -> Option<String> {
5688 std::fs::read_to_string(path).ok()
5689 }
5690
5691 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
5692 crate::unattended_login::probe()
5693 }
5694}
5695
5696#[must_use]
5703pub fn host_home() -> Option<PathBuf> {
5704 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5705}
5706
5707#[cfg(unix)]
5709fn home_directory() -> Option<PathBuf> {
5710 host_home()
5711}
5712
5713fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5722 let output = std::process::Command::new(program)
5723 .args(arguments)
5724 .output()?;
5725 Ok((
5726 output.status.success(),
5727 String::from_utf8_lossy(&output.stdout).into_owned(),
5728 String::from_utf8_lossy(&output.stderr).into_owned(),
5729 ))
5730}
5731
5732#[must_use]
5735pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5736 if cfg!(windows) {
5737 match mode {
5738 StartMode::Boot => DefinitionKind::WindowsService,
5739 StartMode::Login => DefinitionKind::WindowsScheduledTask,
5740 }
5741 } else if cfg!(target_os = "macos") {
5742 DefinitionKind::LaunchdPlist
5743 } else {
5744 DefinitionKind::SystemdUnit
5745 }
5746}
5747
5748#[cfg(any(target_os = "macos", test))]
5755fn enable_launchd_registration(
5756 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5757 domain: &str,
5758 service_target: &str,
5759 plist: &Path,
5760 name: &str,
5761 elevation_remedy: &'static str,
5762) -> Result<(), ServiceError> {
5763 let (enabled, cause) = launchctl(&[
5764 std::ffi::OsStr::new("enable"),
5765 std::ffi::OsStr::new(service_target),
5766 ]);
5767 if enabled {
5768 return Ok(());
5769 }
5770
5771 let (booted_out, bootout_detail) = launchctl(&[
5772 std::ffi::OsStr::new("bootout"),
5773 std::ffi::OsStr::new(service_target),
5774 ]);
5775 let removed = std::fs::remove_file(plist);
5776 if !booted_out || removed.is_err() {
5777 return Err(ServiceError::Rollback {
5778 operation: "enable launchd registration",
5779 name: name.to_string(),
5780 cause,
5781 rollback: format!(
5782 "launchctl bootout {domain}: {}; remove {}: {}",
5783 if booted_out {
5784 "succeeded".to_string()
5785 } else {
5786 bootout_detail
5787 },
5788 plist.display(),
5789 removed
5790 .err()
5791 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5792 ),
5793 });
5794 }
5795
5796 if cause.to_ascii_lowercase().contains("permission denied") {
5797 Err(ServiceError::NeedsElevation {
5798 operation: "enable",
5799 name: name.to_string(),
5800 detail: cause,
5801 remedy: elevation_remedy,
5802 })
5803 } else {
5804 Err(ServiceError::Control {
5805 operation: "enable",
5806 name: name.to_string(),
5807 manager: "launchd",
5808 detail: cause,
5809 })
5810 }
5811}
5812
5813#[derive(Debug, Clone)]
5822pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5823
5824impl ServiceShutdown {
5825 pub async fn wait(mut self) {
5827 if !*self.0.borrow() {
5828 let _ = self.0.changed().await;
5829 }
5830 }
5831}
5832
5833#[cfg(windows)]
5840pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5841where
5842 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5843{
5844 windows_host::run(Box::new(run))
5845}
5846
5847#[cfg(windows)]
5848mod windows_host {
5849 use std::ffi::OsString;
5850 use std::sync::{Arc, Mutex, OnceLock, mpsc};
5851 use std::time::Duration;
5852
5853 use windows_service::service::{
5854 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5855 ServiceType,
5856 };
5857 use windows_service::service_control_handler::{
5858 self, ServiceControlHandlerResult, ServiceStatusHandle,
5859 };
5860
5861 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5862
5863 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5864
5865 struct Invocation {
5866 run: Runner,
5867 result: mpsc::SyncSender<Result<u8, String>>,
5868 }
5869
5870 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5871
5872 windows_service::define_windows_service!(ffi_service_main, service_main);
5873
5874 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5875 let (result_tx, result_rx) = mpsc::sync_channel(1);
5876 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5877 let mut invocation = slot
5878 .lock()
5879 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5880 if invocation.is_some() {
5881 return Err(host_error(
5882 "prepare",
5883 "the service-host slot was already used",
5884 ));
5885 }
5886 *invocation = Some(Invocation {
5887 run,
5888 result: result_tx,
5889 });
5890 drop(invocation);
5891
5892 windows_service::service_dispatcher::start("", ffi_service_main)
5893 .map_err(|error| host_error("connect", &error.to_string()))?;
5894 result_rx
5895 .recv()
5896 .map_err(|error| host_error("finish", &error.to_string()))?
5897 .map_err(|detail| host_error("run", &detail))
5898 }
5899
5900 fn service_main(_arguments: Vec<OsString>) {
5901 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5902 return;
5903 };
5904 let result = run_service(invocation.run);
5905 let _ = invocation.result.send(result);
5906 }
5907
5908 fn run_service(run: Runner) -> Result<u8, String> {
5909 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5910 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5911 let handler_status = Arc::clone(&status);
5912 let handler = move |control| match control {
5913 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5914 ServiceControl::Stop | ServiceControl::Shutdown => {
5915 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5916 let _ = handle.set_service_status(service_status(
5917 ServiceState::StopPending,
5918 ServiceControlAccept::empty(),
5919 1,
5920 Duration::from_secs(300),
5921 0,
5922 ));
5923 }
5924 let _ = stop_tx.send(true);
5925 ServiceControlHandlerResult::NoError
5926 }
5927 _ => ServiceControlHandlerResult::NotImplemented,
5928 };
5929 let handle = service_control_handler::register("", handler)
5930 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5931 *status
5932 .lock()
5933 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5934 handle
5935 .set_service_status(service_status(
5936 ServiceState::Running,
5937 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5938 0,
5939 Duration::default(),
5940 0,
5941 ))
5942 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5943
5944 let exit = run(ServiceShutdown(stop_rx));
5945 handle
5946 .set_service_status(service_status(
5947 ServiceState::Stopped,
5948 ServiceControlAccept::empty(),
5949 0,
5950 Duration::default(),
5951 u32::from(exit),
5952 ))
5953 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5954 Ok(exit)
5955 }
5956
5957 fn service_status(
5958 state: ServiceState,
5959 accepted: ServiceControlAccept,
5960 checkpoint: u32,
5961 wait_hint: Duration,
5962 exit: u32,
5963 ) -> ServiceStatus {
5964 ServiceStatus {
5965 service_type: ServiceType::OWN_PROCESS,
5966 current_state: state,
5967 controls_accepted: accepted,
5968 exit_code: if exit == 0 {
5969 ServiceExitCode::Win32(0)
5970 } else {
5971 ServiceExitCode::ServiceSpecific(exit)
5972 },
5973 checkpoint,
5974 wait_hint,
5975 process_id: None,
5976 }
5977 }
5978
5979 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5980 ServiceError::Control {
5981 operation,
5982 name: SERVICE_NAME.to_string(),
5983 manager: "the Windows Service Control Manager",
5984 detail: detail.to_string(),
5985 }
5986 }
5987}
5988
5989#[cfg(windows)]
5990mod sys {
5991 use std::ffi::{OsStr, OsString};
6001 use std::time::{Duration, Instant};
6002
6003 use runner_manager_domain::model::StartMode;
6004 use windows_service::service::{
6005 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
6006 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
6007 ServiceState, ServiceType,
6008 };
6009 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
6010
6011 use super::{
6012 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
6013 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
6014 };
6015
6016 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
6019 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
6023 const ACCESS_DENIED: i32 = 5;
6025 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
6026 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
6027
6028 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
6029 choose \"Run as administrator\".";
6030
6031 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6032 Ok(match mode {
6033 StartMode::Boot => Box::new(ScmControl),
6034 StartMode::Login => Box::new(TaskControl),
6035 })
6036 }
6037
6038 #[derive(Debug)]
6041 struct ScmControl;
6042
6043 fn scm_error(
6047 operation: &'static str,
6048 name: &str,
6049 error: &windows_service::Error,
6050 ) -> ServiceError {
6051 let raw = match error {
6052 windows_service::Error::Winapi(io) => io.raw_os_error(),
6053 _ => None,
6054 };
6055 let detail = match error {
6056 windows_service::Error::Winapi(io) => io.to_string(),
6057 other => other.to_string(),
6058 };
6059 if raw == Some(ACCESS_DENIED) {
6060 return ServiceError::NeedsElevation {
6061 operation,
6062 name: name.to_string(),
6063 detail,
6064 remedy: ELEVATION_REMEDY,
6065 };
6066 }
6067 ServiceError::Control {
6068 operation,
6069 name: name.to_string(),
6070 manager: "the Windows Service Control Manager",
6071 detail,
6072 }
6073 }
6074
6075 fn open_manager(
6076 access: ServiceManagerAccess,
6077 operation: &'static str,
6078 name: &str,
6079 ) -> Result<ServiceManager, ServiceError> {
6080 ServiceManager::local_computer(None::<&OsStr>, access)
6081 .map_err(|error| scm_error(operation, name, &error))
6082 }
6083
6084 impl ServiceControl for ScmControl {
6085 fn manager(&self) -> DefinitionKind {
6086 DefinitionKind::WindowsService
6087 }
6088
6089 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6090 let spec = windows_service_spec(plan);
6091 let manager = open_manager(
6092 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
6093 "install",
6094 &spec.name,
6095 )?;
6096 let info = ServiceInfo {
6097 name: OsString::from(&spec.name),
6098 display_name: OsString::from(&spec.display_name),
6099 service_type: ServiceType::OWN_PROCESS,
6101 start_type: if spec.automatic_start {
6102 ServiceStartType::AutoStart
6103 } else {
6104 ServiceStartType::OnDemand
6105 },
6106 error_control: ServiceErrorControl::Normal,
6107 executable_path: plan.binary().to_path_buf(),
6108 launch_arguments: plan.arguments().to_vec(),
6109 dependencies: Vec::new(),
6110 account_name: spec.account.as_ref().map(OsString::from),
6112 account_password: None,
6115 };
6116 let service = manager
6117 .create_service(
6118 &info,
6119 ServiceAccess::CHANGE_CONFIG
6120 | ServiceAccess::QUERY_CONFIG
6121 | ServiceAccess::QUERY_STATUS
6122 | ServiceAccess::START
6123 | ServiceAccess::STOP
6124 | ServiceAccess::DELETE,
6125 )
6126 .map_err(|error| scm_error("install", &spec.name, &error))?;
6127 service
6128 .set_description(&spec.description)
6129 .map_err(|error| scm_error("describe", &spec.name, &error))?;
6130 service
6131 .update_failure_actions(ServiceFailureActions {
6132 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
6133 reboot_msg: None,
6134 command: None,
6135 actions: Some(vec![
6142 ServiceAction {
6143 action_type: ServiceActionType::Restart,
6144 delay: spec.restart.delay(),
6145 };
6146 3
6147 ]),
6148 })
6149 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
6150 service
6153 .set_failure_actions_on_non_crash_failures(true)
6154 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
6155 Ok(ServiceDefinition::windows_service(plan))
6156 }
6157
6158 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6159 let manager =
6160 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
6161 let service = match manager.open_service(
6162 identity.name(),
6163 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
6164 ) {
6165 Ok(service) => Some(service),
6166 Err(error) if is_missing(&error) => return Ok(false),
6167 Err(error) if is_marked_for_delete(&error) => None,
6172 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
6173 };
6174 if let Some(service) = service {
6175 if let Ok(status) = service.query_status()
6179 && status.current_state != ServiceState::Stopped
6180 {
6181 let _ = service.stop();
6182 }
6183 match service.delete() {
6184 Ok(()) => {}
6185 Err(error) if is_marked_for_delete(&error) => {}
6189 Err(error) => {
6190 return Err(scm_error("uninstall", identity.name(), &error));
6191 }
6192 }
6193 drop(service);
6194 }
6195
6196 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
6203 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
6204 Ok(service) => {
6205 drop(service);
6206 Ok(false)
6207 }
6208 Err(error) if is_missing(&error) => Ok(true),
6209 Err(error) if is_marked_for_delete(&error) => Ok(false),
6210 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
6211 }
6212 })?;
6213 if !absent {
6214 return Err(ServiceError::Control {
6215 operation: "verify uninstall of",
6216 name: identity.name().to_string(),
6217 manager: "the Windows Service Control Manager",
6218 detail: format!(
6219 "the registration was still visible {} seconds after DeleteService; \
6220 retry `service uninstall` from an elevated prompt",
6221 DELETE_TIMEOUT.as_secs()
6222 ),
6223 });
6224 }
6225 Ok(true)
6226 }
6227
6228 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6229 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
6230 let service = match manager.open_service(
6231 identity.name(),
6232 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
6233 ) {
6234 Ok(service) => service,
6235 Err(error) if is_missing(&error) => return Ok(None),
6236 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
6237 };
6238 let config = service
6239 .query_config()
6240 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6241 let status = service
6242 .query_status()
6243 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6244 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
6245 actions
6246 .actions
6247 .and_then(|actions| actions.into_iter().next())
6248 .filter(|action| action.action_type == ServiceActionType::Restart)
6249 .map(|action| action.delay)
6250 });
6251 Ok(Some(Registration {
6252 manager: DefinitionKind::WindowsService,
6253 start_mode: StartMode::Boot,
6254 command_line: config.executable_path.to_string_lossy().into_owned(),
6258 account: config
6259 .account_name
6260 .map(|account| account.to_string_lossy().into_owned()),
6261 running: status.current_state == ServiceState::Running,
6262 starts_automatically: config.start_type == ServiceStartType::AutoStart,
6263 restart_delay,
6264 }))
6265 }
6266
6267 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6268 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
6269 let service = manager
6270 .open_service(identity.name(), ServiceAccess::START)
6271 .map_err(|error| scm_error("start", identity.name(), &error))?;
6272 service
6273 .start::<&OsStr>(&[])
6274 .map_err(|error| scm_error("start", identity.name(), &error))
6275 }
6276
6277 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6278 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
6279 let service = manager
6280 .open_service(
6281 identity.name(),
6282 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
6283 )
6284 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6285 let status = service
6286 .query_status()
6287 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6288 if status.current_state == ServiceState::Stopped {
6289 return Ok(false);
6290 }
6291 service
6292 .stop()
6293 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6294 Ok(true)
6295 }
6296 }
6297
6298 fn is_missing(error: &windows_service::Error) -> bool {
6299 matches!(error, windows_service::Error::Winapi(io)
6300 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
6301 }
6302
6303 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
6304 matches!(error, windows_service::Error::Winapi(io)
6305 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
6306 }
6307
6308 pub(super) fn wait_until_scm_absent(
6309 timeout: Duration,
6310 poll_interval: Duration,
6311 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
6312 ) -> Result<bool, ServiceError> {
6313 let deadline = Instant::now() + timeout;
6314 loop {
6315 if probe_absent()? {
6316 return Ok(true);
6317 }
6318 if Instant::now() >= deadline {
6319 return Ok(false);
6320 }
6321 std::thread::sleep(poll_interval);
6322 }
6323 }
6324
6325 #[derive(Debug)]
6328 struct TaskControl;
6329
6330 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
6331 if detail.to_ascii_lowercase().contains("access is denied") {
6332 return ServiceError::NeedsElevation {
6333 operation,
6334 name: name.to_string(),
6335 detail,
6336 remedy: ELEVATION_REMEDY,
6337 };
6338 }
6339 ServiceError::Control {
6340 operation,
6341 name: name.to_string(),
6342 manager: "Windows Task Scheduler",
6343 detail,
6344 }
6345 }
6346
6347 fn schtasks(
6348 operation: &'static str,
6349 name: &str,
6350 arguments: &[&OsStr],
6351 ) -> Result<(bool, String), ServiceError> {
6352 match run("schtasks.exe", arguments) {
6353 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
6354 Err(error) => Err(task_error(operation, name, error.to_string())),
6355 }
6356 }
6357
6358 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
6361 let mut bytes = vec![0xFF, 0xFE];
6362 for unit in text.encode_utf16() {
6363 bytes.extend_from_slice(&unit.to_le_bytes());
6364 }
6365 std::fs::write(path, bytes)
6366 }
6367
6368 impl ServiceControl for TaskControl {
6369 fn manager(&self) -> DefinitionKind {
6370 DefinitionKind::WindowsScheduledTask
6371 }
6372
6373 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6374 let name = plan.identity().name().to_string();
6375 let principal = TaskPrincipal::current()?;
6376 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
6377 let directory = tempfile::tempdir()
6378 .map_err(|error| task_error("install", &name, error.to_string()))?;
6379 let document = directory.path().join("task.xml");
6380 write_utf16(&document, definition.text())
6381 .map_err(|error| task_error("install", &name, error.to_string()))?;
6382 let (ok, message) = schtasks(
6383 "install",
6384 &name,
6385 &[
6386 OsStr::new("/Create"),
6387 OsStr::new("/TN"),
6388 OsStr::new(&name),
6389 OsStr::new("/XML"),
6390 document.as_os_str(),
6391 ],
6392 )?;
6393 if !ok {
6394 return Err(task_error("install", &name, message));
6395 }
6396 Ok(definition)
6397 }
6398
6399 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6400 if self.query(identity)?.is_none() {
6401 return Ok(false);
6402 }
6403 let name = identity.name().to_string();
6404 let (ok, message) = schtasks(
6405 "uninstall",
6406 &name,
6407 &[
6408 OsStr::new("/Delete"),
6409 OsStr::new("/TN"),
6410 OsStr::new(&name),
6411 OsStr::new("/F"),
6412 ],
6413 )?;
6414 if !ok {
6415 return Err(task_error("uninstall", &name, message));
6416 }
6417 Ok(true)
6418 }
6419
6420 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6421 let name = identity.name().to_string();
6422 let (ok, document) = schtasks(
6423 "inspect",
6424 &name,
6425 &[
6426 OsStr::new("/Query"),
6427 OsStr::new("/TN"),
6428 OsStr::new(&name),
6429 OsStr::new("/XML"),
6430 OsStr::new("ONE"),
6431 ],
6432 )?;
6433 if !ok {
6434 return Ok(None);
6440 }
6441 let command = xml_value(&document, "Command").unwrap_or_default();
6442 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6443 let command_line = if arguments.is_empty() {
6444 super::quote_argument(&command)
6445 } else {
6446 format!("{} {arguments}", super::quote_argument(&command))
6447 };
6448 Ok(Some(Registration {
6449 manager: DefinitionKind::WindowsScheduledTask,
6450 start_mode: StartMode::Login,
6451 command_line,
6452 account: xml_value(&document, "UserId"),
6453 running: task_is_running(&name),
6454 starts_automatically: super::windows_login_task_starts_automatically(&document),
6455 restart_delay: xml_value(&document, "Interval")
6456 .as_deref()
6457 .and_then(parse_iso8601),
6458 }))
6459 }
6460
6461 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6462 let name = identity.name().to_string();
6463 let (ok, message) = schtasks(
6464 "start",
6465 &name,
6466 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6467 )?;
6468 if ok {
6469 Ok(())
6470 } else {
6471 Err(task_error("start", &name, message))
6472 }
6473 }
6474
6475 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6476 let running = self
6477 .query(identity)?
6478 .is_some_and(|registration| registration.running);
6479 if !running {
6480 return Ok(false);
6481 }
6482 let name = identity.name().to_string();
6483 let (ok, message) = schtasks(
6484 "stop",
6485 &name,
6486 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6487 )?;
6488 if ok {
6489 Ok(true)
6490 } else {
6491 Err(task_error("stop", &name, message))
6492 }
6493 }
6494 }
6495
6496 fn task_is_running(name: &str) -> bool {
6510 let Ok((true, stdout, _)) = run(
6511 "schtasks.exe",
6512 &[
6513 OsStr::new("/Query"),
6514 OsStr::new("/TN"),
6515 OsStr::new(name),
6516 OsStr::new("/FO"),
6517 OsStr::new("CSV"),
6518 OsStr::new("/NH"),
6519 ],
6520 ) else {
6521 return false;
6522 };
6523 stdout
6524 .lines()
6525 .filter_map(|line| line.rsplit(',').next())
6526 .any(|status| {
6527 status
6528 .trim()
6529 .trim_matches('"')
6530 .eq_ignore_ascii_case("running")
6531 })
6532 }
6533
6534 fn parse_iso8601(value: &str) -> Option<Duration> {
6538 let rest = value.strip_prefix("PT")?;
6539 if let Some(minutes) = rest.strip_suffix('M') {
6540 return minutes
6541 .parse::<u64>()
6542 .ok()
6543 .map(|minutes| Duration::from_secs(minutes * 60));
6544 }
6545 rest.strip_suffix('S')?
6546 .parse::<u64>()
6547 .ok()
6548 .map(Duration::from_secs)
6549 }
6550}
6551
6552#[cfg(unix)]
6564fn write_definition(
6565 operation: &'static str,
6566 name: &str,
6567 path: &Path,
6568 text: &str,
6569 remedy: &'static str,
6570) -> Result<(), ServiceError> {
6571 if let Some(parent) = path.parent()
6572 && let Err(error) = std::fs::create_dir_all(parent)
6573 && error.kind() != std::io::ErrorKind::AlreadyExists
6574 {
6575 return Err(definition_error(operation, name, error, remedy, parent));
6576 }
6577 std::fs::write(path, text)
6578 .map_err(|error| definition_error(operation, name, error, remedy, path))
6579}
6580
6581#[cfg(all(unix, any(target_os = "macos", test)))]
6588fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
6589 use std::io::Write as _;
6590
6591 let path = drift.path.as_path();
6592 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
6593 let failed = |error: std::io::Error, at: &Path| {
6594 definition_error("rewrite", name, error, SUDO_REMEDY, at)
6595 };
6596 let mut staged =
6597 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
6598 staged
6599 .write_all(drift.rendered.as_bytes())
6600 .and_then(|()| staged.as_file().sync_all())
6601 .map_err(|error| failed(error, staged.path()))?;
6602 std::fs::set_permissions(
6603 staged.path(),
6604 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
6605 )
6606 .map_err(|error| failed(error, path))?;
6607 staged
6608 .persist(path)
6609 .map(drop)
6610 .map_err(|error| failed(error.error, path))
6611}
6612
6613#[cfg(unix)]
6614fn definition_error(
6615 operation: &'static str,
6616 name: &str,
6617 error: std::io::Error,
6618 remedy: &'static str,
6619 path: &Path,
6620) -> ServiceError {
6621 let detail = format!("{}: {error}", path.display());
6622 if error.kind() == std::io::ErrorKind::PermissionDenied {
6623 ServiceError::NeedsElevation {
6624 operation,
6625 name: name.to_string(),
6626 detail,
6627 remedy,
6628 }
6629 } else {
6630 ServiceError::Control {
6631 operation,
6632 name: name.to_string(),
6633 manager: "the local service manager",
6634 detail,
6635 }
6636 }
6637}
6638
6639#[cfg(unix)]
6641const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6642 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6643 agent not running until you sign in.";
6644
6645#[cfg(target_os = "macos")]
6650mod sys {
6651 use std::ffi::OsStr;
6664 use std::path::PathBuf;
6665 use std::time::Duration;
6666
6667 use runner_manager_domain::model::StartMode;
6668
6669 use super::{
6670 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6671 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6672 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6673 write_definition, xml_value,
6674 };
6675
6676 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6677 Ok(Box::new(LaunchdControl { mode }))
6678 }
6679
6680 #[derive(Debug)]
6681 struct LaunchdControl {
6682 mode: StartMode,
6683 }
6684
6685 impl LaunchdControl {
6686 fn domain(&self) -> String {
6688 match self.mode {
6689 StartMode::Boot => "system".to_string(),
6690 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6693 }
6694 }
6695
6696 fn service_target(&self, identity: &ServiceIdentity) -> String {
6697 format!("{}/{}", self.domain(), identity.launchd_label())
6698 }
6699
6700 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6703 let file = format!("{}.plist", identity.launchd_label());
6704 match self.mode {
6705 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6706 StartMode::Login => {
6707 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6708 }
6709 }
6710 }
6711
6712 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6713 ServiceError::Control {
6714 operation,
6715 name: name.to_string(),
6716 manager: "launchd",
6717 detail,
6718 }
6719 }
6720
6721 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6722 match run("launchctl", arguments) {
6723 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6724 Err(error) => (false, error.to_string()),
6725 }
6726 }
6727 }
6728
6729 impl ServiceControl for LaunchdControl {
6730 fn manager(&self) -> DefinitionKind {
6731 DefinitionKind::LaunchdPlist
6732 }
6733
6734 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6735 let name = plan.identity().name().to_string();
6736 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6737 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6738 return Err(self.failed(
6739 "install",
6740 &name,
6741 "this account has no home directory, so there is nowhere to put a \
6742 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6743 /Library/LaunchDaemons."
6744 .to_string(),
6745 ));
6746 };
6747 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6748 let target = self.domain();
6749 let (ok, message) = self.launchctl(&[
6750 OsStr::new("bootstrap"),
6751 OsStr::new(&target),
6752 path.as_os_str(),
6753 ]);
6754 if !ok {
6755 let _ = std::fs::remove_file(&path);
6759 if message.to_ascii_lowercase().contains("permission denied") {
6760 return Err(ServiceError::NeedsElevation {
6761 operation: "install",
6762 name,
6763 detail: message,
6764 remedy: SUDO_REMEDY,
6765 });
6766 }
6767 return Err(self.failed("install", &name, message));
6768 }
6769 let service_target = self.service_target(plan.identity());
6772 enable_launchd_registration(
6773 |arguments| self.launchctl(arguments),
6774 &target,
6775 &service_target,
6776 &path,
6777 &name,
6778 SUDO_REMEDY,
6779 )?;
6780 Ok(definition)
6781 }
6782
6783 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6784 let Some(path) = self.plist_path(identity) else {
6785 return Ok(false);
6786 };
6787 if !path.exists() {
6788 return Ok(false);
6789 }
6790 let target = self.service_target(identity);
6791 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6792 if !ok
6793 && !message.to_ascii_lowercase().contains("no such process")
6794 && !message.contains("113")
6795 {
6796 if message.to_ascii_lowercase().contains("permission denied") {
6797 return Err(ServiceError::NeedsElevation {
6798 operation: "uninstall",
6799 name: identity.name().to_string(),
6800 detail: message,
6801 remedy: SUDO_REMEDY,
6802 });
6803 }
6804 return Err(self.failed("uninstall", identity.name(), message));
6805 }
6806 std::fs::remove_file(&path).map_err(|error| {
6809 super::definition_error(
6810 "uninstall",
6811 identity.name(),
6812 error,
6813 SUDO_REMEDY,
6814 path.as_path(),
6815 )
6816 })?;
6817 Ok(true)
6818 }
6819
6820 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6821 let Some(path) = self.plist_path(identity) else {
6822 return Ok(None);
6823 };
6824 let Ok(document) = std::fs::read_to_string(&path) else {
6825 return Ok(None);
6826 };
6827 let target = self.service_target(identity);
6828 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6829 Ok(Some(Registration {
6830 manager: DefinitionKind::LaunchdPlist,
6831 start_mode: self.mode,
6832 command_line: program_arguments(&document),
6833 account: plist_string_value(&document, "UserName")
6834 .or_else(|| Some("the invoking user".to_string())),
6835 running: loaded && printed.contains("state = running"),
6836 starts_automatically: document.contains("<key>RunAtLoad</key>")
6837 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6838 restart_delay: xml_value(
6839 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6840 "integer",
6841 )
6842 .and_then(|value| value.parse::<u64>().ok())
6843 .map(Duration::from_secs),
6844 }))
6845 }
6846
6847 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6848 let target = self.service_target(identity);
6849 let (ok, message) = self.launchctl(&[
6850 OsStr::new("kickstart"),
6851 OsStr::new("-k"),
6852 OsStr::new(&target),
6853 ]);
6854 if ok {
6855 Ok(())
6856 } else {
6857 Err(self.failed("start", identity.name(), message))
6858 }
6859 }
6860
6861 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6862 let running = self
6863 .query(identity)?
6864 .is_some_and(|registration| registration.running);
6865 if !running {
6866 return Ok(false);
6867 }
6868 let target = self.service_target(identity);
6869 let (ok, message) = self.launchctl(&[
6870 OsStr::new("kill"),
6871 OsStr::new("SIGTERM"),
6872 OsStr::new(&target),
6873 ]);
6874 if ok {
6875 Ok(true)
6876 } else {
6877 Err(self.failed("stop", identity.name(), message))
6878 }
6879 }
6880 }
6881
6882 pub(super) fn reload_detached(
6897 identity: &ServiceIdentity,
6898 mode: StartMode,
6899 plist: &std::path::Path,
6900 ) -> Result<(), ServiceError> {
6901 use std::os::unix::process::CommandExt;
6902
6903 const SCRIPT: &str = r#"(
6904 sleep 1
6905 /bin/launchctl bootout "$1"
6906 tries=0
6907 until /bin/launchctl bootstrap "$2" "$3"; do
6908 tries=$((tries + 1))
6909 if [ "$tries" -ge 60 ]; then
6910 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
6911 exit 1
6912 fi
6913 sleep 1
6914 done
6915) </dev/null &"#;
6916
6917 let control = LaunchdControl { mode };
6918 let reinstall = match mode {
6921 StartMode::Boot => "sudo runner-manager service install --start-at boot",
6922 StartMode::Login => "runner-manager service install --start-at login",
6923 };
6924 let mut command = std::process::Command::new("/bin/sh");
6925 command
6926 .args(["-c", SCRIPT, "runner-manager-reload"])
6927 .arg(control.service_target(identity))
6928 .arg(control.domain())
6929 .arg(plist)
6930 .arg(reinstall)
6931 .stdin(std::process::Stdio::null());
6932 unsafe {
6935 command.pre_exec(|| {
6936 if libc::setsid() == -1 {
6937 return Err(std::io::Error::last_os_error());
6938 }
6939 Ok(())
6940 });
6941 }
6942 let status = command.status().map_err(|error| {
6943 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
6944 })?;
6945 if status.success() {
6946 Ok(())
6947 } else {
6948 Err(control.failed(
6949 "reload",
6950 identity.name(),
6951 format!("the reload helper exited with {status}"),
6952 ))
6953 }
6954 }
6955
6956 fn program_arguments(document: &str) -> String {
6958 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6959 return String::new();
6960 };
6961 let Some(end) = rest.find("</array>") else {
6962 return String::new();
6963 };
6964 let mut out = Vec::new();
6965 let mut cursor = &rest[..end];
6966 while let Some(open) = cursor.find("<string>") {
6967 let after = &cursor[open + "<string>".len()..];
6968 let Some(close) = after.find("</string>") else {
6969 break;
6970 };
6971 out.push(quote_argument(&super::xml_unescape(&after[..close])));
6972 cursor = &after[close..];
6973 }
6974 out.join(" ")
6975 }
6976}
6977
6978#[cfg(all(unix, not(target_os = "macos")))]
6983mod sys {
6984 use std::ffi::OsStr;
6994 use std::path::PathBuf;
6995 use std::time::Duration;
6996
6997 use runner_manager_domain::model::StartMode;
6998
6999 use super::{
7000 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
7001 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
7002 home_directory, ini_directives, run, write_definition,
7003 };
7004
7005 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7006 Ok(Box::new(SystemdControl { mode }))
7007 }
7008
7009 #[derive(Debug)]
7010 struct SystemdControl {
7011 mode: StartMode,
7012 }
7013
7014 impl SystemdControl {
7015 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
7016 let file = identity.systemd_unit();
7017 match self.mode {
7018 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
7019 StartMode::Login => {
7020 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
7021 }
7022 }
7023 }
7024
7025 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
7027 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
7028 if self.mode == StartMode::Login {
7029 all.push(OsStr::new("--user"));
7030 }
7031 all.extend(arguments.iter().map(OsStr::new));
7032 match run("systemctl", &all) {
7033 Ok((ok, stdout, stderr)) => (
7034 ok,
7035 if stdout.trim().is_empty() {
7036 stderr
7037 } else {
7038 stdout
7039 },
7040 ),
7041 Err(error) => (false, error.to_string()),
7042 }
7043 }
7044
7045 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
7046 ServiceError::Control {
7047 operation,
7048 name: name.to_string(),
7049 manager: "systemd",
7050 detail,
7051 }
7052 }
7053 }
7054
7055 impl ServiceControl for SystemdControl {
7056 fn manager(&self) -> DefinitionKind {
7057 DefinitionKind::SystemdUnit
7058 }
7059
7060 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7061 let name = plan.identity().name().to_string();
7062 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
7063 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
7064 return Err(self.failed(
7065 "install",
7066 &name,
7067 "this account has no home directory, so there is nowhere to put a systemd \
7068 user unit. Use --start-at boot, which installs a system unit under \
7069 /etc/systemd/system."
7070 .to_string(),
7071 ));
7072 };
7073 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
7074 let unit = plan.identity().systemd_unit();
7075 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
7076 if !reloaded {
7077 let _ = std::fs::remove_file(&path);
7078 return Err(self.failed("install", &name, message));
7079 }
7080 let (enabled, message) = self.systemctl(&["enable", &unit]);
7081 if !enabled {
7082 let _ = std::fs::remove_file(&path);
7083 let _ = self.systemctl(&["daemon-reload"]);
7084 return Err(self.failed("install", &name, message));
7085 }
7086 Ok(definition)
7087 }
7088
7089 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7090 let Some(path) = self.unit_path(identity) else {
7091 return Ok(false);
7092 };
7093 if !path.exists() {
7094 return Ok(false);
7095 }
7096 let unit = identity.systemd_unit();
7097 let _ = self.systemctl(&["disable", "--now", &unit]);
7102 std::fs::remove_file(&path).map_err(|error| {
7103 super::definition_error(
7104 "uninstall",
7105 identity.name(),
7106 error,
7107 SUDO_REMEDY,
7108 path.as_path(),
7109 )
7110 })?;
7111 let _ = self.systemctl(&["daemon-reload"]);
7112 Ok(true)
7113 }
7114
7115 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7116 let Some(path) = self.unit_path(identity) else {
7117 return Ok(None);
7118 };
7119 let Ok(document) = std::fs::read_to_string(&path) else {
7120 return Ok(None);
7121 };
7122 let unit = identity.systemd_unit();
7123 let directives = ini_directives(&document, "Service");
7124 let (_, active) = self.systemctl(&["is-active", &unit]);
7125 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
7126 Ok(Some(Registration {
7127 manager: DefinitionKind::SystemdUnit,
7128 start_mode: self.mode,
7129 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
7130 account: directives.get("User").cloned().or_else(|| {
7131 Some(match self.mode {
7132 StartMode::Boot => "root".to_string(),
7133 StartMode::Login => "the invoking user".to_string(),
7134 })
7135 }),
7136 running: active.trim() == "active",
7137 starts_automatically: enabled.trim() == "enabled",
7138 restart_delay: directives
7139 .get("RestartSec")
7140 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
7141 .map(Duration::from_secs),
7142 }))
7143 }
7144
7145 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7146 let unit = identity.systemd_unit();
7147 let (ok, message) = self.systemctl(&["start", &unit]);
7148 if ok {
7149 Ok(())
7150 } else {
7151 Err(self.failed("start", identity.name(), message))
7152 }
7153 }
7154
7155 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7156 let running = self
7157 .query(identity)?
7158 .is_some_and(|registration| registration.running);
7159 if !running {
7160 return Ok(false);
7161 }
7162 let unit = identity.systemd_unit();
7163 let (ok, message) = self.systemctl(&["stop", &unit]);
7164 if ok {
7165 Ok(true)
7166 } else {
7167 Err(self.failed("stop", identity.name(), message))
7168 }
7169 }
7170 }
7171}
7172
7173#[cfg(all(test, target_os = "macos"))]
7175pub(crate) mod launchd_fixture {
7176 use std::path::{Path, PathBuf};
7177
7178 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
7182
7183 pub(crate) fn enabled() -> bool {
7186 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
7187 if !enabled {
7188 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
7189 }
7190 enabled
7191 }
7192
7193 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
7201 let binary = directory.join("launch-agent-job");
7202 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
7203 .expect("test binary copied");
7204 binary
7205 }
7206
7207 pub(crate) struct LoadedAgent(String);
7209
7210 impl LoadedAgent {
7211 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
7214 let domain = format!("gui/{}", unsafe { libc::getuid() });
7217 let loaded = Self(format!("{domain}/{label}"));
7219 let bootstrap = std::process::Command::new("launchctl")
7220 .arg("bootstrap")
7221 .arg(&domain)
7222 .arg(plist)
7223 .output()
7224 .expect("launchctl runs");
7225 assert!(
7226 bootstrap.status.success(),
7227 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
7228 String::from_utf8_lossy(&bootstrap.stderr)
7229 );
7230 loaded
7231 }
7232
7233 pub(crate) fn target(&self) -> &str {
7235 &self.0
7236 }
7237 }
7238
7239 impl Drop for LoadedAgent {
7240 fn drop(&mut self) {
7241 let _ = std::process::Command::new("launchctl")
7242 .args(["bootout", &self.0])
7243 .output();
7244 }
7245 }
7246}
7247
7248#[cfg(test)]
7249mod tests {
7250 use super::*;
7251
7252 use std::collections::BTreeMap;
7253
7254 #[test]
7255 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
7256 assert!(windows_login_task_starts_automatically(
7257 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
7258 ));
7259 assert!(windows_login_task_starts_automatically(
7260 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
7261 ));
7262 assert!(!windows_login_task_starts_automatically(
7263 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
7264 ));
7265 assert!(!windows_login_task_starts_automatically(
7266 "<Task><BootTrigger></BootTrigger></Task>"
7267 ));
7268 }
7269
7270 fn linux_plan(mode: StartMode) -> InstallPlan {
7277 InstallPlan::unchecked(
7278 ServiceIdentity::product(),
7279 mode,
7280 "/opt/runner-manager/bin/runner-manager",
7281 ServiceDirectories {
7282 config: PathBuf::from("/var/lib/runner-manager/config"),
7283 state: PathBuf::from("/var/lib/runner-manager/state"),
7284 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
7285 logs: PathBuf::from("/var/log/runner-manager"),
7286 },
7287 )
7288 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
7289 }
7290
7291 fn windows_plan(mode: StartMode) -> InstallPlan {
7292 InstallPlan::unchecked(
7293 ServiceIdentity::product(),
7294 mode,
7295 "C:\\Program Files\\runner-manager\\runner-manager.exe",
7296 ServiceDirectories {
7297 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
7298 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
7299 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
7300 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
7301 },
7302 )
7303 }
7304
7305 fn edited(text: &str, from: &str, to: &str) -> String {
7313 assert!(
7314 text.contains(from),
7315 "the rendered definition does not contain `{from}`, so this test would assert \
7316 nothing about a widened one"
7317 );
7318 text.replace(from, to)
7319 }
7320
7321 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
7323 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
7324 let Ok(entries) = std::fs::read_dir(directory) else {
7325 return;
7326 };
7327 for entry in entries.flatten() {
7328 let path = entry.path();
7329 if path.is_dir() {
7330 walk(&path, out);
7331 } else if let Ok(bytes) = std::fs::read(&path) {
7332 out.insert(path, bytes);
7333 }
7334 }
7335 }
7336 let mut out = BTreeMap::new();
7337 for root in roots {
7338 walk(root, &mut out);
7339 }
7340 out
7341 }
7342
7343 struct Host {
7344 _root: tempfile::TempDir,
7345 paths: AppPaths,
7346 binary: PathBuf,
7347 runner_root: LocalAbsolutePath,
7356 controls: RecordingControls,
7357 }
7358
7359 impl Host {
7360 fn new() -> Self {
7361 let root = tempfile::tempdir().expect("a temporary directory");
7362 let paths = AppPaths::rooted_at(root.path());
7363 paths.create_all().expect("the four directories");
7364 let binary = root.path().join(if cfg!(windows) {
7365 "runner-manager.exe"
7366 } else {
7367 "runner-manager"
7368 });
7369 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
7370 let runner_root = LocalAbsolutePath::new(
7371 root.path()
7372 .join("runner-root")
7373 .to_str()
7374 .expect("a unicode temporary path"),
7375 )
7376 .expect("a local absolute path");
7377 Self {
7378 _root: root,
7379 paths,
7380 binary,
7381 runner_root,
7382 controls: RecordingControls::new(),
7383 }
7384 }
7385
7386 fn operations(&self) -> ServiceOperations {
7387 ServiceOperations::with_controls(
7388 self.paths.clone(),
7389 ServiceIdentity::product(),
7390 std::sync::Arc::new(self.controls.clone()),
7391 )
7392 .with_runner_root(self.runner_root.clone())
7393 }
7394
7395 fn request(&self, mode: StartMode) -> InstallRequest {
7396 InstallRequest::new(mode).for_binary(&self.binary)
7397 }
7398 }
7399
7400 #[cfg(windows)]
7401 #[test]
7402 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
7403 let probes = std::cell::Cell::new(0);
7404 let absent =
7405 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
7406 let next = probes.get() + 1;
7407 probes.set(next);
7408 Ok(next == 3)
7409 })
7410 .expect("the simulated SCM probe succeeds");
7411
7412 assert!(absent);
7413 assert_eq!(
7414 probes.get(),
7415 3,
7416 "uninstall must recheck after transient presence instead of treating it as a leak"
7417 );
7418 }
7419
7420 #[cfg(windows)]
7421 #[test]
7422 fn windows_recognises_an_already_pending_service_deletion() {
7423 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
7424
7425 assert!(super::sys::is_marked_for_delete(&error));
7426 }
7427
7428 #[test]
7429 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
7430 let root = tempfile::tempdir().expect("a temporary directory");
7431 let plist = root.path().join("fixture.plist");
7432 std::fs::write(&plist, b"fixture").expect("a plist fixture");
7433 let calls = std::cell::RefCell::new(Vec::new());
7434
7435 let error = enable_launchd_registration(
7436 |arguments| {
7437 let call = arguments
7438 .iter()
7439 .map(|argument| argument.to_string_lossy().into_owned())
7440 .collect::<Vec<_>>();
7441 let operation = call[0].clone();
7442 calls.borrow_mut().push(call);
7443 if operation == "enable" {
7444 (false, "label remains disabled".to_string())
7445 } else {
7446 (true, String::new())
7447 }
7448 },
7449 "system",
7450 "system/com.openai.runner-manager-selftest",
7451 &plist,
7452 "runner-manager-selftest",
7453 "rerun with administrative rights",
7454 )
7455 .expect_err("enable failure must fail the install");
7456
7457 assert!(
7458 matches!(
7459 error,
7460 ServiceError::Control {
7461 operation: "enable",
7462 ..
7463 }
7464 ),
7465 "{error}"
7466 );
7467 assert_eq!(calls.borrow().len(), 2);
7468 assert_eq!(calls.borrow()[0][0], "enable");
7469 assert_eq!(calls.borrow()[1][0], "bootout");
7470 assert!(!plist.exists(), "rollback must remove the plist");
7471 }
7472
7473 #[test]
7478 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
7479 let plan = windows_plan(StartMode::Boot);
7480 let command_line = plan.command_line();
7481 assert!(
7482 command_line.starts_with('"'),
7483 "a path with a space must be quoted, got {command_line}"
7484 );
7485 assert_eq!(
7486 executable_from_command_line(&command_line).as_deref(),
7487 Some(plan.binary())
7488 );
7489 }
7490
7491 #[test]
7492 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
7493 let plan = linux_plan(StartMode::Boot);
7494 let command_line = plan.command_line();
7495 assert!(!command_line.starts_with('"'), "got {command_line}");
7496 assert_eq!(
7497 executable_from_command_line(&command_line).as_deref(),
7498 Some(plan.binary())
7499 );
7500 }
7501
7502 #[test]
7503 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
7504 let awkward = r#"C:\odd "name"\rm.exe"#;
7507 let quoted = quote_argument(awkward);
7508 assert_eq!(
7509 executable_from_command_line(&format!("{quoted} daemon run"))
7510 .as_deref()
7511 .map(Path::to_string_lossy)
7512 .as_deref(),
7513 Some(awkward)
7514 );
7515 }
7516
7517 #[test]
7518 fn an_empty_command_line_has_no_executable() {
7519 assert_eq!(executable_from_command_line(" "), None);
7520 assert_eq!(executable_from_command_line(""), None);
7521 }
7522
7523 #[test]
7524 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
7525 let awkward = r#"DOMAIN\R&D <team> "ops""#;
7526 assert_eq!(
7527 xml_escape(awkward),
7528 "DOMAIN\\R&D <team> "ops""
7529 );
7530 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
7531 }
7532
7533 #[test]
7538 fn a_restart_delay_under_the_floor_is_refused() {
7539 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
7540 .expect_err("half a second is under the one-second floor");
7541 assert!(
7542 matches!(error, ServiceError::RestartDelay { .. }),
7543 "{error}"
7544 );
7545 }
7546
7547 #[test]
7548 fn a_restart_delay_over_the_ceiling_is_refused() {
7549 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
7550 .expect_err("an hour is over the five-minute ceiling");
7551 assert!(
7552 matches!(error, ServiceError::RestartDelay { .. }),
7553 "{error}"
7554 );
7555 }
7556
7557 #[test]
7558 fn a_reset_window_no_longer_than_the_delay_is_refused() {
7559 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
7560 .expect_err("a window equal to the delay can never elapse between restarts");
7561 assert!(
7562 matches!(error, ServiceError::RestartResetWindow { .. }),
7563 "{error}"
7564 );
7565 }
7566
7567 #[test]
7568 fn a_delay_inside_the_bound_is_accepted() {
7569 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
7570 .expect("twenty seconds is inside the bound");
7571 assert_eq!(policy.delay(), Duration::from_secs(20));
7572 assert_eq!(policy.reset_after(), Duration::from_secs(300));
7573 }
7574
7575 #[test]
7580 fn a_fixture_identity_can_never_be_the_product_identity() {
7581 let fixture = ServiceIdentity::fixture("abc123");
7582 assert!(fixture.is_fixture());
7583 assert!(!ServiceIdentity::product().is_fixture());
7584 assert_ne!(fixture.name(), ServiceIdentity::product().name());
7585 assert_ne!(
7586 fixture.launchd_label(),
7587 ServiceIdentity::product().launchd_label()
7588 );
7589 assert_ne!(
7590 fixture.systemd_unit(),
7591 ServiceIdentity::product().systemd_unit()
7592 );
7593 }
7594
7595 #[test]
7596 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
7597 let fixture = ServiceIdentity::fixture("A b/c:\\d");
7598 assert!(
7599 fixture
7600 .name()
7601 .chars()
7602 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7603 "got {}",
7604 fixture.name()
7605 );
7606 }
7607
7608 #[test]
7613 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7614 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7615 assert!(unit.contains("KillMode=process\n"), "{unit}");
7616 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7617 assert!(unit.contains("RestartSec=15\n"), "{unit}");
7618 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7619 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7620 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7621 }
7622
7623 #[test]
7624 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7625 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7626 assert!(
7627 !unit.contains("LoadCredential="),
7628 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7629 );
7630 }
7631
7632 #[test]
7633 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7634 let unit = systemd_unit(&linux_plan(StartMode::Login));
7635 assert!(
7636 !unit.contains("LoadCredential="),
7637 "a user unit must not name a root-owned credential file, got:\n{unit}"
7638 );
7639 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7640 }
7641
7642 #[test]
7643 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7644 let plan = linux_plan(StartMode::Boot);
7645 let unit = systemd_unit(&plan);
7646 let directives = ini_directives(&unit, "Service");
7647 let listed = split_quoted(
7648 directives
7649 .get("ReadWritePaths")
7650 .expect("the unit names its writable paths"),
7651 );
7652 assert_eq!(listed.len(), 5, "{listed:?}");
7653 for path in plan.directories().all() {
7654 assert!(
7655 listed.iter().any(|entry| entry == &path.to_string_lossy()),
7656 "{} is missing from {listed:?}",
7657 path.display()
7658 );
7659 }
7660 assert!(
7661 listed
7662 .iter()
7663 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7664 "atomic credential replacement needs its parent directory in {listed:?}"
7665 );
7666 }
7667
7668 #[test]
7669 fn the_unit_records_the_absolute_binary_path() {
7670 let plan = linux_plan(StartMode::Boot);
7671 let unit = systemd_unit(&plan);
7672 assert!(
7673 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7674 "{unit}"
7675 );
7676 }
7677
7678 #[test]
7683 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7684 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7685 assert!(
7688 plist.contains(
7689 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
7690 ),
7691 "{plist}"
7692 );
7693 assert!(
7694 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
7695 "{plist}"
7696 );
7697 }
7698
7699 #[test]
7700 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7701 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7702 assert_eq!(
7703 plist_string_value(&daemon, "UserName").as_deref(),
7704 Some("root")
7705 );
7706 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7707
7708 let agent = launchd_plist(&linux_plan(StartMode::Login));
7709 assert_eq!(
7710 plist_string_value(&agent, "UserName"),
7711 None,
7712 "a LaunchAgent already runs as the operator:\n{agent}"
7713 );
7714 }
7715
7716 #[test]
7717 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
7718 for mode in [StartMode::Boot, StartMode::Login] {
7719 let plan = linux_plan(mode);
7720 let plist = launchd_plist(&plan);
7721 assert_eq!(
7722 plist_string_value(&plist, "ProcessType").as_deref(),
7723 Some("Interactive"),
7724 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
7725 );
7726 let review = review_least_privilege(
7727 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
7728 &plan,
7729 );
7730 assert!(
7731 review
7732 .controls()
7733 .iter()
7734 .any(|control| control == "ProcessType=Interactive"),
7735 "{review}"
7736 );
7737 assert!(
7738 !review
7739 .findings()
7740 .iter()
7741 .any(|finding| finding.subject == "ProcessType"),
7742 "{review}"
7743 );
7744 }
7745 }
7746
7747 #[test]
7751 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
7752 let plan = linux_plan(StartMode::Login);
7753 let rendered = launchd_plist(&plan);
7754 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
7755 for (label, throttled) in [
7756 (
7757 "Background",
7758 edited(
7759 &rendered,
7760 interactive,
7761 "<key>ProcessType</key>\n <string>Background</string>\n",
7762 ),
7763 ),
7764 ("absent", edited(&rendered, interactive, "")),
7765 ] {
7766 let review = review_least_privilege(
7767 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
7768 &plan,
7769 );
7770 assert!(review.is_least_privilege(), "{label}: {review}");
7771 assert!(
7772 review.findings().iter().any(|finding| {
7773 finding.kind == FindingKind::Shortfall
7774 && finding.subject == "ProcessType"
7775 && finding.detail.contains(label)
7776 }),
7777 "{label}: {review}"
7778 );
7779 }
7780 }
7781
7782 #[test]
7783 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
7784 let host = Host::new();
7785 let identity = ServiceIdentity::product();
7786 let plan = InstallPlan::resolve(
7787 identity.clone(),
7788 &host.request(StartMode::Login),
7789 ServiceDirectories::of(&host.paths),
7790 )
7791 .expect("a resolvable plan");
7792 let installed = launchd_plist(&plan);
7793 let definition =
7794 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
7795 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7796 record.installed_by_version = "0.4.31".into();
7797 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
7798
7799 assert_eq!(
7800 launchd_definition_drift(&identity, &record, path.clone(), &installed),
7801 None
7802 );
7803 let edited_by_operator = edited(
7804 &installed,
7805 "</dict>\n</plist>\n",
7806 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
7807 );
7808 assert_eq!(
7809 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
7810 None,
7811 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
7812 );
7813
7814 let written_by_0_4_31 = edited(
7815 &installed,
7816 "<string>Interactive</string>",
7817 "<string>Background</string>",
7818 );
7819 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
7820 .expect("a Background plist is drift");
7821 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
7822 assert_eq!(drift.start_mode, StartMode::Login);
7823 assert_eq!(
7824 drift.rendered, installed,
7825 "the repair must write exactly what `service install` writes"
7826 );
7827
7828 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7829 .with_definition_drift(Some(drift));
7830 let problem = status
7831 .problems()
7832 .iter()
7833 .find(|problem| problem.subject == "definition")
7834 .expect("the drift is reported");
7835 assert!(
7838 problem.detail.contains("0.4.31")
7839 && problem.detail.contains(
7840 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
7841 )
7842 && !problem.detail.contains("service install")
7843 && !problem.detail.contains("systemctl")
7844 && !problem.detail.contains("sudo"),
7845 "{}",
7846 problem.detail
7847 );
7848 }
7849
7850 #[cfg(unix)]
7851 #[test]
7852 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
7853 use std::os::unix::fs::PermissionsExt as _;
7854
7855 let root = tempfile::tempdir().expect("a temporary directory");
7856 let path = root
7857 .path()
7858 .join("io.github.IvanMurzak.runner-manager.plist");
7859 std::fs::write(&path, "<string>Background</string>").expect("old plist");
7860 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
7861 let drift = DefinitionDrift {
7862 path: path.clone(),
7863 installed_by_version: "0.4.31".into(),
7864 rendered: launchd_plist(&linux_plan(StartMode::Login)),
7865 start_mode: StartMode::Login,
7866 manager: DefinitionKind::LaunchdPlist,
7867 };
7868
7869 replace_definition("runner-manager", &drift).expect("replaced");
7870
7871 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
7872 assert_eq!(
7873 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
7874 0o644
7875 );
7876 assert_eq!(
7877 std::fs::read_dir(root.path()).unwrap().count(),
7878 1,
7879 "no staging file is left beside the plist"
7880 );
7881 }
7882
7883 #[cfg(target_os = "macos")]
7892 #[test]
7893 #[ignore = "run only by launchd, as the job of the detached-reload test"]
7894 fn detached_reload_job() {
7895 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
7896 let tag = label
7897 .split_once("-selftest-")
7898 .map(|(_, tag)| tag.to_string())
7899 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
7900 let identity = ServiceIdentity::fixture(&tag);
7901 assert_eq!(identity.launchd_label(), label);
7902 let path = std::env::current_dir()
7903 .expect("working directory")
7904 .join(format!("{label}.plist"));
7905 let installed = std::fs::read_to_string(&path).expect("own plist");
7906 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
7907 let drift = DefinitionDrift {
7908 path: path.clone(),
7909 installed_by_version: "0.4.31".into(),
7910 rendered: installed.replace(
7911 "<string>Background</string>",
7912 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
7913 ),
7914 start_mode: StartMode::Login,
7915 manager: DefinitionKind::LaunchdPlist,
7916 };
7917 replace_definition(identity.name(), &drift).expect("plist replaced");
7918 super::sys::reload_detached(&identity, StartMode::Login, &path)
7919 .expect("helper started");
7920 }
7921 std::thread::sleep(Duration::from_secs(120));
7922 }
7923
7924 #[cfg(target_os = "macos")]
7934 #[test]
7935 fn a_job_reloads_itself_from_its_rewritten_plist() {
7936 if !super::launchd_fixture::enabled() {
7937 return;
7938 }
7939 let root = tempfile::tempdir().expect("a temporary directory");
7940 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
7941 let label = identity.launchd_label();
7942 let path = root.path().join(format!("{label}.plist"));
7943 let binary = super::launchd_fixture::job_binary(root.path());
7944 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
7945 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
7946 plist.push_str(&plist_string("Label", &label));
7947 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
7948 for argument in [
7949 binary.to_string_lossy().as_ref(),
7950 "--ignored",
7951 "--exact",
7952 "service::tests::detached_reload_job",
7953 "--test-threads=1",
7954 ] {
7955 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
7956 }
7957 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
7958 plist.push_str(&plist_string(
7959 "WorkingDirectory",
7960 &root.path().to_string_lossy(),
7961 ));
7962 plist.push_str(&plist_string("ProcessType", "Background"));
7963 plist.push_str("</dict>\n</plist>\n");
7964 std::fs::write(&path, plist).expect("plist written");
7965
7966 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
7967 let spawn_type = || {
7968 let printed = std::process::Command::new("launchctl")
7969 .args(["print", loaded.target()])
7970 .output()
7971 .expect("launchctl runs");
7972 String::from_utf8_lossy(&printed.stdout)
7973 .lines()
7974 .find_map(|line| {
7975 line.trim()
7976 .strip_prefix("spawn type = ")
7977 .map(str::to_string)
7978 })
7979 };
7980 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
7981
7982 let deadline = std::time::Instant::now() + Duration::from_secs(90);
7983 while spawn_type().as_deref() != Some("interactive (4)") {
7984 assert!(
7985 std::time::Instant::now() < deadline,
7986 "the job did not come back under its rewritten plist; launchd reports {:?}",
7987 spawn_type()
7988 );
7989 std::thread::sleep(Duration::from_millis(250));
7990 }
7991 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
7992 assert_eq!(
7993 plist_string_value(&rewritten, "ProcessType").as_deref(),
7994 Some(LAUNCHD_PROCESS_TYPE)
7995 );
7996 }
7997
7998 #[test]
7999 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
8000 let plist = launchd_plist(&linux_plan(StartMode::Boot));
8001 assert!(
8002 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
8003 "{plist}"
8004 );
8005 assert!(plist.contains("<string>daemon</string>"), "{plist}");
8006 assert!(plist.contains("<string>run</string>"), "{plist}");
8007 }
8008
8009 #[test]
8010 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
8011 assert_eq!(
8012 ServiceIdentity::product().launchd_label(),
8013 "io.github.IvanMurzak.runner-manager"
8014 );
8015 }
8016
8017 #[test]
8022 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
8023 let xml = windows_scheduled_task_xml(
8024 &windows_plan(StartMode::Login),
8025 &TaskPrincipal::named("HOST\\operator"),
8026 );
8027 assert!(xml.contains("<LogonTrigger>"), "{xml}");
8028 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
8029 assert!(
8030 xml.contains("<LogonType>InteractiveToken</LogonType>"),
8031 "{xml}"
8032 );
8033 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
8034 assert!(
8035 xml.contains("<Interval>PT1M</Interval>"),
8036 "Task Scheduler takes whole minutes only, and rejects the registration outright \
8037 for anything finer:\n{xml}"
8038 );
8039 }
8040
8041 #[test]
8042 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
8043 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
8048 let policy =
8049 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
8050 .expect("inside the supported range");
8051 assert_eq!(
8052 policy
8053 .effective_delay(DefinitionKind::WindowsScheduledTask)
8054 .as_secs(),
8055 enforced,
8056 "a {asked}s delay must be enforced as {enforced}s"
8057 );
8058 assert!(
8059 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
8060 "rounding must never shorten the bound"
8061 );
8062 }
8063 }
8064
8065 #[test]
8066 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
8067 let policy = RestartPolicy::default();
8068 for kind in [
8069 DefinitionKind::WindowsService,
8070 DefinitionKind::LaunchdPlist,
8071 DefinitionKind::SystemdUnit,
8072 ] {
8073 assert_eq!(
8074 policy.effective_delay(kind),
8075 policy.delay(),
8076 "{kind:?} takes seconds and enforces exactly what it is given"
8077 );
8078 }
8079 }
8080
8081 #[test]
8082 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
8083 let host = Host::new();
8084 let operations = host.operations();
8085 operations
8086 .install(&host.request(StartMode::Login))
8087 .expect("an install at login");
8088 host.controls.edit("runner-manager", |registration| {
8090 registration.manager = DefinitionKind::WindowsScheduledTask;
8091 registration.restart_delay = Some(Duration::from_secs(60));
8092 });
8093
8094 let status = operations.status().expect("a status");
8095 assert!(
8096 status.is_healthy(),
8097 "minute granularity is the manager's, not a mis-registration: {status}"
8098 );
8099 assert!(
8100 status
8101 .notes()
8102 .iter()
8103 .any(|note| note.contains("whole minutes")),
8104 "but the operator must be told why 15 became 60: {status}"
8105 );
8106
8107 host.controls.edit("runner-manager", |registration| {
8110 registration.restart_delay = Some(Duration::from_secs(1));
8111 });
8112 assert!(
8113 !operations.status().expect("a status").is_healthy(),
8114 "a one-second delay is not what any manager was asked for"
8115 );
8116 }
8117
8118 #[test]
8119 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
8120 let plan = windows_plan(StartMode::Login);
8121 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
8122 assert_eq!(
8123 xml_value(&xml, "Command").as_deref(),
8124 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
8125 "{xml}"
8126 );
8127 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
8128 }
8129
8130 #[test]
8131 fn an_account_name_holding_xml_punctuation_is_escaped() {
8132 let xml = windows_scheduled_task_xml(
8133 &windows_plan(StartMode::Login),
8134 &TaskPrincipal::named("R&D\\ops"),
8135 );
8136 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
8137 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
8138 }
8139
8140 #[test]
8145 fn the_service_starts_automatically_under_the_account_the_store_admits() {
8146 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
8147 let directives = ini_directives(&text, "windows-service");
8148 assert_eq!(
8149 directives.get("StartType").map(String::as_str),
8150 Some("AutoStart")
8151 );
8152 assert_eq!(
8153 directives.get("Account").map(String::as_str),
8154 Some("NT AUTHORITY\\SYSTEM")
8155 );
8156 assert_eq!(
8157 directives.get("ServiceType").map(String::as_str),
8158 Some("OWN_PROCESS")
8159 );
8160 assert_eq!(
8161 directives
8162 .get("FailureActionRestartDelaySecs")
8163 .map(String::as_str),
8164 Some("15")
8165 );
8166 assert_eq!(
8167 directives
8168 .get("FailureActionsOnNonCrashFailures")
8169 .map(String::as_str),
8170 Some("true"),
8171 "without this flag a non-zero exit is not a failure the manager restarts"
8172 );
8173 }
8174
8175 #[test]
8176 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
8177 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
8178 assert_eq!(spec.account, None);
8179 assert!(spec.automatic_start);
8180 assert!(
8181 spec.command_line.contains("daemon run"),
8182 "{}",
8183 spec.command_line
8184 );
8185 }
8186
8187 #[test]
8192 fn each_definition_goes_where_its_platform_expects_it() {
8193 let home = PathBuf::from("/home/op");
8194 assert_eq!(
8195 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8196 Some(Path::new(
8197 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
8198 ))
8199 );
8200 assert_eq!(
8201 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8202 Some(Path::new(
8203 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
8204 ))
8205 );
8206 assert_eq!(
8207 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8208 Some(Path::new("/etc/systemd/system/runner-manager.service"))
8209 );
8210 assert_eq!(
8211 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8212 Some(Path::new(
8213 "/home/op/.config/systemd/user/runner-manager.service"
8214 ))
8215 );
8216 assert_eq!(
8217 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
8218 None,
8219 "the Service Control Manager has no file"
8220 );
8221 }
8222
8223 #[test]
8224 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
8225 assert_eq!(
8226 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
8227 None
8228 );
8229 assert_eq!(
8230 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
8231 None
8232 );
8233 }
8234
8235 #[test]
8240 fn the_rendered_definitions_are_all_least_privilege() {
8241 let linux = linux_plan(StartMode::Boot);
8242 let windows = windows_plan(StartMode::Boot);
8243 for (definition, plan) in [
8244 (ServiceDefinition::systemd(&linux, None), &linux),
8245 (ServiceDefinition::launchd(&linux, None), &linux),
8246 (ServiceDefinition::windows_service(&windows), &windows),
8247 ] {
8248 let review = review_least_privilege(&definition, plan);
8249 assert!(
8250 review.is_least_privilege(),
8251 "{:?} should be least privilege, got:\n{review}",
8252 definition.kind()
8253 );
8254 assert!(
8255 !review.controls().is_empty(),
8256 "a review that confirms nothing proves nothing: {:?}",
8257 definition.kind()
8258 );
8259 }
8260 }
8261
8262 #[test]
8263 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
8264 let plan = windows_plan(StartMode::Login);
8265 let definition =
8266 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
8267 let review = review_least_privilege(&definition, &plan);
8268 assert!(review.is_least_privilege(), "{review}");
8269 assert!(
8270 review
8271 .controls()
8272 .iter()
8273 .any(|control| control.contains("LeastPrivilege")),
8274 "{review}"
8275 );
8276 }
8277
8278 #[test]
8279 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
8280 let plan = linux_plan(StartMode::Boot);
8281 let rendered = systemd_unit(&plan);
8282 let widened = edited(
8283 &rendered,
8284 "ReadWritePaths=/var/lib/runner-manager/config",
8285 "ReadWritePaths=/etc /var/lib/runner-manager/config",
8286 );
8287 let review = review_least_privilege(
8288 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8289 &plan,
8290 );
8291 assert!(!review.is_least_privilege(), "{review}");
8292 assert!(
8293 review
8294 .excesses()
8295 .iter()
8296 .any(|finding| finding.detail.contains("/etc")),
8297 "the review must name the directory it objects to: {review}"
8298 );
8299 }
8300
8301 #[test]
8302 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
8303 let plan = linux_plan(StartMode::Boot);
8304 let rendered = systemd_unit(&plan);
8305 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
8306 let review = review_least_privilege(
8307 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
8308 &plan,
8309 );
8310 assert!(!review.is_least_privilege(), "{review}");
8311 assert!(
8312 review
8313 .excesses()
8314 .iter()
8315 .any(|finding| finding.subject == "NoNewPrivileges"),
8316 "{review}"
8317 );
8318 }
8319
8320 #[test]
8321 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
8322 let plan = linux_plan(StartMode::Boot);
8323 let rendered = systemd_unit(&plan);
8324 let widened = edited(
8325 &rendered,
8326 "CapabilityBoundingSet=\n",
8327 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
8328 );
8329 let review = review_least_privilege(
8330 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8331 &plan,
8332 );
8333 assert!(!review.is_least_privilege(), "{review}");
8334 assert!(
8335 review
8336 .excesses()
8337 .iter()
8338 .any(|finding| finding.subject == "CapabilityBoundingSet"),
8339 "{review}"
8340 );
8341 }
8342
8343 #[test]
8344 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
8345 let plan = linux_plan(StartMode::Boot);
8346 let rendered = systemd_unit(&plan);
8347 let widened = edited(
8348 &rendered,
8349 "[Install]",
8350 "ListenStream=127.0.0.1:9000\n\n[Install]",
8351 );
8352 let review = review_least_privilege(
8353 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8354 &plan,
8355 );
8356 assert!(
8357 !review.is_least_privilege(),
8358 "07-security.md rule 2 forbids any inbound surface: {review}"
8359 );
8360 }
8361
8362 #[test]
8363 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
8364 let plan = linux_plan(StartMode::Boot);
8365 let rendered = systemd_unit(&plan);
8366 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
8367 let review = review_least_privilege(
8368 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
8369 &plan,
8370 );
8371 assert!(
8372 review.is_least_privilege(),
8373 "too little authority is not an excess: {review}"
8374 );
8375 assert!(
8376 review
8377 .findings()
8378 .iter()
8379 .any(|finding| finding.kind == FindingKind::Shortfall
8380 && finding.detail.contains("runtime")),
8381 "{review}"
8382 );
8383 }
8384
8385 #[test]
8386 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
8387 let plan = linux_plan(StartMode::Login);
8388 let rendered = launchd_plist(&plan);
8389 let widened = edited(
8390 &rendered,
8391 "<key>ProcessType</key>",
8392 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
8393 );
8394 let review = review_least_privilege(
8395 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8396 &plan,
8397 );
8398 assert!(!review.is_least_privilege(), "{review}");
8399 assert!(
8400 review
8401 .excesses()
8402 .iter()
8403 .any(|finding| finding.subject == "UserName"),
8404 "{review}"
8405 );
8406 }
8407
8408 #[test]
8409 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
8410 let plan = linux_plan(StartMode::Boot);
8411 let rendered = launchd_plist(&plan);
8412 let widened = edited(
8413 &rendered,
8414 "<key>SessionCreate</key>\n <false/>",
8415 "<key>SessionCreate</key>\n <true/>",
8416 );
8417 let review = review_least_privilege(
8418 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8419 &plan,
8420 );
8421 assert!(!review.is_least_privilege(), "{review}");
8422 assert!(
8423 review
8424 .excesses()
8425 .iter()
8426 .any(|finding| finding.subject == "SessionCreate"),
8427 "{review}"
8428 );
8429 }
8430
8431 #[test]
8432 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
8433 let plan = linux_plan(StartMode::Boot);
8434 let rendered = launchd_plist(&plan);
8435 let widened = edited(
8436 &rendered,
8437 "<key>ProcessType</key>",
8438 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
8439 );
8440 let review = review_least_privilege(
8441 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8442 &plan,
8443 );
8444 assert!(!review.is_least_privilege(), "{review}");
8445 }
8446
8447 #[test]
8448 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
8449 let plan = windows_plan(StartMode::Login);
8450 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8451 let widened = edited(
8452 &rendered,
8453 "<RunLevel>LeastPrivilege</RunLevel>",
8454 "<RunLevel>HighestAvailable</RunLevel>",
8455 );
8456 let review = review_least_privilege(
8457 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8458 &plan,
8459 );
8460 assert!(!review.is_least_privilege(), "{review}");
8461 assert!(
8462 review
8463 .excesses()
8464 .iter()
8465 .any(|finding| finding.subject == "RunLevel"),
8466 "{review}"
8467 );
8468 }
8469
8470 #[test]
8471 fn a_task_that_would_store_a_password_is_not_least_privilege() {
8472 let plan = windows_plan(StartMode::Login);
8473 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8474 let widened = edited(
8475 &rendered,
8476 "<LogonType>InteractiveToken</LogonType>",
8477 "<LogonType>Password</LogonType>",
8478 );
8479 let review = review_least_privilege(
8480 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8481 &plan,
8482 );
8483 assert!(!review.is_least_privilege(), "{review}");
8484 }
8485
8486 #[test]
8487 fn an_interactive_windows_service_is_not_least_privilege() {
8488 let plan = windows_plan(StartMode::Boot);
8489 let rendered = windows_service_descriptor(&plan);
8490 let widened = edited(
8491 &rendered,
8492 "ServiceType=OWN_PROCESS",
8493 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
8494 );
8495 let review = review_least_privilege(
8496 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
8497 &plan,
8498 );
8499 assert!(!review.is_least_privilege(), "{review}");
8500 assert!(
8501 review
8502 .excesses()
8503 .iter()
8504 .any(|finding| finding.subject == "ServiceType"),
8505 "{review}"
8506 );
8507 }
8508
8509 #[test]
8510 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
8511 let plan = windows_plan(StartMode::Boot);
8512 let rendered = windows_service_descriptor(&plan);
8513 let changed = edited(
8514 &rendered,
8515 "Account=NT AUTHORITY\\SYSTEM",
8516 "Account=NT AUTHORITY\\LocalService",
8517 );
8518 let review = review_least_privilege(
8519 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
8520 &plan,
8521 );
8522 assert!(review.is_least_privilege(), "{review}");
8528 assert!(
8529 review.findings().iter().any(|finding| {
8530 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
8531 }),
8532 "{review}"
8533 );
8534 }
8535
8536 #[test]
8541 fn a_recorded_path_that_is_still_there_is_current() {
8542 let host = Host::new();
8543 let state = inspect_binary(&host.binary, Some(&host.binary));
8544 assert!(!state.is_error(), "{state}");
8545 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
8546 }
8547
8548 #[test]
8549 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
8550 let host = Host::new();
8551 let recorded = host.binary.clone();
8555 let healthy = inspect_binary(&recorded, Some(&recorded));
8556 assert!(
8557 !healthy.is_error(),
8558 "the discriminator: before the binary moves, this must be healthy"
8559 );
8560
8561 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
8562
8563 let state = inspect_binary(&recorded, Some(&recorded));
8564 assert!(state.is_error(), "{state}");
8565 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
8566 assert!(
8567 state.to_string().contains("npm"),
8568 "the message must name the cause an operator will not otherwise connect: {state}"
8569 );
8570 }
8571
8572 #[test]
8573 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
8574 let root = tempfile::tempdir().expect("a temporary directory");
8575 let state = inspect_binary(root.path(), None);
8576 assert!(state.is_error(), "{state}");
8577 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
8578 }
8579
8580 #[test]
8581 fn a_registration_naming_a_different_binary_is_a_divergence() {
8582 let host = Host::new();
8583 let other = host.binary.with_file_name("something-else");
8584 let state = inspect_binary(&host.binary, Some(&other));
8585 assert!(state.is_error(), "{state}");
8586 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
8587 }
8588
8589 #[test]
8590 fn absence_is_reported_before_divergence() {
8591 let host = Host::new();
8592 let recorded = host.binary.clone();
8593 std::fs::remove_file(&recorded).expect("removable");
8594 let other = recorded.with_file_name("something-else");
8595 assert!(matches!(
8599 inspect_binary(&recorded, Some(&other)),
8600 BinaryPath::Missing { .. }
8601 ));
8602 }
8603
8604 #[test]
8609 fn the_record_round_trips_through_toml() {
8610 let host = Host::new();
8611 let plan = InstallPlan::resolve(
8612 ServiceIdentity::product(),
8613 &host.request(StartMode::Boot),
8614 ServiceDirectories::of(&host.paths),
8615 )
8616 .expect("a resolvable plan");
8617 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8618 let record = InstallRecord::of(&plan, &definition, Utc::now());
8619 record.write(&host.paths).expect("a writable record");
8620 let read = InstallRecord::read(&host.paths)
8621 .expect("a readable record")
8622 .expect("a record is there");
8623 assert_eq!(read, record);
8624 assert_eq!(read.binary, host.binary);
8625 assert!(read.binary.is_absolute());
8626 }
8627
8628 #[test]
8629 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
8630 let host = Host::new();
8631 let identity = ServiceIdentity::product();
8632 let plan = InstallPlan::resolve(
8633 identity.clone(),
8634 &host.request(StartMode::Boot),
8635 ServiceDirectories::of(&host.paths),
8636 )
8637 .expect("a resolvable plan");
8638 let installed = systemd_unit(&plan);
8639 let definition =
8640 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
8641 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8642 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
8643
8644 assert_eq!(
8645 systemd_definition_drift(&identity, &record, path.clone(), &installed),
8646 None,
8647 "the record must rebuild exactly the unit `service install` wrote"
8648 );
8649
8650 record.installed_by_version = "0.4.7".into();
8651 let frozen = installed.replace(
8652 "[Service]\n",
8653 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
8654 );
8655 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
8656 .expect("an outdated unit is drift");
8657 assert_eq!(drift.installed_by_version, "0.4.7");
8658 assert_eq!(drift.rendered, installed);
8659
8660 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8661 .with_definition_drift(Some(drift));
8662 assert!(!status.is_healthy());
8663 assert!(
8664 status
8665 .problems()
8666 .iter()
8667 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
8668 "{status}"
8669 );
8670 }
8671
8672 #[test]
8673 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
8674 let host = Host::new();
8675 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8676 let first = Utc::now() - chrono::Duration::hours(9);
8677 record_github_credential_rejected(&host.paths, first).unwrap();
8678 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
8679 let since = github_credential_rejected_since(&host.paths).unwrap();
8680 assert_eq!(since, Some(first));
8681
8682 let status = ServiceStatus::compose(
8683 ServiceIdentity::product(),
8684 None,
8685 None,
8686 None,
8687 None,
8688 &host.paths,
8689 )
8690 .with_credential_rejection(since);
8691 assert!(!status.is_healthy());
8692 assert_eq!(status.credential_rejected_since(), Some(first));
8693 assert!(status.to_string().contains("auth login"), "{status}");
8694
8695 clear_github_credential_rejection(&host.paths).unwrap();
8696 clear_github_credential_rejection(&host.paths).unwrap();
8697 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8698 }
8699
8700 #[test]
8702 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
8703 use crate::launch_health::{
8704 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
8705 record_launches_blocked,
8706 };
8707
8708 let host = Host::new();
8709 let compose = || {
8710 ServiceStatus::compose(
8711 ServiceIdentity::product(),
8712 None,
8713 None,
8714 None,
8715 None,
8716 &host.paths,
8717 )
8718 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
8719 };
8720 assert!(
8721 !compose()
8722 .problems()
8723 .iter()
8724 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
8725 );
8726 let blocked = BlockCause::FenceHeld {
8727 directory: PathBuf::from("launch-fence"),
8728 owner: None,
8729 }
8730 .blocked_since(Utc::now(), Viewpoint::Guest);
8731 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
8732
8733 let status = compose();
8734 assert!(!status.is_healthy());
8735 assert_eq!(status.launches_blocked(), Some(&blocked));
8736 let problem = status
8737 .problems()
8738 .iter()
8739 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
8740 .expect("reported under its own subject");
8741 assert!(problem.detail.contains("systemctl restart"), "{status}");
8742
8743 clear_launches_blocked(&host.paths).unwrap();
8744 assert_eq!(compose().launches_blocked(), None);
8745 }
8746
8747 #[test]
8750 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
8751 let host = Host::new();
8752 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8753 let first = Utc::now() - chrono::Duration::minutes(40);
8754 record_credential_unreadable(&host.paths, first).unwrap();
8755 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
8756 let since = credential_unreadable_since(&host.paths).unwrap();
8757 assert_eq!(since, Some(first), "a restart loop must not move the start");
8758
8759 let status = ServiceStatus::compose(
8760 ServiceIdentity::product(),
8761 None,
8762 None,
8763 None,
8764 None,
8765 &host.paths,
8766 )
8767 .with_credential_unreadable(since);
8768 assert!(!status.is_healthy());
8769 let problem = status
8770 .problems()
8771 .iter()
8772 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
8773 .expect("reported under its own subject");
8774 assert!(
8776 problem
8777 .detail
8778 .contains(&sign_in_instruction(StartMode::default())),
8779 "{status}"
8780 );
8781
8782 clear_credential_unreadable(&host.paths).unwrap();
8783 clear_credential_unreadable(&host.paths).unwrap();
8784 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8785 let healthy = ServiceStatus::compose(
8786 ServiceIdentity::product(),
8787 None,
8788 None,
8789 None,
8790 None,
8791 &host.paths,
8792 )
8793 .with_credential_unreadable(None);
8794 assert!(
8795 !healthy
8796 .problems()
8797 .iter()
8798 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
8799 "{healthy}"
8800 );
8801 }
8802
8803 #[test]
8804 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
8805 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
8806 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
8807 assert_eq!(
8808 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
8809 cfg!(unix),
8810 "a machine-scoped store needs root on Unix"
8811 );
8812 assert_eq!(
8813 sign_in_instruction(StartMode::Login).contains("SSH"),
8814 cfg!(target_os = "macos"),
8815 "only the macOS login keychain is out of an SSH session's reach"
8816 );
8817 }
8818
8819 #[cfg(unix)]
8828 #[test]
8829 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
8830 use std::os::unix::fs::PermissionsExt as _;
8831
8832 let host = Host::new();
8833 let plan = InstallPlan::resolve(
8834 ServiceIdentity::product(),
8835 &host.request(StartMode::Boot),
8836 ServiceDirectories::of(&host.paths),
8837 )
8838 .expect("a resolvable plan");
8839 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8840 InstallRecord::of(&plan, &definition, Utc::now())
8841 .write(&host.paths)
8842 .expect("a writable record");
8843
8844 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
8845 .expect("the record is there")
8846 .permissions()
8847 .mode()
8848 & 0o777;
8849 assert_eq!(
8850 mode, 0o644,
8851 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
8852 service install` wrote, and `service status` fails on their own host. It holds no \
8853 credential and sits in a 0700 directory, so 0644 discloses nothing"
8854 );
8855 }
8856
8857 #[test]
8861 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
8862 let host = Host::new();
8863 let path = InstallRecord::path(&host.paths);
8864 std::fs::write(
8865 &path,
8866 format!(
8867 "schema_version = {RECORD_SCHEMA_VERSION}
8868service_name = \"runner-manager\"
8869 manager = \"systemd\"
8870start_mode = \"boot\"
8871account = \"root\"
8872 binary = \"/x\"
8873arguments = []
8874restart_delay_secs = 15
8875 restart_reset_secs = 600
8876log_file = \"/x\"
8877 installed_at = \"2026-01-01T00:00:00Z\"
8878installed_by_version = \"0.1.0\"
8879 [directories]
8880config = \"/a\"
8881state = \"/b\"
8882runtime = \"/c\"
8883logs = \"/d\"
8884"
8885 ),
8886 )
8887 .expect("a writable record");
8888 let read = InstallRecord::read(&host.paths)
8889 .expect("a record missing an optional field is still readable")
8890 .expect("a record is there");
8891 assert_eq!(
8892 read.source_binary, None,
8893 "the legacy layout has no source, and must not invent one"
8894 );
8895 }
8896
8897 #[test]
8899 fn a_registration_remembers_the_file_it_was_copied_from() {
8900 let host = Host::new();
8901 let source = host.binary.with_file_name("npm-installed-runner-manager");
8902 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
8903 let plan = InstallPlan::resolve(
8904 ServiceIdentity::product(),
8905 &host.request(StartMode::Boot).copied_from(&source),
8906 ServiceDirectories::of(&host.paths),
8907 )
8908 .expect("a resolvable plan");
8909 let definition = ServiceDefinition::from_text(
8910 DefinitionKind::SystemdUnit,
8911 "[Service]
8912",
8913 );
8914 let record = InstallRecord::of(&plan, &definition, Utc::now());
8915 record.write(&host.paths).expect("a writable record");
8916
8917 let read = InstallRecord::read(&host.paths)
8918 .expect("a readable record")
8919 .expect("a record is there");
8920 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
8921 assert_ne!(
8922 read.source_binary.as_deref(),
8923 Some(read.binary.as_path()),
8924 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
8925 );
8926 }
8927
8928 #[test]
8929 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
8930 let host = Host::new();
8931 let path = InstallRecord::path(&host.paths);
8932 std::fs::write(
8933 &path,
8934 format!(
8935 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
8936 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
8937 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
8938 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
8939 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
8940 RECORD_SCHEMA_VERSION + 1
8941 ),
8942 )
8943 .expect("a writable record");
8944 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
8945 assert!(
8946 matches!(error, ServiceError::RecordUnreadable { .. }),
8947 "{error}"
8948 );
8949 assert!(
8950 error.to_string().contains("service uninstall"),
8951 "the message must say how to recover: {error}"
8952 );
8953 }
8954
8955 #[test]
8956 fn no_record_is_not_an_error() {
8957 let host = Host::new();
8958 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
8959 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
8960 }
8961
8962 #[test]
8967 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
8968 let host = Host::new();
8969 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
8970 }
8971
8972 #[test]
8973 fn the_heartbeat_round_trips_to_the_second() {
8974 let host = Host::new();
8975 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
8976 .expect("a valid timestamp")
8977 .with_timezone(&Utc);
8978 record_github_contact(&host.paths, at).expect("a writable heartbeat");
8979 assert_eq!(
8980 last_github_contact(&host.paths).expect("readable"),
8981 Some(at)
8982 );
8983 }
8984
8985 #[test]
8986 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
8987 let host = Host::new();
8988 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
8989 let error = last_github_contact(&host.paths)
8990 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
8991 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8992 }
8993
8994 #[test]
9000 fn a_runner_root_refusal_round_trips_for_service_status() {
9001 let host = Host::new();
9002 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
9003
9004 assert!(
9005 runner_root_refusals(&host.paths)
9006 .expect("readable")
9007 .is_empty(),
9008 "no record means every policy is placing runners"
9009 );
9010
9011 record_runner_root_refusal(
9012 &host.paths,
9013 "policy-a",
9014 at,
9015 "denied_by_privacy_policy",
9016 "/Volumes/NVME/runners",
9017 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
9018 )
9019 .expect("a writable record");
9020
9021 let refusals = runner_root_refusals(&host.paths).expect("readable");
9022 assert_eq!(refusals.len(), 1);
9023 assert_eq!(refusals[0].policy, "policy-a");
9024 assert_eq!(refusals[0].at, at);
9025 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
9026 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
9027 assert!(
9028 refusals[0].detail.contains("/Volumes/NVME/runners")
9029 && refusals[0].detail.contains("Full Disk Access"),
9030 "the path and the remediation are the whole point of this file: {refusals:?}"
9031 );
9032 }
9033
9034 #[test]
9042 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
9043 let host = Host::new();
9044 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
9045 record_runner_root_refusal(
9046 &host.paths,
9047 "broken",
9048 at,
9049 "denied_by_privacy_policy",
9050 "/Volumes/NVME/runners",
9051 "detail",
9052 )
9053 .expect("a writable record");
9054 record_runner_root_refusal(
9055 &host.paths,
9056 "also-broken",
9057 at,
9058 "not_writable",
9059 "/srv/other",
9060 "detail",
9061 )
9062 .expect("a writable record");
9063
9064 clear_runner_root_refusal(&host.paths, "healthy")
9066 .expect("clearing an absent policy is fine");
9067 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
9068
9069 let refusals = runner_root_refusals(&host.paths).expect("readable");
9070 assert_eq!(
9071 refusals
9072 .iter()
9073 .map(|r| r.policy.as_str())
9074 .collect::<Vec<_>>(),
9075 vec!["broken"],
9076 "the policy that is still refused must keep its record"
9077 );
9078 }
9079
9080 #[test]
9083 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
9084 let host = Host::new();
9085 record_runner_root_refusal(
9086 &host.paths,
9087 "p",
9088 Utc::now(),
9089 "not_writable",
9090 "/srv/x",
9091 "detail",
9092 )
9093 .expect("a writable record");
9094
9095 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
9096 assert!(
9097 !root_refusal_path(&host.paths).exists(),
9098 "a host with nothing refused leaves nothing behind"
9099 );
9100 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
9101 }
9102
9103 #[test]
9106 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
9107 let host = Host::new();
9108 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
9109 let error = runner_root_refusals(&host.paths)
9110 .expect_err("an unparseable record is not the same as no record");
9111 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9112 }
9113
9114 #[test]
9124 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
9125 let host = Host::new();
9126 record_runner_root_refusal(
9127 &host.paths,
9128 "policy-a",
9129 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
9130 "denied_by_privacy_policy",
9131 "/Volumes/NVME/runners",
9132 "Grant Full Disk Access to the program that runs the service",
9133 )
9134 .expect("a writable record");
9135
9136 let status = host.operations().status().expect("a readable status");
9137 let notes = status.notes().join("\n");
9138
9139 assert!(
9140 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
9141 "the directory and the remediation the log had to scrub must appear here: {notes}"
9142 );
9143 assert!(
9144 notes.contains("policy-a"),
9145 "the operator has to know which target placed no runner: {notes}"
9146 );
9147 assert!(
9148 !status
9149 .problems()
9150 .iter()
9151 .any(|problem| problem.subject == "runner root"),
9152 "a record nothing an operator types can clear must not drive the exit code"
9153 );
9154 }
9155
9156 #[test]
9162 fn install_records_the_absolute_binary_path_and_the_four_directories() {
9163 let host = Host::new();
9164 let installed = host
9165 .operations()
9166 .install(&host.request(StartMode::Boot))
9167 .expect("an install against the recording controls");
9168
9169 assert_eq!(installed.record.binary, host.binary);
9170 assert!(installed.record.binary.is_absolute());
9171 assert_eq!(installed.record.start_mode, StartMode::Boot);
9172 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
9173 assert_eq!(
9174 installed.record.directories,
9175 ServiceDirectories::of(&host.paths)
9176 );
9177 assert_eq!(
9178 installed.record.log_file,
9179 host.paths.logs_dir().join(LOG_FILE_STEM)
9180 );
9181 assert_eq!(installed.record.restart_delay_secs, 15);
9182
9183 let registrations = host.controls.registrations();
9184 assert_eq!(registrations.len(), 1);
9185 assert_eq!(registrations[0].0, StartMode::Boot);
9186 assert_eq!(registrations[0].1, "runner-manager");
9187 }
9188
9189 #[test]
9190 fn install_is_refused_while_the_single_instance_lock_is_held() {
9191 let host = Host::new();
9192 {
9194 let operations = host.operations();
9195 operations
9196 .install(&host.request(StartMode::Boot))
9197 .expect("an install with the lock free");
9198 operations.uninstall().expect("a clean slate");
9199 }
9200
9201 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
9202 .expect("this process takes the lock first");
9203
9204 let error = host
9205 .operations()
9206 .install(&host.request(StartMode::Boot))
9207 .expect_err("a second agent must not be registered while one is running");
9208 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
9209 assert!(
9210 error.to_string().contains("already running"),
9211 "the message must be actionable: {error}"
9212 );
9213 assert!(
9214 host.controls.registrations().is_empty(),
9215 "a refused install must register nothing"
9216 );
9217 assert_eq!(
9218 InstallRecord::read(&host.paths).expect("readable"),
9219 None,
9220 "a refused install must write no record"
9221 );
9222 }
9223
9224 #[test]
9232 fn installing_over_the_same_start_mode_replaces_the_registration() {
9233 let host = Host::new();
9234 let operations = host.operations();
9235 operations
9236 .install(&host.request(StartMode::Boot))
9237 .expect("the first install");
9238
9239 let again = operations
9240 .install(&host.request(StartMode::Boot))
9241 .expect("an install over the same mode replaces rather than refusing");
9242
9243 assert!(
9244 again.replaced_existing,
9245 "the operator is told this replaced something rather than made it"
9246 );
9247 assert_eq!(
9248 host.controls.registrations().len(),
9249 1,
9250 "replacing must not leave two registrations behind"
9251 );
9252 assert_eq!(
9253 InstallRecord::read(&host.paths)
9254 .expect("readable")
9255 .expect("a record")
9256 .start_mode,
9257 StartMode::Boot
9258 );
9259 }
9260
9261 #[test]
9265 fn installing_over_the_other_start_mode_is_refused() {
9266 let host = Host::new();
9267 let operations = host.operations();
9268 operations
9269 .install(&host.request(StartMode::Boot))
9270 .expect("the first install");
9271
9272 let error = operations
9273 .install(&host.request(StartMode::Login))
9274 .expect_err("a mode change is not an install");
9275 assert!(
9276 matches!(
9277 error,
9278 ServiceError::AlreadyInstalled {
9279 existing: StartMode::Boot,
9280 requested: StartMode::Login,
9281 ..
9282 }
9283 ),
9284 "{error}"
9285 );
9286 assert!(
9287 !error
9288 .to_string()
9289 .contains("switch the start mode in place,"),
9290 "the old remedy named a capability no command offers; the terminal UI is where \
9291 the start mode moves: {error}"
9292 );
9293 assert_eq!(host.controls.registrations().len(), 1);
9294 }
9295
9296 #[test]
9297 fn install_rolls_back_the_registration_when_record_persistence_fails() {
9298 let host = Host::new();
9299 let record_path = InstallRecord::path(&host.paths);
9300 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
9301
9302 let error = host
9303 .operations()
9304 .install(&host.request(StartMode::Boot))
9305 .expect_err("record persistence must fail");
9306
9307 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9308 assert!(
9309 host.controls.registrations().is_empty(),
9310 "a failed install must not leave a live unrecorded registration"
9311 );
9312 assert!(
9313 host.controls
9314 .calls()
9315 .iter()
9316 .any(|call| call == "uninstall runner-manager (boot)"),
9317 "the registration must be explicitly rolled back: {:?}",
9318 host.controls.calls()
9319 );
9320 assert!(
9321 !host.runner_root.as_path().exists(),
9322 "the rollback must take the runner root this install created with it; a directory \
9323 prepared for a registration that does not exist is litter, and on Windows it is \
9324 litter with a security descriptor"
9325 );
9326 }
9327
9328 #[test]
9333 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
9334 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
9335
9336 assert_eq!(
9340 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9341 ServiceAccount::LocalSystem
9342 );
9343 assert_eq!(
9344 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
9345 ServiceAccount::InvokingUser
9346 );
9347
9348 let boot = default_root_sddl(&RootAdmission::LocalSystem);
9349 assert!(!grants_broad_write(&boot), "{boot}");
9350 assert!(
9351 !boot.contains("S-1-5-21"),
9352 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
9353 );
9354
9355 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
9360 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
9361 assert!(!grants_broad_write(&login), "{login}");
9362 }
9363
9364 #[test]
9365 fn an_install_reports_the_runner_root_it_prepared() {
9366 let host = Host::new();
9367 let installed = host
9368 .operations()
9369 .install(&host.request(StartMode::Boot))
9370 .expect("an install");
9371 let rendered = installed.runner_root.to_string();
9372 assert!(
9373 !rendered.contains("S-1-5-21"),
9374 "the report must add no identity to the output: {rendered}"
9375 );
9376 if cfg!(windows) {
9377 assert_eq!(
9378 installed.runner_root.path(),
9379 Some(host.runner_root.as_path())
9380 );
9381 assert!(
9382 host.runner_root.as_path().is_dir(),
9383 "the directory jobs would run in has to exist once the service is registered"
9384 );
9385 } else {
9386 assert_eq!(
9387 installed.runner_root,
9388 crate::runner_root_access::RootAccessSummary::NotApplicable,
9389 "macOS and Linux keep the runtime directory they have always used"
9390 );
9391 }
9392 }
9393
9394 #[test]
9395 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
9396 let host = Host::new();
9397 let operations = host.operations();
9398 operations
9399 .install(&host.request(StartMode::Boot))
9400 .expect("an install at boot");
9401
9402 let change = operations
9403 .set_start_mode(StartMode::Login)
9404 .expect("a switch to login");
9405
9406 assert!(change.changed);
9407 if cfg!(windows) {
9408 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
9409 assert!(
9410 host.runner_root.as_path().is_dir(),
9411 "the switch must not remove the directory it reconciled"
9412 );
9413 }
9414 assert!(
9417 change.to_string().contains("runner root"),
9418 "{}",
9419 change.to_string()
9420 );
9421 }
9422
9423 #[test]
9424 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
9425 let host = Host::new();
9426 let operations = host.operations();
9427 operations
9428 .install(&host.request(StartMode::Boot))
9429 .expect("an install at boot");
9430
9431 let change = operations
9432 .set_start_mode(StartMode::Boot)
9433 .expect("a switch to the mode already in force");
9434
9435 assert!(!change.changed);
9436 assert_eq!(
9437 change.runner_root,
9438 crate::runner_root_access::RootAccessSummary::NotApplicable,
9439 "nothing moves, so nothing about the root's access control has to; reconciling here \
9440 would turn a no-op command into one that can fail on a permission it does not need"
9441 );
9442 }
9443
9444 #[cfg(windows)]
9445 #[test]
9446 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
9447 let host = Host::new();
9448 host.controls
9449 .fail_next_install(StartMode::Boot, "injected registration failure");
9450
9451 let error = host
9452 .operations()
9453 .install(&host.request(StartMode::Boot))
9454 .expect_err("the manager refuses the registration");
9455
9456 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9457 assert!(
9458 !host.runner_root.as_path().exists(),
9459 "the directory was created for a registration that does not exist"
9460 );
9461 }
9462
9463 #[cfg(windows)]
9464 #[test]
9465 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
9466 let host = Host::new();
9467 crate::runner_root_access::create_with_descriptor_for_tests(
9471 host.runner_root.as_path(),
9472 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
9473 )
9474 .expect("a deliberately open runner root");
9475 let before = crate::runner_root_access::report(host.runner_root.as_path());
9476
9477 let error = host
9478 .operations()
9479 .install(&host.request(StartMode::Boot))
9480 .expect_err("an open runner root is refused");
9481
9482 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
9483 assert!(
9484 error.to_string().contains("nothing was registered"),
9485 "{error}"
9486 );
9487 assert!(
9488 host.controls.registrations().is_empty(),
9489 "the refusal has to come before the platform is asked to register anything: {:?}",
9490 host.controls.calls()
9491 );
9492 assert_eq!(
9493 crate::runner_root_access::report(host.runner_root.as_path()),
9494 before,
9495 "an open directory is refused rather than tightened: its contents cannot be trusted, \
9496 so adopting it would be worse than declining it"
9497 );
9498 }
9499
9500 #[cfg(windows)]
9501 #[test]
9502 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
9503 let host = Host::new();
9504 let operations = host.operations();
9505 operations
9506 .install(&host.request(StartMode::Boot))
9507 .expect("an install");
9508 assert!(host.runner_root.as_path().is_dir());
9509
9510 operations.uninstall().expect("an uninstall");
9511
9512 assert!(
9513 host.runner_root.as_path().is_dir(),
9514 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
9515 runner root may hold an operator's retained workspaces."
9516 );
9517 }
9518
9519 #[test]
9520 fn install_reviews_what_it_registered() {
9521 let host = Host::new();
9522 let installed = host
9523 .operations()
9524 .install(&host.request(StartMode::Boot))
9525 .expect("an install");
9526 assert!(
9527 installed.review.is_least_privilege(),
9528 "{}",
9529 installed.review
9530 );
9531 assert!(
9532 !installed.review.controls().is_empty(),
9533 "a review that confirms nothing proves nothing: {}",
9534 installed.review
9535 );
9536 assert_eq!(
9537 installed.review.kind(),
9538 host_definition_kind(StartMode::Boot),
9539 "the review must be of the definition this host's manager was given"
9540 );
9541 assert!(
9542 !installed.review.account().justification().is_empty(),
9543 "a privileged account with no stated reason is an unreviewed one"
9544 );
9545 }
9546
9547 #[test]
9552 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
9553 let host = Host::new();
9554 let operations = host.operations();
9555 operations
9556 .install(&host.request(StartMode::Boot))
9557 .expect("an install");
9558
9559 let config = host.paths.config_dir();
9563 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
9564 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
9565 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
9566 std::fs::write(
9567 host.paths
9568 .state_dir()
9569 .join("packages/2.330.0/runner.tar.gz"),
9570 b"cached package",
9571 )
9572 .expect("writable");
9573 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
9574 std::fs::write(
9575 host.paths.state_dir().join("secrets/user-access-token"),
9576 b"a stand-in for the stored credential",
9577 )
9578 .expect("writable");
9579 std::fs::write(
9580 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
9581 b"diagnostics",
9582 )
9583 .expect("writable");
9584
9585 let roots: Vec<PathBuf> = host
9586 .paths
9587 .all()
9588 .iter()
9589 .map(|(_, path)| (*path).to_path_buf())
9590 .collect();
9591 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
9592 let before = snapshot(&roots);
9593
9594 assert!(
9597 before.len() >= 6,
9598 "the fixture must actually contain the files this test is about, got {before:#?}"
9599 );
9600 let record_path = InstallRecord::path(&host.paths);
9601 assert!(
9602 before.contains_key(&record_path),
9603 "the install record must be present before uninstall"
9604 );
9605
9606 let uninstalled = operations.uninstall().expect("an uninstall");
9607 assert!(uninstalled.removed_registration);
9608 assert!(uninstalled.removed_record);
9609
9610 let after = snapshot(&roots);
9611
9612 let mut expected = before.clone();
9614 expected.remove(&record_path);
9615 assert_eq!(
9616 after, expected,
9617 "uninstall must remove its own record and nothing else"
9618 );
9619 assert!(
9620 !record_path.exists(),
9621 "the record itself must go, or `uninstall` did nothing at all"
9622 );
9623 assert!(
9624 uninstalled
9625 .preserved
9626 .iter()
9627 .all(|path| roots.contains(&path.as_path())),
9628 "the preserved list must name the four directories: {uninstalled}"
9629 );
9630 }
9631
9632 #[test]
9633 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
9634 let host = Host::new();
9635 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
9636 assert!(!uninstalled.removed_registration);
9637 assert!(!uninstalled.removed_record);
9638 }
9639
9640 #[test]
9641 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
9642 let host = Host::new();
9643 let operations = host.operations();
9644 operations
9645 .install(&host.request(StartMode::Boot))
9646 .expect("an install");
9647 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
9648
9649 let uninstalled = operations.uninstall().expect("an uninstall");
9650 assert!(
9651 uninstalled.removed_registration,
9652 "a lost record must not strand a registration"
9653 );
9654 assert!(host.controls.registrations().is_empty());
9655 }
9656
9657 #[test]
9662 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
9663 let host = Host::new();
9664 let operations = host.operations();
9665 operations
9666 .install(&host.request(StartMode::Boot))
9667 .expect("an install at boot");
9668
9669 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
9673
9674 let change = operations
9675 .set_start_mode(StartMode::Login)
9676 .expect("a switch that does not reinstall the product");
9677 assert!(change.changed);
9678 assert_eq!(change.from, StartMode::Boot);
9679 assert_eq!(change.to, StartMode::Login);
9680 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
9681
9682 let record = InstallRecord::read(&host.paths)
9683 .expect("readable")
9684 .expect("a record");
9685 assert_eq!(record.start_mode, StartMode::Login);
9686 assert_eq!(
9687 record.binary, host.binary,
9688 "the recorded path must survive the switch untouched"
9689 );
9690
9691 let registrations = host.controls.registrations();
9692 assert_eq!(registrations.len(), 1, "{registrations:?}");
9693 assert_eq!(registrations[0].0, StartMode::Login);
9694 assert!(
9695 registrations[0]
9696 .2
9697 .command_line
9698 .contains(&host.binary.to_string_lossy().into_owned()),
9699 "{:?}",
9700 registrations[0].2
9701 );
9702 }
9703
9704 #[test]
9705 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
9706 let host = Host::new();
9707 let operations = host.operations();
9708 operations
9709 .install(&host.request(StartMode::Boot))
9710 .expect("an install at boot");
9711 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9712 host.controls
9713 .fail_next_install(StartMode::Login, "injected target failure");
9714
9715 let error = operations
9716 .set_start_mode(StartMode::Login)
9717 .expect_err("the target manager refuses the install");
9718
9719 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9720 assert_eq!(
9721 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9722 record_before
9723 );
9724 let registrations = host.controls.registrations();
9725 assert_eq!(registrations.len(), 1, "{registrations:?}");
9726 assert_eq!(registrations[0].0, StartMode::Boot);
9727 }
9728
9729 #[test]
9730 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
9731 let host = Host::new();
9732 let operations = host.operations();
9733 operations
9734 .install(&host.request(StartMode::Boot))
9735 .expect("an install at boot");
9736 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9737 let config = host.paths.config_dir().to_path_buf();
9738 let hidden = config.with_file_name("config-hidden-by-fault");
9739 host.controls.hide_directory_after_install(
9740 StartMode::Login,
9741 config.clone(),
9742 hidden.clone(),
9743 );
9744
9745 let error = operations
9746 .set_start_mode(StartMode::Login)
9747 .expect_err("the injected filesystem fault prevents persistence");
9748
9749 std::fs::remove_file(&config).expect("remove the injected blocker");
9750 std::fs::rename(&hidden, &config).expect("restore the record directory");
9751 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9752 assert_eq!(
9753 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9754 record_before
9755 );
9756 let registrations = host.controls.registrations();
9757 assert_eq!(registrations.len(), 1, "{registrations:?}");
9758 assert_eq!(registrations[0].0, StartMode::Boot);
9759 assert!(
9760 host.controls
9761 .calls()
9762 .iter()
9763 .any(|call| call == "uninstall runner-manager (login)"),
9764 "the target must be rolled back: {:?}",
9765 host.controls.calls()
9766 );
9767 }
9768
9769 #[test]
9770 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
9771 let host = Host::new();
9772 let operations = host.operations();
9773 operations
9774 .install(&host.request(StartMode::Boot))
9775 .expect("an install");
9776 let before = host.controls.calls().len();
9777
9778 let change = operations
9779 .set_start_mode(StartMode::Boot)
9780 .expect("a no-op switch");
9781 assert!(!change.changed);
9782 assert_eq!(
9783 host.controls.calls().len(),
9784 before,
9785 "a no-op switch must not touch the service manager"
9786 );
9787 }
9788
9789 #[test]
9790 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
9791 let host = Host::new();
9792 let error = host
9793 .operations()
9794 .set_start_mode(StartMode::Login)
9795 .expect_err("there is nothing to switch");
9796 assert!(
9797 matches!(error, ServiceError::NotInstalled { .. }),
9798 "{error}"
9799 );
9800 }
9801
9802 #[test]
9807 fn status_reports_the_four_facts_journey_five_asks_for() {
9808 let host = Host::new();
9809 let operations = host.operations();
9810 operations
9811 .install(&host.request(StartMode::Boot))
9812 .expect("an install");
9813 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
9814 .expect("a valid timestamp")
9815 .with_timezone(&Utc);
9816 record_github_contact(&host.paths, at).expect("a heartbeat");
9817
9818 let status = operations.status().expect("a status");
9819 assert_eq!(status.start_mode(), Some(StartMode::Boot));
9820 assert_eq!(
9821 status.binary().map(BinaryPath::recorded),
9822 Some(host.binary.as_path())
9823 );
9824 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
9825 assert_eq!(status.last_github_contact(), Some(at));
9826 assert!(status.is_installed());
9827 assert!(status.is_healthy(), "{status}");
9828
9829 let printed = status.to_string();
9830 for fragment in [
9831 "start mode",
9832 "diagnostic log",
9833 "last GitHub contact",
9834 "binary",
9835 ] {
9836 assert!(printed.contains(fragment), "{printed}");
9837 }
9838 }
9839
9840 #[cfg(unix)]
9852 #[test]
9853 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
9854 use std::os::unix::fs::PermissionsExt as _;
9855
9856 if unsafe { libc::geteuid() } == 0 {
9860 return;
9861 }
9862
9863 let host = Host::new();
9864 let operations = host.operations();
9865 operations
9866 .install(&host.request(StartMode::Boot))
9867 .expect("an install");
9868 assert!(
9869 operations.status().expect("a status").is_healthy(),
9870 "the discriminator: healthy before the record is made unreadable"
9871 );
9872
9873 let record = InstallRecord::path(&host.paths);
9874 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
9875 .expect("the mode is applied");
9876
9877 let status = operations
9878 .status()
9879 .expect("a record this account may not read is reported, not thrown");
9880 assert!(status.is_installed(), "{status}");
9881 assert!(!status.is_healthy(), "{status}");
9882
9883 let printed = status.to_string();
9884 assert!(
9885 printed.contains("this account may not read it"),
9886 "the operator is told which of the two states this is: {printed}"
9887 );
9888 assert!(
9889 !printed.contains("there is no install record"),
9890 "a record that is there and unreadable is not a record that is missing, and the \
9891 missing one's remedy starts with `service uninstall`: {printed}"
9892 );
9893
9894 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
9897 .expect("the mode is restored");
9898 }
9899
9900 #[test]
9901 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
9902 let host = Host::new();
9903 let operations = host.operations();
9904 operations
9905 .install(&host.request(StartMode::Boot))
9906 .expect("an install");
9907
9908 assert!(
9911 operations.status().expect("a status").is_healthy(),
9912 "the freshly installed host must be healthy"
9913 );
9914
9915 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
9916
9917 let status = operations.status().expect("a status");
9918 assert!(!status.is_healthy(), "{status}");
9919 assert!(
9920 status
9921 .problems()
9922 .iter()
9923 .any(|problem| problem.subject == "binary"),
9924 "{status}"
9925 );
9926 assert!(status.to_string().contains("STALE"), "{status}");
9927 }
9928
9929 #[test]
9930 fn status_reports_a_registration_that_would_not_start_at_boot() {
9931 let host = Host::new();
9932 let operations = host.operations();
9933 operations
9934 .install(&host.request(StartMode::Boot))
9935 .expect("an install");
9936 assert!(operations.status().expect("a status").is_healthy());
9937
9938 host.controls.edit("runner-manager", |registration| {
9939 registration.starts_automatically = false;
9940 });
9941
9942 let status = operations.status().expect("a status");
9943 assert!(!status.is_healthy(), "{status}");
9944 assert!(
9945 status
9946 .problems()
9947 .iter()
9948 .any(|problem| problem.detail.contains("after a reboot")),
9949 "{status}"
9950 );
9951 }
9952
9953 #[test]
9954 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
9955 let host = Host::new();
9956 let operations = host.operations();
9957 operations
9958 .install(&host.request(StartMode::Login))
9959 .expect("install and immediate start");
9960
9961 assert!(
9962 operations.status().expect("running status").is_running(),
9963 "install must not wait for the next login trigger"
9964 );
9965 operations.stop().expect("stop the registration");
9966 let status = operations.status().expect("stopped status");
9967 assert!(!status.is_healthy(), "{status}");
9968 assert!(
9969 status
9970 .problems()
9971 .iter()
9972 .any(|problem| problem.subject == "runtime"),
9973 "{status}"
9974 );
9975 let rendered = status.to_string();
9976 assert!(
9977 rendered.contains("runner-manager service install --start-at login"),
9978 "{rendered}"
9979 );
9980 assert!(
9981 !rendered.contains("runner-manager service start"),
9982 "{rendered}"
9983 );
9984 }
9985
9986 #[test]
9987 fn status_reports_a_restart_policy_something_else_edited() {
9988 let host = Host::new();
9989 let operations = host.operations();
9990 operations
9991 .install(&host.request(StartMode::Boot))
9992 .expect("an install");
9993 assert!(operations.status().expect("a status").is_healthy());
9994
9995 host.controls.edit("runner-manager", |registration| {
9996 registration.restart_delay = Some(Duration::from_secs(1));
9997 });
9998
9999 let status = operations.status().expect("a status");
10000 assert!(!status.is_healthy(), "{status}");
10001 assert!(
10002 status
10003 .problems()
10004 .iter()
10005 .any(|problem| problem.subject == "restart policy"),
10006 "{status}"
10007 );
10008 }
10009
10010 #[test]
10011 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
10012 let host = Host::new();
10013 let operations = host.operations();
10014 operations
10015 .install(&host.request(StartMode::Boot))
10016 .expect("an install");
10017 let other = host.binary.with_file_name("someone-elses.exe");
10018 std::fs::write(&other, b"x").expect("writable");
10019
10020 host.controls.edit("runner-manager", |registration| {
10021 registration.command_line = quote_argument(&other.to_string_lossy());
10022 });
10023
10024 let status = operations.status().expect("a status");
10025 assert!(!status.is_healthy(), "{status}");
10026 assert!(
10027 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
10028 "{status}"
10029 );
10030 }
10031
10032 #[test]
10033 fn status_reports_a_record_no_service_manager_knows_about() {
10034 let host = Host::new();
10035 let operations = host.operations();
10036 operations
10037 .install(&host.request(StartMode::Boot))
10038 .expect("an install");
10039 for mode in [StartMode::Boot, StartMode::Login] {
10041 host.controls
10042 .control(mode)
10043 .expect("a control")
10044 .uninstall(&ServiceIdentity::product())
10045 .expect("removed");
10046 }
10047
10048 let status = operations.status().expect("a status");
10049 assert!(!status.is_healthy(), "{status}");
10050 assert!(
10051 status
10052 .problems()
10053 .iter()
10054 .any(|problem| problem.subject == "registration"),
10055 "{status}"
10056 );
10057 }
10058
10059 #[test]
10060 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
10061 let host = Host::new();
10062 let status = host.operations().status().expect("a status");
10063 assert!(!status.is_installed());
10064 assert!(
10065 status.is_healthy(),
10066 "a host that never installed the service has no fault to report: {status}"
10067 );
10068 assert!(status.to_string().contains("installed"), "{status}");
10069 }
10070
10071 #[test]
10072 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
10073 let host = Host::new();
10074 let operations = host.operations();
10075 operations
10076 .install(&host.request(StartMode::Login))
10077 .expect("an install at login");
10078 let status = operations.status().expect("a status");
10079 assert!(
10080 status
10081 .notes()
10082 .iter()
10083 .any(|note| note.contains("does not run until the operator signs in")),
10084 "05-infrastructure.md requires `service status` to say so: {status}"
10085 );
10086 }
10087
10088 #[test]
10091 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
10092 use crate::unattended_login::{AutoLogin, UnattendedLogin};
10093 let automatic = UnattendedLogin {
10094 auto_login: AutoLogin::As("ivan".into()),
10095 filevault_on: Some(false),
10096 };
10097 assert_eq!(
10098 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
10099 None
10100 );
10101
10102 let off = UnattendedLogin {
10103 auto_login: AutoLogin::Off,
10104 filevault_on: Some(false),
10105 };
10106 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
10107 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
10108 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
10109
10110 let locked = UnattendedLogin {
10111 auto_login: AutoLogin::As("ivan".into()),
10112 filevault_on: Some(true),
10113 };
10114 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
10115 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
10116
10117 let unread = UnattendedLogin {
10120 auto_login: AutoLogin::As("ivan".into()),
10121 filevault_on: None,
10122 };
10123 assert_eq!(
10124 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
10125 None
10126 );
10127
10128 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
10130 assert!(
10131 gap.reason
10132 .contains("does not run until the operator signs in")
10133 );
10134 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
10135 }
10136
10137 #[test]
10140 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
10141 let host = Host::new();
10142 let operations = host.operations();
10143 operations
10144 .install(&host.request(StartMode::Login))
10145 .expect("an install at login");
10146 let stalled_problem = |operations: &ServiceOperations| {
10147 operations
10148 .status()
10149 .expect("a status")
10150 .problems()
10151 .iter()
10152 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
10153 .map(|problem| problem.detail.clone())
10154 };
10155 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
10156 assert_eq!(stalled_problem(&operations), None, "beating");
10157
10158 crate::daemon_heartbeat::beat(
10159 &operations.paths,
10160 Utc::now() - chrono::Duration::minutes(20),
10161 )
10162 .unwrap();
10163 let detail = stalled_problem(&operations).expect("a stall is a problem");
10164 assert!(detail.contains("has made no progress since"), "{detail}");
10165 assert!(detail.contains("runner-manager service stop"), "{detail}");
10166 }
10167
10168 #[test]
10170 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
10171 let host = Host::new();
10172 let operations = host.operations();
10173 operations
10174 .install(&host.request(StartMode::Login))
10175 .expect("an install at login");
10176 let paths = operations.paths.clone();
10177 crate::host_fitness::record_host_unfit(
10178 &paths,
10179 &[crate::host_fitness::UnfitFinding {
10180 id: "host.runner_root_responsive".into(),
10181 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
10182 remedy: Some("click Allow".into()),
10183 }],
10184 Utc::now(),
10185 )
10186 .unwrap();
10187 let status = operations.status().expect("a status");
10188 assert!(!status.is_healthy(), "{status}");
10189 let problem = status
10190 .problems()
10191 .iter()
10192 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
10193 .expect("the refusal is a problem");
10194 assert!(
10195 problem.detail.contains("/Volumes/NVME/runners"),
10196 "{problem:?}"
10197 );
10198 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
10199
10200 crate::host_fitness::clear_host_unfit(&paths).unwrap();
10203 crate::host_fitness::record_host_unfit(
10204 &paths,
10205 &[],
10206 Utc::now() - chrono::Duration::hours(2),
10207 )
10208 .unwrap();
10209 let status = operations.status().expect("a status");
10210 assert!(
10211 !status
10212 .problems()
10213 .iter()
10214 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
10215 "{status}"
10216 );
10217 }
10218
10219 #[test]
10224 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
10225 let host = Host::new();
10226 let operations = host.operations();
10227 operations
10228 .install(&host.request(StartMode::Login))
10229 .expect("an install at login");
10230 operations.start().expect("a start");
10231 assert!(operations.status().expect("a status").is_running());
10232 assert!(operations.stop().expect("a stop"));
10233 assert!(!operations.status().expect("a status").is_running());
10234 }
10235
10236 #[test]
10237 fn starting_a_host_with_no_registration_is_refused() {
10238 let host = Host::new();
10239 let error = host.operations().start().expect_err("nothing to start");
10240 assert!(
10241 matches!(error, ServiceError::NotInstalled { .. }),
10242 "{error}"
10243 );
10244 }
10245
10246 #[cfg(windows)]
10262 #[test]
10263 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
10264 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
10265
10266 let root = tempfile::tempdir().expect("a temporary directory");
10267 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
10268 .expect("a rooted machine-scoped store");
10269 store
10270 .store(&secrecy::SecretString::from("a stand-in for the token"))
10271 .expect("the store accepts a value");
10272 let protection = store.protection().expect("the DACL can be read back");
10273
10274 assert!(
10275 protection.description().contains(";;;SY)"),
10276 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
10277 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
10278 protection.description()
10279 );
10280 assert_eq!(
10281 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
10282 ServiceAccount::LocalSystem,
10283 "and that is the account this installer registers, which is why SY is what matters"
10284 );
10285 assert!(
10286 !protection.readable_by_other_local_users(),
10287 "the same DACL must still exclude ordinary local users: {}",
10288 protection.description()
10289 );
10290
10291 for rejected in [";;;LS)", ";;;NS)"] {
10295 assert!(
10296 !protection.description().contains(rejected),
10297 "if the store ever admitted {rejected}, the least-privilege analysis in \
10298 docs/service-account.md would need redoing: {}",
10299 protection.description()
10300 );
10301 }
10302 }
10303}