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 ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
175
176pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
187
188#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct ServiceIdentity {
202 name: String,
203 display_name: String,
204 description: String,
205}
206
207impl ServiceIdentity {
208 #[must_use]
210 pub fn product() -> Self {
211 Self {
212 name: SERVICE_NAME.to_string(),
213 display_name: DISPLAY_NAME.to_string(),
214 description: DESCRIPTION.to_string(),
215 }
216 }
217
218 #[must_use]
225 pub fn fixture(tag: &str) -> Self {
226 let tag: String = tag
227 .chars()
228 .map(|c| {
229 if c.is_ascii_alphanumeric() {
230 c.to_ascii_lowercase()
231 } else {
232 '-'
233 }
234 })
235 .collect();
236 let name = format!("{SERVICE_NAME}-selftest-{tag}");
237 Self {
238 display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
239 description: "Disposable fixture created by runner-manager's own installer tests. \
240 Safe to remove."
241 .to_string(),
242 name,
243 }
244 }
245
246 #[must_use]
251 pub fn is_fixture(&self) -> bool {
252 self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
253 }
254
255 #[must_use]
258 pub fn name(&self) -> &str {
259 &self.name
260 }
261
262 #[must_use]
264 pub fn display_name(&self) -> &str {
265 &self.display_name
266 }
267
268 #[must_use]
270 pub fn description(&self) -> &str {
271 &self.description
272 }
273
274 #[must_use]
280 pub fn launchd_label(&self) -> String {
281 format!(
282 "{}.{}.{}",
283 crate::paths::QUALIFIER,
284 crate::paths::ORGANIZATION,
285 self.name
286 )
287 }
288
289 #[must_use]
291 pub fn systemd_unit(&self) -> String {
292 format!("{}.service", self.name)
293 }
294}
295
296impl fmt::Display for ServiceIdentity {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 f.write_str(&self.name)
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct RestartPolicy {
317 delay: Duration,
318 reset_after: Duration,
319}
320
321impl RestartPolicy {
322 pub const MIN_DELAY: Duration = Duration::from_secs(1);
329
330 pub const MAX_DELAY: Duration = Duration::from_secs(300);
333
334 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
336
337 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
339
340 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
348 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
349 return Err(ServiceError::RestartDelay {
350 requested_secs: delay.as_secs(),
351 min_secs: Self::MIN_DELAY.as_secs(),
352 max_secs: Self::MAX_DELAY.as_secs(),
353 });
354 }
355 if reset_after <= delay {
356 return Err(ServiceError::RestartResetWindow {
357 reset_secs: reset_after.as_secs(),
358 delay_secs: delay.as_secs(),
359 });
360 }
361 Ok(Self { delay, reset_after })
362 }
363
364 #[must_use]
366 pub const fn delay(&self) -> Duration {
367 self.delay
368 }
369
370 #[must_use]
372 pub const fn reset_after(&self) -> Duration {
373 self.reset_after
374 }
375
376 #[must_use]
393 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
394 match kind {
395 DefinitionKind::WindowsScheduledTask => {
396 let seconds = self.delay.as_secs();
397 let minutes = seconds.div_ceil(60);
398 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
399 }
400 _ => self.delay,
401 }
402 }
403}
404
405impl Default for RestartPolicy {
406 fn default() -> Self {
407 Self {
408 delay: Self::DEFAULT_DELAY,
409 reset_after: Self::DEFAULT_RESET_AFTER,
410 }
411 }
412}
413
414impl fmt::Display for RestartPolicy {
415 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416 write!(
417 f,
418 "restart after {}s, failure count resets after {}s",
419 self.delay.as_secs(),
420 self.reset_after.as_secs()
421 )
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "snake_case")]
437pub enum ServiceAccount {
438 LocalSystem,
441 Root,
444 InvokingUser,
446}
447
448impl ServiceAccount {
449 #[must_use]
458 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
459 match (kind, mode) {
460 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
461 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
462 (_, StartMode::Boot) => Self::Root,
463 }
464 }
465
466 #[must_use]
469 pub const fn for_start_mode(mode: StartMode) -> Self {
470 Self::for_definition(host_definition_kind(mode), mode)
471 }
472
473 #[must_use]
475 pub const fn as_str(&self) -> &'static str {
476 match self {
477 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
478 Self::Root => "root",
479 Self::InvokingUser => "the invoking user",
480 }
481 }
482
483 #[must_use]
490 pub const fn justification(&self) -> &'static str {
491 match self {
492 Self::LocalSystem => {
493 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
494 NetworkService cannot read it, and widening the DACL to reach them would grant \
495 every service on this host read access to the one credential this product holds"
496 }
497 Self::Root => {
498 "a boot-time registration runs outside every login session: on macOS the System \
499 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
500 machine-scoped store is a 0600 file under /var/lib that only root can open \
501 before a session exists"
502 }
503 Self::InvokingUser => {
504 "a login-mode registration reads the user-scoped store, which is deliberately \
505 readable by exactly one account and needs no elevation at all"
506 }
507 }
508 }
509
510 #[must_use]
512 pub const fn needs_elevation(&self) -> bool {
513 matches!(self, Self::LocalSystem | Self::Root)
514 }
515}
516
517impl fmt::Display for ServiceAccount {
518 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
519 f.write_str(self.as_str())
520 }
521}
522
523#[derive(Debug, thiserror::Error)]
534pub enum ServiceError {
535 #[error("cannot install the service while an agent is already running on this host: {source}")]
541 LockHeld {
542 #[source]
544 source: Box<LockError>,
545 },
546
547 #[error(
550 "cannot tell whether an agent is already running on this host, so the service was not \
551 installed: {source}. Fix the reported problem with the state directory and try again."
552 )]
553 LockUnreadable {
554 #[source]
556 source: Box<LockError>,
557 },
558
559 #[error(
567 "this registration would run jobs under the default runner root, and that root could \
568 not be prepared, so nothing was registered: {source}"
569 )]
570 RunnerRoot {
571 #[source]
573 source: Box<RootAccessError>,
574 },
575
576 #[error(
578 "cannot resolve the absolute path of this executable, so there is nothing to register: \
579 {detail}. Run the installer from the installed binary rather than through a shell \
580 function or a wrapper that replaces argv[0]."
581 )]
582 BinaryPath {
583 detail: String,
585 },
586
587 #[error(
589 "{} is not a file, so registering it would create a service that cannot start. Install \
590 the product first, then run `service install` from the installed binary.",
591 path.display()
592 )]
593 BinaryMissing {
594 path: PathBuf,
596 },
597
598 #[error(
600 "a restart delay of {requested_secs}s is outside the supported range \
601 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
602 value, so `service status` would report a delay that is not the one in force."
603 )]
604 RestartDelay {
605 requested_secs: u64,
607 min_secs: u64,
609 max_secs: u64,
611 },
612
613 #[error(
615 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
616 restart delay, so the count would reset between every pair of restarts and no \
617 start limit could ever apply."
618 )]
619 RestartResetWindow {
620 reset_secs: u64,
622 delay_secs: u64,
624 },
625
626 #[error(
632 "{name} is already registered to start at {existing}, and this asks for {requested}. \
633 Changing the start mode moves the registration between two different service \
634 managers, so it is not something an install does by itself: run `service uninstall` \
635 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
636 switches it in place and keeps the registration running throughout."
637 )]
638 AlreadyInstalled {
639 name: String,
641 existing: StartMode,
643 requested: StartMode,
645 },
646
647 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
649 NotInstalled {
650 name: String,
652 operation: &'static str,
654 },
655
656 #[error("cannot {operation} the service record {}: {detail}", path.display())]
658 Record {
659 operation: &'static str,
661 path: PathBuf,
663 detail: String,
665 },
666
667 #[error(
669 "the service record {} was not written by this product, or was written by a version \
670 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
671 neither touches configuration, secrets, or the cache.",
672 path.display()
673 )]
674 RecordUnreadable {
675 path: PathBuf,
677 detail: String,
679 },
680
681 #[error(
695 "the service record {} is there and this account may not read it: {detail}. It was \
696 written by whichever account installed the service -- on a boot-mode host, `sudo \
697 service install`. Running `service install` again rewrites it readable; until then \
698 only what the service manager itself reports is available.",
699 path.display()
700 )]
701 RecordNotPermitted {
702 path: PathBuf,
704 detail: String,
706 },
707
708 #[error("cannot prepare this host's application-data directories: {source}")]
710 Paths {
711 #[source]
713 source: Box<crate::paths::PathsError>,
714 },
715
716 #[error("cannot {operation} {name} through {manager}: {detail}")]
718 Control {
719 operation: &'static str,
721 name: String,
723 manager: &'static str,
725 detail: String,
727 },
728
729 #[error(
731 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
732 Inspect `service status` before retrying."
733 )]
734 Rollback {
735 operation: &'static str,
737 name: String,
739 cause: String,
741 rollback: String,
743 },
744
745 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
747 NeedsElevation {
748 operation: &'static str,
750 name: String,
752 detail: String,
754 remedy: &'static str,
756 },
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771pub struct ServiceDirectories {
772 pub config: PathBuf,
774 pub state: PathBuf,
776 pub runtime: PathBuf,
778 pub logs: PathBuf,
780}
781
782impl ServiceDirectories {
783 #[must_use]
785 pub fn of(paths: &AppPaths) -> Self {
786 Self {
787 config: paths.config_dir().to_path_buf(),
788 state: paths.state_dir().to_path_buf(),
789 runtime: paths.runtime_dir().to_path_buf(),
790 logs: paths.logs_dir().to_path_buf(),
791 }
792 }
793
794 #[must_use]
796 pub fn all(&self) -> [&Path; 4] {
797 [
798 self.config.as_path(),
799 self.state.as_path(),
800 self.runtime.as_path(),
801 self.logs.as_path(),
802 ]
803 }
804
805 #[must_use]
808 pub fn log_file(&self) -> PathBuf {
809 self.logs.join(LOG_FILE_STEM)
810 }
811}
812
813#[derive(Debug, Clone, PartialEq, Eq)]
825pub struct InstallRequest {
826 start_mode: StartMode,
827 binary: Option<PathBuf>,
828 source_binary: Option<PathBuf>,
829 arguments: Vec<OsString>,
830 restart: RestartPolicy,
831 on_demand: bool,
832}
833
834impl InstallRequest {
835 #[must_use]
837 pub fn new(start_mode: StartMode) -> Self {
838 Self {
839 start_mode,
840 binary: None,
841 source_binary: None,
842 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
843 restart: RestartPolicy::default(),
844 on_demand: false,
845 }
846 }
847
848 #[must_use]
858 pub const fn started_on_demand(mut self) -> Self {
859 self.on_demand = true;
860 self
861 }
862
863 #[must_use]
870 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
871 self.binary = Some(binary.into());
872 self
873 }
874
875 #[must_use]
892 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
893 self.source_binary = Some(source.into());
894 self
895 }
896
897 #[must_use]
899 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
900 where
901 I: IntoIterator<Item = S>,
902 S: Into<OsString>,
903 {
904 self.arguments = arguments.into_iter().map(Into::into).collect();
905 self
906 }
907
908 #[must_use]
911 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
912 self.restart = restart;
913 self
914 }
915
916 #[must_use]
918 pub const fn start_mode(&self) -> StartMode {
919 self.start_mode
920 }
921}
922
923#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct InstallPlan {
931 identity: ServiceIdentity,
932 start_mode: StartMode,
933 binary: PathBuf,
934 source_binary: Option<PathBuf>,
935 arguments: Vec<OsString>,
936 account: ServiceAccount,
937 restart: RestartPolicy,
938 directories: ServiceDirectories,
939 secret_guard: Option<PathBuf>,
940 on_demand: bool,
941}
942
943impl InstallPlan {
944 pub fn resolve(
952 identity: ServiceIdentity,
953 request: &InstallRequest,
954 directories: ServiceDirectories,
955 ) -> Result<Self, ServiceError> {
956 let binary = match &request.binary {
957 Some(named) => absolute(named)?,
958 None => running_executable()?,
959 };
960 if !binary.is_file() {
961 return Err(ServiceError::BinaryMissing { path: binary });
962 }
963 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
968 .ok()
969 .map(|store| store.guard());
970 Ok(Self {
971 identity,
972 start_mode: request.start_mode,
973 binary,
974 source_binary: request.source_binary.clone(),
975 arguments: request.arguments.clone(),
976 account: ServiceAccount::for_start_mode(request.start_mode),
977 restart: request.restart,
978 directories,
979 secret_guard,
980 on_demand: request.on_demand,
981 })
982 }
983
984 #[must_use]
991 pub fn unchecked(
992 identity: ServiceIdentity,
993 start_mode: StartMode,
994 binary: impl Into<PathBuf>,
995 directories: ServiceDirectories,
996 ) -> Self {
997 Self {
998 identity,
999 start_mode,
1000 binary: binary.into(),
1001 source_binary: None,
1002 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1003 account: ServiceAccount::for_start_mode(start_mode),
1004 restart: RestartPolicy::default(),
1005 directories,
1006 secret_guard: None,
1007 on_demand: false,
1008 }
1009 }
1010
1011 #[must_use]
1014 pub const fn started_on_demand(mut self) -> Self {
1015 self.on_demand = true;
1016 self
1017 }
1018
1019 #[must_use]
1021 pub const fn is_on_demand(&self) -> bool {
1022 self.on_demand
1023 }
1024
1025 #[must_use]
1032 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1033 self.secret_guard = Some(guard.into());
1034 self
1035 }
1036
1037 #[must_use]
1039 pub fn secret_guard(&self) -> Option<&Path> {
1040 self.secret_guard.as_deref()
1041 }
1042
1043 #[must_use]
1045 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1046 self.restart = restart;
1047 self
1048 }
1049
1050 #[must_use]
1052 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1053 where
1054 I: IntoIterator<Item = S>,
1055 S: Into<OsString>,
1056 {
1057 self.arguments = arguments.into_iter().map(Into::into).collect();
1058 self
1059 }
1060
1061 #[must_use]
1063 pub const fn identity(&self) -> &ServiceIdentity {
1064 &self.identity
1065 }
1066
1067 #[must_use]
1069 pub const fn start_mode(&self) -> StartMode {
1070 self.start_mode
1071 }
1072
1073 #[must_use]
1075 pub fn binary(&self) -> &Path {
1076 &self.binary
1077 }
1078
1079 #[must_use]
1085 pub fn source_binary(&self) -> Option<&Path> {
1086 self.source_binary.as_deref()
1087 }
1088
1089 #[must_use]
1091 pub fn arguments(&self) -> &[OsString] {
1092 &self.arguments
1093 }
1094
1095 #[must_use]
1097 pub const fn account(&self) -> &ServiceAccount {
1098 &self.account
1099 }
1100
1101 #[must_use]
1103 pub const fn restart(&self) -> RestartPolicy {
1104 self.restart
1105 }
1106
1107 #[must_use]
1109 pub const fn directories(&self) -> &ServiceDirectories {
1110 &self.directories
1111 }
1112
1113 #[must_use]
1115 pub fn command_line(&self) -> String {
1116 let mut out = quote_argument(&self.binary.to_string_lossy());
1117 for argument in &self.arguments {
1118 out.push(' ');
1119 out.push_str("e_argument(&argument.to_string_lossy()));
1120 }
1121 out
1122 }
1123}
1124
1125fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1133 let reversal = change.revert();
1134 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1135}
1136
1137fn rolled_back<T>(
1150 retained: Option<String>,
1151 rollback: Result<T, ServiceError>,
1152 operation: &'static str,
1153 identity: &ServiceIdentity,
1154 cause: ServiceError,
1155) -> ServiceError {
1156 let left_behind = match (rollback.err(), retained) {
1157 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1158 (Some(rollback), None) => Some(rollback.to_string()),
1159 (None, retained) => retained,
1160 };
1161 match left_behind {
1162 Some(rollback) => ServiceError::Rollback {
1163 operation,
1164 name: identity.name().to_string(),
1165 cause: cause.to_string(),
1166 rollback,
1167 },
1168 None => cause,
1169 }
1170}
1171
1172fn undo_runner_root(
1175 change: &RootAccessChange,
1176 operation: &'static str,
1177 identity: &ServiceIdentity,
1178 cause: ServiceError,
1179) -> ServiceError {
1180 rolled_back(
1181 retained_runner_root(change),
1182 Ok(()),
1183 operation,
1184 identity,
1185 cause,
1186 )
1187}
1188
1189fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1203 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1204 detail: format!("{} could not be made absolute: {error}", path.display()),
1205 })
1206}
1207
1208fn running_executable() -> Result<PathBuf, ServiceError> {
1210 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1211 detail: error.to_string(),
1212 })?;
1213 absolute(&raw)
1214}
1215
1216pub(crate) fn quote_argument(argument: &str) -> String {
1225 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1226 return argument.to_string();
1227 }
1228 let mut out = String::with_capacity(argument.len() + 2);
1229 out.push('"');
1230 let mut backslashes = 0usize;
1231 for c in argument.chars() {
1232 match c {
1233 '\\' => {
1234 backslashes += 1;
1235 out.push('\\');
1236 }
1237 '"' => {
1238 for _ in 0..=backslashes {
1241 out.push('\\');
1242 }
1243 out.push('"');
1244 backslashes = 0;
1245 }
1246 other => {
1247 backslashes = 0;
1248 out.push(other);
1249 }
1250 }
1251 }
1252 for _ in 0..backslashes {
1254 out.push('\\');
1255 }
1256 out.push('"');
1257 out
1258}
1259
1260#[must_use]
1271pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1272 let trimmed = command_line.trim_start();
1273 if trimmed.is_empty() {
1274 return None;
1275 }
1276 let mut out = String::new();
1277 let mut chars = trimmed.chars().peekable();
1278 let quoted = chars.peek() == Some(&'"');
1279 if quoted {
1280 chars.next();
1281 let mut backslashes = 0usize;
1282 for c in chars {
1283 match c {
1284 '\\' => {
1285 backslashes += 1;
1286 }
1287 '"' => {
1288 out.extend(std::iter::repeat_n('\\', backslashes / 2));
1291 if backslashes.is_multiple_of(2) {
1292 break;
1293 }
1294 backslashes = 0;
1295 out.push('"');
1296 }
1297 other => {
1298 out.extend(std::iter::repeat_n('\\', backslashes));
1299 backslashes = 0;
1300 out.push(other);
1301 }
1302 }
1303 }
1304 } else {
1305 for c in chars {
1306 if c == ' ' || c == '\t' {
1307 break;
1308 }
1309 out.push(c);
1310 }
1311 }
1312 if out.is_empty() {
1313 None
1314 } else {
1315 Some(PathBuf::from(out))
1316 }
1317}
1318
1319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1325pub enum DefinitionKind {
1326 WindowsService,
1330 WindowsScheduledTask,
1332 LaunchdPlist,
1334 SystemdUnit,
1336}
1337
1338impl DefinitionKind {
1339 #[must_use]
1341 pub const fn manager(self) -> &'static str {
1342 match self {
1343 Self::WindowsService => "the Windows Service Control Manager",
1344 Self::WindowsScheduledTask => "Windows Task Scheduler",
1345 Self::LaunchdPlist => "launchd",
1346 Self::SystemdUnit => "systemd",
1347 }
1348 }
1349}
1350
1351impl fmt::Display for DefinitionKind {
1352 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1353 f.write_str(self.manager())
1354 }
1355}
1356
1357#[derive(Debug, Clone, PartialEq, Eq)]
1364pub struct ServiceDefinition {
1365 kind: DefinitionKind,
1366 text: String,
1367 install_path: Option<PathBuf>,
1368}
1369
1370impl ServiceDefinition {
1371 #[must_use]
1373 pub const fn kind(&self) -> DefinitionKind {
1374 self.kind
1375 }
1376
1377 #[must_use]
1379 pub fn text(&self) -> &str {
1380 &self.text
1381 }
1382
1383 #[must_use]
1390 pub fn install_path(&self) -> Option<&Path> {
1391 self.install_path.as_deref()
1392 }
1393
1394 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1407 Ok(match host_definition_kind(plan.start_mode()) {
1408 DefinitionKind::WindowsService => Self::windows_service(plan),
1409 DefinitionKind::WindowsScheduledTask => {
1410 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1411 }
1412 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1413 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1414 })
1415 }
1416
1417 #[must_use]
1419 pub fn windows_service(plan: &InstallPlan) -> Self {
1420 Self {
1421 kind: DefinitionKind::WindowsService,
1422 text: windows_service_descriptor(plan),
1423 install_path: None,
1424 }
1425 }
1426
1427 #[must_use]
1429 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1430 Self {
1431 kind: DefinitionKind::WindowsScheduledTask,
1432 text: windows_scheduled_task_xml(plan, principal),
1433 install_path: None,
1434 }
1435 }
1436
1437 #[must_use]
1442 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1443 let file = format!("{}.plist", plan.identity().launchd_label());
1444 let install_path = match plan.start_mode() {
1445 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1446 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1447 };
1448 Self {
1449 kind: DefinitionKind::LaunchdPlist,
1450 text: launchd_plist(plan),
1451 install_path,
1452 }
1453 }
1454
1455 #[must_use]
1463 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1464 Self {
1465 kind,
1466 text: text.into(),
1467 install_path: None,
1468 }
1469 }
1470
1471 #[must_use]
1476 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1477 let file = plan.identity().systemd_unit();
1478 let install_path = match plan.start_mode() {
1479 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1480 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1481 };
1482 Self {
1483 kind: DefinitionKind::SystemdUnit,
1484 text: systemd_unit(plan),
1485 install_path,
1486 }
1487 }
1488}
1489
1490pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1492pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1494pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1496pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1498
1499const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1502
1503pub const START_LIMIT_BURST: u32 = 5;
1511
1512#[must_use]
1533pub fn systemd_unit(plan: &InstallPlan) -> String {
1534 let identity = plan.identity();
1535 let restart = plan.restart();
1536 let directories = plan.directories();
1537
1538 let mut out = String::new();
1539 out.push_str("[Unit]\n");
1540 out.push_str(&format!("Description={}\n", identity.display_name()));
1541 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1542 out.push_str("After=network-online.target\n");
1543 out.push_str("Wants=network-online.target\n");
1544 out.push_str(&format!(
1547 "StartLimitIntervalSec={}\n",
1548 restart.reset_after().as_secs()
1549 ));
1550 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1551
1552 out.push_str("\n[Service]\n");
1553 out.push_str("Type=simple\n");
1554 out.push_str("KillMode=process\n");
1558 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1559 out.push_str(&format!(
1560 "WorkingDirectory={}\n",
1561 directories.state.display()
1562 ));
1563 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1564 out.push_str("Restart=on-failure\n");
1565 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1566
1567 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1568 for directive in SYSTEMD_HARDENING {
1569 out.push_str(directive);
1570 out.push('\n');
1571 }
1572 let mut writable = directories.all().to_vec();
1573 if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1574 writable.push(secret_directory);
1575 }
1576 out.push_str(&format!(
1577 "ReadWritePaths={}\n",
1578 writable
1579 .iter()
1580 .map(|path| quote_argument(&path.to_string_lossy()))
1581 .collect::<Vec<_>>()
1582 .join(" ")
1583 ));
1584
1585 out.push_str("\n[Install]\n");
1586 out.push_str(match plan.start_mode() {
1587 StartMode::Boot => "WantedBy=multi-user.target\n",
1588 StartMode::Login => "WantedBy=default.target\n",
1589 });
1590 out
1591}
1592
1593pub const SYSTEMD_HARDENING: [&str; 13] = [
1600 "NoNewPrivileges=yes",
1601 "CapabilityBoundingSet=",
1602 "AmbientCapabilities=",
1603 "PrivateTmp=yes",
1604 "PrivateDevices=yes",
1605 "ProtectSystem=strict",
1606 "ProtectKernelTunables=yes",
1607 "ProtectKernelModules=yes",
1608 "ProtectControlGroups=yes",
1609 "RestrictNamespaces=yes",
1610 "RestrictRealtime=yes",
1611 "RestrictSUIDSGID=yes",
1612 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1613];
1614
1615pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1639
1640#[must_use]
1647pub fn launchd_plist(plan: &InstallPlan) -> String {
1648 let identity = plan.identity();
1649 let directories = plan.directories();
1650 let mut out = String::new();
1651 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1652 out.push_str(
1653 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1654 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1655 );
1656 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1657 out.push_str(&plist_string("Label", &identity.launchd_label()));
1658
1659 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1660 out.push_str(&format!(
1661 " <string>{}</string>\n",
1662 xml_escape(&plan.binary().to_string_lossy())
1663 ));
1664 for argument in plan.arguments() {
1665 out.push_str(&format!(
1666 " <string>{}</string>\n",
1667 xml_escape(&argument.to_string_lossy())
1668 ));
1669 }
1670 out.push_str(" </array>\n");
1671
1672 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1673 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1674 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1675 out.push_str(" </dict>\n");
1676 out.push_str(&format!(
1677 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1678 plan.restart().delay().as_secs()
1679 ));
1680 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1681 out.push_str(&plist_string(
1682 "WorkingDirectory",
1683 &directories.state.to_string_lossy(),
1684 ));
1685 out.push_str(&plist_string(
1686 "StandardOutPath",
1687 &directories
1688 .logs
1689 .join("runner-manager.launchd.out.log")
1690 .to_string_lossy(),
1691 ));
1692 out.push_str(&plist_string(
1693 "StandardErrorPath",
1694 &directories
1695 .logs
1696 .join("runner-manager.launchd.err.log")
1697 .to_string_lossy(),
1698 ));
1699
1700 match plan.start_mode() {
1701 StartMode::Boot => {
1702 out.push_str(&plist_string(
1705 "UserName",
1706 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1707 .as_str(),
1708 ));
1709 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1711 }
1712 StartMode::Login => {
1713 }
1717 }
1718
1719 out.push_str("</dict>\n</plist>\n");
1720 out
1721}
1722
1723fn plist_string(key: &str, value: &str) -> String {
1725 format!(
1726 " <key>{}</key>\n <string>{}</string>\n",
1727 xml_escape(key),
1728 xml_escape(value)
1729 )
1730}
1731
1732#[derive(Debug, Clone, PartialEq, Eq)]
1740pub struct TaskPrincipal {
1741 user_id: String,
1742}
1743
1744impl TaskPrincipal {
1745 pub fn current() -> Result<Self, ServiceError> {
1753 let user = std::env::var("USERNAME")
1754 .ok()
1755 .filter(|value| !value.trim().is_empty());
1756 let Some(user) = user else {
1757 return Err(ServiceError::Control {
1758 operation: "identify the account for",
1759 name: SERVICE_NAME.to_string(),
1760 manager: "Windows Task Scheduler",
1761 detail: "this session reports no %USERNAME%, so there is no principal to \
1762 register a logon-triggered task for"
1763 .to_string(),
1764 });
1765 };
1766 let domain = std::env::var("USERDOMAIN")
1767 .ok()
1768 .filter(|value| !value.trim().is_empty());
1769 Ok(Self {
1770 user_id: match domain {
1771 Some(domain) => format!("{domain}\\{user}"),
1772 None => user,
1773 },
1774 })
1775 }
1776
1777 #[must_use]
1779 pub fn named(user_id: impl Into<String>) -> Self {
1780 Self {
1781 user_id: user_id.into(),
1782 }
1783 }
1784
1785 #[must_use]
1787 pub fn user_id(&self) -> &str {
1788 &self.user_id
1789 }
1790}
1791
1792#[must_use]
1799pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1800 let identity = plan.identity();
1801 let user = xml_escape(principal.user_id());
1802 let arguments = plan
1803 .arguments()
1804 .iter()
1805 .map(|argument| quote_argument(&argument.to_string_lossy()))
1806 .collect::<Vec<_>>()
1807 .join(" ");
1808 let mut out = String::new();
1809 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1810 out.push_str(
1811 "<Task version=\"1.4\" \
1812 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1813 );
1814 out.push_str(" <RegistrationInfo>\n");
1815 out.push_str(&format!(
1816 " <Description>{}</Description>\n",
1817 xml_escape(identity.description())
1818 ));
1819 out.push_str(&format!(
1820 " <URI>\\{}</URI>\n",
1821 xml_escape(identity.name())
1822 ));
1823 out.push_str(" </RegistrationInfo>\n");
1824
1825 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1826 out.push_str(" <Enabled>true</Enabled>\n");
1827 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1828 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1829
1830 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1831 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1832 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1833 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1834 out.push_str(" </Principal>\n </Principals>\n");
1835
1836 out.push_str(" <Settings>\n");
1837 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1841 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1842 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1843 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1844 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1845 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1846 out.push_str(" <IdleSettings>\n");
1847 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1848 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1849 out.push_str(" </IdleSettings>\n");
1850 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1851 out.push_str(" <Enabled>true</Enabled>\n");
1852 out.push_str(" <Hidden>false</Hidden>\n");
1853 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1854 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1855 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1857 out.push_str(" <Priority>7</Priority>\n");
1858 out.push_str(" <RestartOnFailure>\n");
1859 out.push_str(&format!(
1860 " <Interval>{}</Interval>\n",
1861 iso8601_minutes(
1862 plan.restart()
1863 .effective_delay(DefinitionKind::WindowsScheduledTask)
1864 )
1865 ));
1866 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1867 out.push_str(" </RestartOnFailure>\n");
1868 out.push_str(" </Settings>\n");
1869
1870 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1871 out.push_str(&format!(
1872 " <Command>{}</Command>\n",
1873 xml_escape(&plan.binary().to_string_lossy())
1874 ));
1875 if !arguments.is_empty() {
1876 out.push_str(&format!(
1877 " <Arguments>{}</Arguments>\n",
1878 xml_escape(&arguments)
1879 ));
1880 }
1881 out.push_str(&format!(
1882 " <WorkingDirectory>{}</WorkingDirectory>\n",
1883 xml_escape(&plan.directories().state.to_string_lossy())
1884 ));
1885 out.push_str(" </Exec>\n </Actions>\n");
1886 out.push_str("</Task>\n");
1887 out
1888}
1889
1890fn iso8601_minutes(duration: Duration) -> String {
1893 format!("PT{}M", duration.as_secs() / 60)
1894}
1895
1896pub(crate) fn xml_escape(value: &str) -> String {
1904 let mut out = String::with_capacity(value.len());
1905 for c in value.chars() {
1906 match c {
1907 '&' => out.push_str("&"),
1908 '<' => out.push_str("<"),
1909 '>' => out.push_str(">"),
1910 '"' => out.push_str("""),
1911 '\'' => out.push_str("'"),
1912 other => out.push(other),
1913 }
1914 }
1915 out
1916}
1917
1918fn xml_unescape(value: &str) -> String {
1924 value
1925 .replace("<", "<")
1926 .replace(">", ">")
1927 .replace(""", "\"")
1928 .replace("'", "'")
1929 .replace("&", "&")
1930}
1931
1932#[derive(Debug, Clone, PartialEq, Eq)]
1941pub struct WindowsServiceSpec {
1942 pub name: String,
1944 pub display_name: String,
1946 pub description: String,
1948 pub automatic_start: bool,
1950 pub account: Option<String>,
1953 pub command_line: String,
1955 pub restart: RestartPolicy,
1957}
1958
1959#[must_use]
1961pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1962 WindowsServiceSpec {
1963 name: plan.identity().name().to_string(),
1964 display_name: plan.identity().display_name().to_string(),
1965 description: plan.identity().description().to_string(),
1966 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1971 account: match ServiceAccount::for_definition(
1972 DefinitionKind::WindowsService,
1973 plan.start_mode(),
1974 ) {
1975 ServiceAccount::LocalSystem => None,
1978 other => Some(other.as_str().to_string()),
1979 },
1980 command_line: plan.command_line(),
1981 restart: plan.restart(),
1982 }
1983}
1984
1985#[must_use]
1988fn windows_service_descriptor(plan: &InstallPlan) -> String {
1989 let spec = windows_service_spec(plan);
1990 let mut out = String::new();
1991 out.push_str("[windows-service]\n");
1992 out.push_str(&format!("Name={}\n", spec.name));
1993 out.push_str(&format!("DisplayName={}\n", spec.display_name));
1994 out.push_str(&format!("Description={}\n", spec.description));
1995 out.push_str("ServiceType=OWN_PROCESS\n");
1999 out.push_str(&format!(
2000 "StartType={}\n",
2001 if spec.automatic_start {
2002 "AutoStart"
2003 } else {
2004 "OnDemand"
2005 }
2006 ));
2007 out.push_str("ErrorControl=Normal\n");
2008 out.push_str(&format!(
2009 "Account={}\n",
2010 spec.account
2011 .as_deref()
2012 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2013 ));
2014 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2015 out.push_str(&format!(
2016 "FailureActionRestartDelaySecs={}\n",
2017 spec.restart.delay().as_secs()
2018 ));
2019 out.push_str(&format!(
2020 "FailureActionsResetPeriodSecs={}\n",
2021 spec.restart.reset_after().as_secs()
2022 ));
2023 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2027 out.push_str(&format!(
2028 "ReadWritePaths={}\n",
2029 plan.directories()
2030 .all()
2031 .iter()
2032 .map(|path| quote_argument(&path.to_string_lossy()))
2033 .collect::<Vec<_>>()
2034 .join(" ")
2035 ));
2036 out
2037}
2038
2039#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2045pub enum FindingKind {
2046 Excess,
2049 Shortfall,
2052}
2053
2054impl fmt::Display for FindingKind {
2055 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2056 f.write_str(match self {
2057 Self::Excess => "excess",
2058 Self::Shortfall => "shortfall",
2059 })
2060 }
2061}
2062
2063#[derive(Debug, Clone, PartialEq, Eq)]
2065pub struct PrivilegeFinding {
2066 pub kind: FindingKind,
2068 pub subject: String,
2070 pub detail: String,
2072}
2073
2074impl fmt::Display for PrivilegeFinding {
2075 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2076 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2077 }
2078}
2079
2080#[derive(Debug, Clone, PartialEq, Eq)]
2094pub struct PrivilegeReview {
2095 kind: DefinitionKind,
2096 account: ServiceAccount,
2097 controls: Vec<String>,
2098 findings: Vec<PrivilegeFinding>,
2099}
2100
2101impl PrivilegeReview {
2102 #[must_use]
2104 pub fn is_least_privilege(&self) -> bool {
2105 !self
2106 .findings
2107 .iter()
2108 .any(|finding| finding.kind == FindingKind::Excess)
2109 }
2110
2111 #[must_use]
2113 pub fn findings(&self) -> &[PrivilegeFinding] {
2114 &self.findings
2115 }
2116
2117 #[must_use]
2120 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2121 self.findings
2122 .iter()
2123 .filter(|finding| finding.kind == FindingKind::Excess)
2124 .collect()
2125 }
2126
2127 #[must_use]
2133 pub fn controls(&self) -> &[String] {
2134 &self.controls
2135 }
2136
2137 #[must_use]
2139 pub const fn account(&self) -> &ServiceAccount {
2140 &self.account
2141 }
2142
2143 #[must_use]
2145 pub const fn kind(&self) -> DefinitionKind {
2146 self.kind
2147 }
2148}
2149
2150impl fmt::Display for PrivilegeReview {
2151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2152 writeln!(
2153 f,
2154 "{} runs as {} ({})",
2155 self.kind,
2156 self.account,
2157 self.account.justification()
2158 )?;
2159 for control in &self.controls {
2160 writeln!(f, " confirmed {control}")?;
2161 }
2162 for finding in &self.findings {
2163 writeln!(f, " {finding}")?;
2164 }
2165 if self.is_least_privilege() {
2166 write!(f, " verdict least privilege")
2167 } else {
2168 write!(
2169 f,
2170 " verdict NOT least privilege: {} excess(es)",
2171 self.excesses().len()
2172 )
2173 }
2174 }
2175}
2176
2177#[must_use]
2183pub fn review_least_privilege(
2184 definition: &ServiceDefinition,
2185 plan: &InstallPlan,
2186) -> PrivilegeReview {
2187 let mut controls = Vec::new();
2188 let mut findings = Vec::new();
2189 match definition.kind() {
2190 DefinitionKind::SystemdUnit => {
2191 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2192 }
2193 DefinitionKind::LaunchdPlist => {
2194 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2195 }
2196 DefinitionKind::WindowsScheduledTask => {
2197 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2198 }
2199 DefinitionKind::WindowsService => {
2200 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2201 }
2202 }
2203 PrivilegeReview {
2204 kind: definition.kind(),
2205 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2210 controls,
2211 findings,
2212 }
2213}
2214
2215fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2217 let mut permitted = plan
2218 .directories()
2219 .all()
2220 .iter()
2221 .map(|path| path.to_string_lossy().into_owned())
2222 .collect::<Vec<_>>();
2223 if kind == DefinitionKind::SystemdUnit
2224 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2225 {
2226 permitted.push(secret_directory.to_string_lossy().into_owned());
2227 }
2228 permitted
2229}
2230
2231fn same_path_text(left: &str, right: &str) -> bool {
2238 if cfg!(windows) {
2239 left.eq_ignore_ascii_case(right)
2240 } else {
2241 left == right
2242 }
2243}
2244
2245fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2248 match kind {
2249 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2250 left.eq_ignore_ascii_case(right)
2251 }
2252 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2253 }
2254}
2255
2256fn review_writable_paths(
2258 kind: DefinitionKind,
2259 subject: &str,
2260 listed: &[String],
2261 plan: &InstallPlan,
2262 controls: &mut Vec<String>,
2263 findings: &mut Vec<PrivilegeFinding>,
2264) {
2265 let permitted = permitted_paths(kind, plan);
2266 for entry in listed {
2267 if !permitted
2268 .iter()
2269 .any(|allowed| same_path_for(kind, allowed, entry))
2270 {
2271 findings.push(PrivilegeFinding {
2272 kind: FindingKind::Excess,
2273 subject: subject.to_string(),
2274 detail: format!(
2275 "{entry} is writable but is not one of this registration's required paths"
2276 ),
2277 });
2278 }
2279 }
2280 for allowed in &permitted {
2281 if !listed
2282 .iter()
2283 .any(|entry| same_path_for(kind, allowed, entry))
2284 {
2285 findings.push(PrivilegeFinding {
2286 kind: FindingKind::Shortfall,
2287 subject: subject.to_string(),
2288 detail: format!(
2289 "{allowed} is one of this registration's directories but is not writable, \
2290 so the daemon cannot use it"
2291 ),
2292 });
2293 }
2294 }
2295 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2296 controls.push(format!(
2297 "{subject} names exactly the required application-data and credential paths"
2298 ));
2299 }
2300}
2301
2302fn review_inbound_surface(
2310 text: &str,
2311 markers: &[(&str, &str)],
2312 controls: &mut Vec<String>,
2313 findings: &mut Vec<PrivilegeFinding>,
2314) {
2315 let mut clean = true;
2316 for (marker, detail) in markers {
2317 if text.contains(marker) {
2318 clean = false;
2319 findings.push(PrivilegeFinding {
2320 kind: FindingKind::Excess,
2321 subject: (*marker).to_string(),
2322 detail: (*detail).to_string(),
2323 });
2324 }
2325 }
2326 if clean {
2327 controls.push(
2328 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2329 );
2330 }
2331}
2332
2333fn review_systemd(
2334 text: &str,
2335 plan: &InstallPlan,
2336 controls: &mut Vec<String>,
2337 findings: &mut Vec<PrivilegeFinding>,
2338) {
2339 let directives = ini_directives(text, "Service");
2340 for expected in SYSTEMD_HARDENING {
2341 let (key, value) = expected
2342 .split_once('=')
2343 .expect("every hardening directive is written as key=value");
2344 match directives.get(key) {
2345 Some(actual) if actual == value => controls.push((*expected).to_string()),
2346 Some(actual) => findings.push(PrivilegeFinding {
2347 kind: FindingKind::Excess,
2348 subject: key.to_string(),
2349 detail: format!(
2350 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2351 requirement does not ask for"
2352 ),
2353 }),
2354 None => findings.push(PrivilegeFinding {
2355 kind: FindingKind::Excess,
2356 subject: key.to_string(),
2357 detail: format!(
2358 "is absent, so the unit inherits systemd's default rather than `{value}`"
2359 ),
2360 }),
2361 }
2362 }
2363
2364 match directives.get("ReadWritePaths") {
2365 Some(value) => {
2366 let listed = split_quoted(value);
2367 review_writable_paths(
2368 DefinitionKind::SystemdUnit,
2369 "ReadWritePaths",
2370 &listed,
2371 plan,
2372 controls,
2373 findings,
2374 );
2375 }
2376 None => findings.push(PrivilegeFinding {
2377 kind: FindingKind::Shortfall,
2378 subject: "ReadWritePaths".to_string(),
2379 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2380 .to_string(),
2381 }),
2382 }
2383
2384 if directives.contains_key("PrivateUsers")
2387 && directives.get("PrivateUsers") == Some(&"no".to_string())
2388 {
2389 findings.push(PrivilegeFinding {
2390 kind: FindingKind::Excess,
2391 subject: "PrivateUsers".to_string(),
2392 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2393 .to_string(),
2394 });
2395 }
2396
2397 review_inbound_surface(
2398 text,
2399 &[
2400 (
2401 "ListenStream=",
2402 "asks systemd to open a listening socket for this service, which \
2403 07-security.md rule 2 forbids the product to have",
2404 ),
2405 (
2406 "ListenDatagram=",
2407 "asks systemd to open a listening socket for this service, which \
2408 07-security.md rule 2 forbids the product to have",
2409 ),
2410 ],
2411 controls,
2412 findings,
2413 );
2414}
2415
2416fn review_launchd(
2417 text: &str,
2418 plan: &InstallPlan,
2419 controls: &mut Vec<String>,
2420 findings: &mut Vec<PrivilegeFinding>,
2421) {
2422 match plist_string_value(text, "ProcessType") {
2426 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2427 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2428 }
2429 found => findings.push(PrivilegeFinding {
2430 kind: FindingKind::Shortfall,
2431 subject: "ProcessType".to_string(),
2432 detail: format!(
2433 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2434 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2435 when it next starts with no runner; `service status` says how to restart it",
2436 found.map_or_else(
2437 || "absent (launchd's `Standard`)".to_string(),
2438 |value| format!("`{value}`")
2439 )
2440 ),
2441 }),
2442 }
2443
2444 match plan.start_mode() {
2445 StartMode::Boot => {
2446 if plist_bool_value(text, "SessionCreate") == Some(true) {
2447 findings.push(PrivilegeFinding {
2448 kind: FindingKind::Excess,
2449 subject: "SessionCreate".to_string(),
2450 detail: "asks launchd to create a security session for a job that runs \
2451 outside every login session and has no use for one"
2452 .to_string(),
2453 });
2454 } else {
2455 controls.push("SessionCreate is not requested".to_string());
2456 }
2457 match plist_string_value(text, "UserName").as_deref() {
2458 Some("root") => {
2459 controls.push("UserName=root, stated rather than inherited".to_string())
2460 }
2461 Some(other) => findings.push(PrivilegeFinding {
2462 kind: FindingKind::Shortfall,
2463 subject: "UserName".to_string(),
2464 detail: format!(
2465 "is `{other}`, which cannot unlock the System Keychain: \
2466 /var/db/SystemKey is root-only, so the daemon would start and then \
2467 find no credential"
2468 ),
2469 }),
2470 None => findings.push(PrivilegeFinding {
2471 kind: FindingKind::Shortfall,
2472 subject: "UserName".to_string(),
2473 detail: "is absent, so the account is launchd's implicit default and this \
2474 review cannot confirm it"
2475 .to_string(),
2476 }),
2477 }
2478 }
2479 StartMode::Login => {
2480 if let Some(named) = plist_string_value(text, "UserName") {
2481 findings.push(PrivilegeFinding {
2482 kind: FindingKind::Excess,
2483 subject: "UserName".to_string(),
2484 detail: format!(
2485 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2486 naming an account here asks launchd for a switch a login-mode \
2487 registration has no reason to want"
2488 ),
2489 });
2490 } else {
2491 controls
2492 .push("no UserName: the agent runs as the operator and no other".to_string());
2493 }
2494 }
2495 }
2496
2497 review_inbound_surface(
2498 text,
2499 &[
2500 (
2501 "<key>Sockets</key>",
2502 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2503 forbids the product to have",
2504 ),
2505 (
2506 "<key>MachServices</key>",
2507 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2508 forbids the product to have",
2509 ),
2510 ],
2511 controls,
2512 findings,
2513 );
2514}
2515
2516fn review_scheduled_task(
2517 text: &str,
2518 controls: &mut Vec<String>,
2519 findings: &mut Vec<PrivilegeFinding>,
2520) {
2521 match xml_value(text, "RunLevel").as_deref() {
2522 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2523 Some(other) => findings.push(PrivilegeFinding {
2524 kind: FindingKind::Excess,
2525 subject: "RunLevel".to_string(),
2526 detail: format!(
2527 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2528 an administrator"
2529 ),
2530 }),
2531 None => findings.push(PrivilegeFinding {
2532 kind: FindingKind::Excess,
2533 subject: "RunLevel".to_string(),
2534 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2535 .to_string(),
2536 }),
2537 }
2538
2539 match xml_value(text, "LogonType").as_deref() {
2540 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2541 Some(other) => findings.push(PrivilegeFinding {
2542 kind: FindingKind::Excess,
2543 subject: "LogonType".to_string(),
2544 detail: format!(
2545 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2546 task; an interactive token needs neither"
2547 ),
2548 }),
2549 None => findings.push(PrivilegeFinding {
2550 kind: FindingKind::Shortfall,
2551 subject: "LogonType".to_string(),
2552 detail: "is absent, so this review cannot confirm that no credential is stored"
2553 .to_string(),
2554 }),
2555 }
2556}
2557
2558fn review_windows_service(
2559 text: &str,
2560 plan: &InstallPlan,
2561 controls: &mut Vec<String>,
2562 findings: &mut Vec<PrivilegeFinding>,
2563) {
2564 let directives = ini_directives(text, "windows-service");
2565
2566 match directives.get("ServiceType").map(String::as_str) {
2567 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2568 Some(other) => findings.push(PrivilegeFinding {
2569 kind: FindingKind::Excess,
2570 subject: "ServiceType".to_string(),
2571 detail: format!(
2572 "is `{other}`; an interactive or shared-process service reaches further than a \
2573 daemon that only talks to GitHub over HTTPS"
2574 ),
2575 }),
2576 None => findings.push(PrivilegeFinding {
2577 kind: FindingKind::Shortfall,
2578 subject: "ServiceType".to_string(),
2579 detail: "is absent, so this review cannot confirm the service is not interactive"
2580 .to_string(),
2581 }),
2582 }
2583
2584 match directives.get("Account").map(String::as_str) {
2588 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2589 controls.push(format!(
2590 "Account={account}: the only stock account the machine-scoped store's DACL \
2591 (SY, BA, OW) admits"
2592 ));
2593 }
2594 Some(other) => findings.push(PrivilegeFinding {
2595 kind: FindingKind::Shortfall,
2596 subject: "Account".to_string(),
2597 detail: format!(
2598 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2599 daemon would start and then find no credential. Widening that DACL is not this \
2600 registration's to do: an ACE reaching `{other}` would also reach every other \
2601 service running under it"
2602 ),
2603 }),
2604 None => findings.push(PrivilegeFinding {
2605 kind: FindingKind::Shortfall,
2606 subject: "Account".to_string(),
2607 detail: "is absent, so this review cannot confirm which account was registered"
2608 .to_string(),
2609 }),
2610 }
2611
2612 match directives.get("ReadWritePaths") {
2613 Some(value) => {
2614 let listed = split_quoted(value);
2615 review_writable_paths(
2616 DefinitionKind::WindowsService,
2617 "ReadWritePaths",
2618 &listed,
2619 plan,
2620 controls,
2621 findings,
2622 );
2623 }
2624 None => findings.push(PrivilegeFinding {
2625 kind: FindingKind::Shortfall,
2626 subject: "ReadWritePaths".to_string(),
2627 detail: "is absent, so the directories the service was installed against are not \
2628 recorded"
2629 .to_string(),
2630 }),
2631 }
2632}
2633
2634fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2642 let mut out = BTreeMap::new();
2643 let mut inside = false;
2644 for line in text.lines() {
2645 let line = line.trim();
2646 if line.starts_with('[') && line.ends_with(']') {
2647 inside = &line[1..line.len() - 1] == section;
2648 continue;
2649 }
2650 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2651 continue;
2652 }
2653 if let Some((key, value)) = line.split_once('=') {
2654 out.insert(key.trim().to_string(), value.trim().to_string());
2655 }
2656 }
2657 out
2658}
2659
2660fn split_quoted(value: &str) -> Vec<String> {
2663 let mut out = Vec::new();
2664 let mut rest = value.trim();
2665 while !rest.is_empty() {
2666 if rest.starts_with('"') {
2667 if let Some(parsed) = executable_from_command_line(rest) {
2670 out.push(parsed.to_string_lossy().into_owned());
2671 }
2672 let mut depth = 0usize;
2674 let mut end = rest.len();
2675 for (index, c) in rest.char_indices() {
2676 match c {
2677 '\\' => depth += 1,
2678 '"' => {
2679 if depth.is_multiple_of(2) && index > 0 {
2680 end = index + 1;
2681 break;
2682 }
2683 depth = 0;
2684 }
2685 _ => depth = 0,
2686 }
2687 }
2688 rest = rest[end..].trim_start();
2689 } else {
2690 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2691 out.push(rest[..end].to_string());
2692 rest = rest[end..].trim_start();
2693 }
2694 }
2695 out
2696}
2697
2698pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2710 let open = format!("<{tag}>");
2711 let close = format!("</{tag}>");
2712 let start = text.find(&open)? + open.len();
2713 let end = text[start..].find(&close)? + start;
2714 Some(xml_unescape(text[start..end].trim()))
2715}
2716
2717#[cfg(any(windows, test))]
2723fn windows_login_task_starts_automatically(document: &str) -> bool {
2724 document.contains("<LogonTrigger>")
2725 && xml_value(document, "Enabled").as_deref() != Some("false")
2726}
2727
2728fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2730 let marker = format!("<key>{key}</key>");
2731 let start = text.find(&marker)? + marker.len();
2732 Some(text[start..].trim_start())
2733}
2734
2735pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
2736 let rest = plist_value_after_key(text, key)?;
2737 if !rest.starts_with("<string>") {
2738 return None;
2739 }
2740 xml_value(rest, "string")
2741}
2742
2743fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2744 let rest = plist_value_after_key(text, key)?;
2745 if rest.starts_with("<true/>") {
2746 Some(true)
2747 } else if rest.starts_with("<false/>") {
2748 Some(false)
2749 } else {
2750 None
2751 }
2752}
2753
2754pub const RECORD_SCHEMA_VERSION: u32 = 1;
2765
2766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2778pub struct InstallRecord {
2779 pub schema_version: u32,
2781 pub service_name: String,
2783 pub manager: String,
2785 pub start_mode: StartMode,
2787 pub account: ServiceAccount,
2789 pub binary: PathBuf,
2791 #[serde(default)]
2799 pub source_binary: Option<PathBuf>,
2800 pub arguments: Vec<String>,
2802 pub restart_delay_secs: u64,
2804 pub restart_reset_secs: u64,
2806 pub log_file: PathBuf,
2808 #[serde(default)]
2816 pub starts_on_demand: bool,
2817 pub definition_path: Option<PathBuf>,
2819 pub installed_at: DateTime<Utc>,
2821 pub installed_by_version: String,
2823 pub directories: ServiceDirectories,
2828}
2829
2830impl InstallRecord {
2831 #[must_use]
2833 pub fn path(paths: &AppPaths) -> PathBuf {
2834 paths.config_dir().join(RECORD_FILE)
2835 }
2836
2837 #[must_use]
2839 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2840 Self {
2841 schema_version: RECORD_SCHEMA_VERSION,
2842 service_name: plan.identity().name().to_string(),
2843 manager: definition.kind().manager().to_string(),
2844 start_mode: plan.start_mode(),
2845 account: plan.account().clone(),
2846 binary: plan.binary().to_path_buf(),
2847 arguments: plan
2848 .arguments()
2849 .iter()
2850 .map(|argument| argument.to_string_lossy().into_owned())
2851 .collect(),
2852 restart_delay_secs: plan.restart().delay().as_secs(),
2853 restart_reset_secs: plan.restart().reset_after().as_secs(),
2854 starts_on_demand: plan.is_on_demand(),
2855 source_binary: plan.source_binary().map(Path::to_path_buf),
2856 log_file: plan.directories().log_file(),
2857 definition_path: definition.install_path().map(Path::to_path_buf),
2858 installed_at: at,
2859 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2860 directories: plan.directories().clone(),
2861 }
2862 }
2863
2864 #[must_use]
2872 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2873 let plan = InstallPlan::unchecked(
2874 identity,
2875 self.start_mode,
2876 self.binary.clone(),
2877 self.directories.clone(),
2878 )
2879 .with_arguments(self.arguments.clone())
2880 .with_restart(self.restart());
2881 let plan = if self.starts_on_demand {
2882 plan.started_on_demand()
2883 } else {
2884 plan
2885 };
2886 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2887 Ok(store) => plan.with_secret_guard(store.guard()),
2888 Err(_) => plan,
2889 }
2890 }
2891
2892 #[must_use]
2896 pub fn restart(&self) -> RestartPolicy {
2897 RestartPolicy::new(
2898 Duration::from_secs(self.restart_delay_secs),
2899 Duration::from_secs(self.restart_reset_secs),
2900 )
2901 .unwrap_or_default()
2902 }
2903
2904 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2915 let path = Self::path(paths);
2916 let text = match std::fs::read_to_string(&path) {
2917 Ok(text) => text,
2918 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2919 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2922 return Err(ServiceError::RecordNotPermitted {
2923 path,
2924 detail: error.to_string(),
2925 });
2926 }
2927 Err(error) => {
2928 return Err(ServiceError::Record {
2929 operation: "read",
2930 path,
2931 detail: error.to_string(),
2932 });
2933 }
2934 };
2935 let record: Self =
2936 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2937 path: path.clone(),
2938 detail: error.to_string(),
2939 })?;
2940 if record.schema_version != RECORD_SCHEMA_VERSION {
2941 return Err(ServiceError::RecordUnreadable {
2942 path,
2943 detail: format!(
2944 "it declares schema version {} and this build reads version {}",
2945 record.schema_version, RECORD_SCHEMA_VERSION
2946 ),
2947 });
2948 }
2949 Ok(Some(record))
2950 }
2951
2952 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2958 use std::io::Write as _;
2959
2960 let path = Self::path(paths);
2961 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2962 operation: "encode",
2963 path: path.clone(),
2964 detail: error.to_string(),
2965 })?;
2966 if let Some(parent) = path.parent() {
2967 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2968 operation: "write",
2969 path: path.clone(),
2970 detail: error.to_string(),
2971 })?;
2972 }
2973 let parent = path.parent().ok_or_else(|| ServiceError::Record {
2974 operation: "write",
2975 path: path.clone(),
2976 detail: "the record path has no parent directory".to_string(),
2977 })?;
2978 let mut temporary =
2979 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2980 operation: "write",
2981 path: path.clone(),
2982 detail: error.to_string(),
2983 })?;
2984 temporary
2985 .write_all(text.as_bytes())
2986 .and_then(|()| temporary.as_file().sync_all())
2987 .map_err(|error| ServiceError::Record {
2988 operation: "write",
2989 path: path.clone(),
2990 detail: error.to_string(),
2991 })?;
2992 #[cfg(unix)]
3008 {
3009 use std::os::unix::fs::PermissionsExt as _;
3010
3011 temporary
3012 .as_file()
3013 .set_permissions(std::fs::Permissions::from_mode(0o644))
3014 .map_err(|error| ServiceError::Record {
3015 operation: "write",
3016 path: path.clone(),
3017 detail: error.to_string(),
3018 })?;
3019 }
3020 temporary
3021 .persist(&path)
3022 .map(|_| ())
3023 .map_err(|error| ServiceError::Record {
3024 operation: "write",
3025 path,
3026 detail: error.error.to_string(),
3027 })
3028 }
3029
3030 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3036 let path = Self::path(paths);
3037 match std::fs::remove_file(&path) {
3038 Ok(()) => Ok(true),
3039 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3040 Err(error) => Err(ServiceError::Record {
3041 operation: "remove",
3042 path,
3043 detail: error.to_string(),
3044 }),
3045 }
3046 }
3047}
3048
3049const CONTACT_SCHEMA_VERSION: u32 = 1;
3055
3056#[derive(Debug, Clone, Serialize, Deserialize)]
3057struct ContactRecord {
3058 schema_version: u32,
3059 last_success: DateTime<Utc>,
3060}
3061
3062pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3083 write_state_record(
3084 &contact_path(paths),
3085 &ContactRecord {
3086 schema_version: CONTACT_SCHEMA_VERSION,
3087 last_success: at,
3088 },
3089 )
3090}
3091
3092pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3104 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3105}
3106
3107#[must_use]
3109pub fn contact_path(paths: &AppPaths) -> PathBuf {
3110 paths.state_dir().join(CONTACT_FILE)
3111}
3112
3113pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3116 let failed = |detail: String| ServiceError::Record {
3117 operation: "write",
3118 path: path.to_path_buf(),
3119 detail,
3120 };
3121 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3122 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3123 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3124 let temporary = path.with_extension("toml.new");
3125 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3126 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3127}
3128
3129pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3132 path: &Path,
3133) -> Result<Option<T>, ServiceError> {
3134 let failed = |detail: String| ServiceError::Record {
3135 operation: "read",
3136 path: path.to_path_buf(),
3137 detail,
3138 };
3139 let text = match std::fs::read_to_string(path) {
3140 Ok(text) => text,
3141 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3142 Err(error) => return Err(failed(error.to_string())),
3143 };
3144 toml::from_str(&text)
3145 .map(Some)
3146 .map_err(|error| failed(error.to_string()))
3147}
3148
3149pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3151 match std::fs::remove_file(path) {
3152 Ok(()) => Ok(()),
3153 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3154 Err(error) => Err(ServiceError::Record {
3155 operation: "remove",
3156 path: path.to_path_buf(),
3157 detail: error.to_string(),
3158 }),
3159 }
3160}
3161
3162#[derive(Debug, Serialize, Deserialize)]
3167#[serde(deny_unknown_fields)]
3168struct CredentialRejectionRecord {
3169 schema_version: u32,
3170 rejected_since: DateTime<Utc>,
3171}
3172
3173pub fn record_github_credential_rejected(
3189 paths: &AppPaths,
3190 at: DateTime<Utc>,
3191) -> Result<(), ServiceError> {
3192 if github_credential_rejected_since(paths)
3193 .ok()
3194 .flatten()
3195 .is_some()
3196 {
3197 return Ok(());
3198 }
3199 write_state_record(
3200 &credential_rejection_path(paths),
3201 &CredentialRejectionRecord {
3202 schema_version: CONTACT_SCHEMA_VERSION,
3203 rejected_since: at,
3204 },
3205 )
3206}
3207
3208pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3214 remove_state_record(&credential_rejection_path(paths))
3215}
3216
3217pub fn github_credential_rejected_since(
3223 paths: &AppPaths,
3224) -> Result<Option<DateTime<Utc>>, ServiceError> {
3225 Ok(
3226 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3227 .map(|record| record.rejected_since),
3228 )
3229}
3230
3231#[must_use]
3233pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3234 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3235}
3236
3237#[derive(Debug, Serialize, Deserialize)]
3238#[serde(deny_unknown_fields)]
3239struct CredentialUnreadableRecord {
3240 schema_version: u32,
3241 unreadable_since: DateTime<Utc>,
3242}
3243
3244pub fn record_credential_unreadable(
3263 paths: &AppPaths,
3264 at: DateTime<Utc>,
3265) -> Result<(), ServiceError> {
3266 if credential_unreadable_since(paths).ok().flatten().is_some() {
3267 return Ok(());
3268 }
3269 write_state_record(
3270 &credential_unreadable_path(paths),
3271 &CredentialUnreadableRecord {
3272 schema_version: CONTACT_SCHEMA_VERSION,
3273 unreadable_since: at,
3274 },
3275 )
3276}
3277
3278pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3284 remove_state_record(&credential_unreadable_path(paths))
3285}
3286
3287pub fn credential_unreadable_since(
3294 paths: &AppPaths,
3295) -> Result<Option<DateTime<Utc>>, ServiceError> {
3296 Ok(
3297 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3298 .map(|record| record.unreadable_since),
3299 )
3300}
3301
3302#[must_use]
3304pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3305 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3306}
3307
3308#[must_use]
3316pub fn sign_in_instruction(mode: StartMode) -> String {
3317 let (command, place) = match mode {
3318 StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
3319 StartMode::Boot => (
3320 "runner-manager auth login --start-at boot",
3321 " from an elevated terminal",
3322 ),
3323 StartMode::Login => (
3324 "runner-manager auth login --start-at login",
3325 MACOS_LOGIN_PLACE,
3326 ),
3327 };
3328 format!("`{command}`{place}")
3329}
3330
3331pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3334 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3335 keychain)"
3336} else {
3337 ""
3338};
3339
3340#[derive(Debug, Clone, PartialEq, Eq)]
3357pub struct DefinitionDrift {
3358 pub path: PathBuf,
3360 pub installed_by_version: String,
3362 pub rendered: String,
3364 pub start_mode: StartMode,
3366 pub manager: DefinitionKind,
3368}
3369
3370fn definition_drift(
3378 controls: &dyn ControlFactory,
3379 identity: &ServiceIdentity,
3380 record: &InstallRecord,
3381) -> Option<DefinitionDrift> {
3382 let compare = match host_definition_kind(record.start_mode) {
3383 DefinitionKind::SystemdUnit => systemd_definition_drift,
3384 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3385 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3386 };
3387 let path = record.definition_path.clone()?;
3388 let installed = controls.installed_definition(&path)?;
3389 compare(identity, record, path, &installed)
3390}
3391
3392fn launchd_definition_drift(
3401 identity: &ServiceIdentity,
3402 record: &InstallRecord,
3403 path: PathBuf,
3404 installed: &str,
3405) -> Option<DefinitionDrift> {
3406 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3407 || DefinitionDrift {
3408 path,
3409 installed_by_version: record.installed_by_version.clone(),
3410 rendered: launchd_plist(&record.plan(identity.clone())),
3411 start_mode: record.start_mode,
3412 manager: DefinitionKind::LaunchdPlist,
3413 },
3414 )
3415}
3416
3417fn systemd_definition_drift(
3418 identity: &ServiceIdentity,
3419 record: &InstallRecord,
3420 path: PathBuf,
3421 installed: &str,
3422) -> Option<DefinitionDrift> {
3423 let rendered = systemd_unit(&record.plan(identity.clone()));
3424 (installed != rendered).then(|| DefinitionDrift {
3425 path,
3426 installed_by_version: record.installed_by_version.clone(),
3427 rendered,
3428 start_mode: record.start_mode,
3429 manager: DefinitionKind::SystemdUnit,
3430 })
3431}
3432
3433const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3438
3439#[derive(Debug, Clone, Serialize, Deserialize)]
3440struct RootRefusalFile {
3441 schema_version: u32,
3442 #[serde(default)]
3444 refusals: BTreeMap<String, RootRefusalEntry>,
3445}
3446
3447#[derive(Debug, Clone, Serialize, Deserialize)]
3448struct RootRefusalEntry {
3449 at: DateTime<Utc>,
3450 kind: String,
3451 root: String,
3452 detail: String,
3453}
3454
3455#[derive(Debug, Clone, PartialEq, Eq)]
3457pub struct RunnerRootRefusal {
3458 pub policy: String,
3460 pub at: DateTime<Utc>,
3462 pub kind: String,
3464 pub root: String,
3466 pub detail: String,
3469}
3470
3471pub fn record_runner_root_refusal(
3505 paths: &AppPaths,
3506 policy: &str,
3507 at: DateTime<Utc>,
3508 kind: &str,
3509 root: &str,
3510 detail: &str,
3511) -> Result<(), ServiceError> {
3512 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3513 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3514 refusals: BTreeMap::new(),
3515 });
3516 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3517 file.refusals.insert(
3518 policy.to_owned(),
3519 RootRefusalEntry {
3520 at,
3521 kind: kind.to_owned(),
3522 root: root.to_owned(),
3523 detail: detail.to_owned(),
3524 },
3525 );
3526 write_refusal_file(paths, &file)
3527}
3528
3529pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3540 let Some(mut file) = read_refusal_file(paths)? else {
3541 return Ok(());
3542 };
3543 if file.refusals.remove(policy).is_none() {
3544 return Ok(());
3545 }
3546 if file.refusals.is_empty() {
3547 let path = root_refusal_path(paths);
3548 return match std::fs::remove_file(&path) {
3549 Ok(()) => Ok(()),
3550 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3551 Err(error) => Err(ServiceError::Record {
3552 operation: "remove",
3553 path,
3554 detail: error.to_string(),
3555 }),
3556 };
3557 }
3558 write_refusal_file(paths, &file)
3559}
3560
3561pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3573 Ok(read_refusal_file(paths)?
3574 .map(|file| {
3575 file.refusals
3576 .into_iter()
3577 .map(|(policy, entry)| RunnerRootRefusal {
3578 policy,
3579 at: entry.at,
3580 kind: entry.kind,
3581 root: entry.root,
3582 detail: entry.detail,
3583 })
3584 .collect()
3585 })
3586 .unwrap_or_default())
3587}
3588
3589fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3590 read_state_record(&root_refusal_path(paths))
3591}
3592
3593fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3594 write_state_record(&root_refusal_path(paths), file)
3595}
3596
3597#[must_use]
3599pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3600 paths.state_dir().join(ROOT_REFUSAL_FILE)
3601}
3602
3603#[derive(Debug, Clone, PartialEq, Eq)]
3614pub enum BinaryPath {
3615 Current {
3617 path: PathBuf,
3619 },
3620 Missing {
3627 recorded: PathBuf,
3629 },
3630 NotExecutable {
3633 recorded: PathBuf,
3635 detail: String,
3637 },
3638 Diverged {
3640 recorded: PathBuf,
3642 registered: PathBuf,
3644 },
3645}
3646
3647impl BinaryPath {
3648 #[must_use]
3650 pub const fn is_error(&self) -> bool {
3651 !matches!(self, Self::Current { .. })
3652 }
3653
3654 #[must_use]
3656 pub fn recorded(&self) -> &Path {
3657 match self {
3658 Self::Current { path } => path,
3659 Self::Missing { recorded }
3660 | Self::NotExecutable { recorded, .. }
3661 | Self::Diverged { recorded, .. } => recorded,
3662 }
3663 }
3664}
3665
3666impl fmt::Display for BinaryPath {
3667 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3668 match self {
3669 Self::Current { path } => write!(f, "{}", path.display()),
3670 Self::Missing { recorded } => write!(
3671 f,
3672 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3673 package manager that moved the binary is the usual cause; an `npm i -g` \
3674 installation moves with the active Node version. Run `service install` again \
3675 from the binary that is now installed.",
3676 recorded.display()
3677 ),
3678 Self::NotExecutable { recorded, detail } => write!(
3679 f,
3680 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3681 from the installed binary.",
3682 recorded.display()
3683 ),
3684 Self::Diverged {
3685 recorded,
3686 registered,
3687 } => write!(
3688 f,
3689 "{} -- STALE: the service manager is registered to start {} instead. Something \
3690 has edited the registration since it was installed. Run `service uninstall` and \
3691 `service install`; neither touches configuration, secrets, or the cache.",
3692 recorded.display(),
3693 registered.display()
3694 ),
3695 }
3696 }
3697}
3698
3699#[must_use]
3707pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3708 match std::fs::metadata(recorded) {
3709 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3710 return BinaryPath::Missing {
3711 recorded: recorded.to_path_buf(),
3712 };
3713 }
3714 Err(error) => {
3715 return BinaryPath::NotExecutable {
3716 recorded: recorded.to_path_buf(),
3717 detail: format!("it cannot be inspected ({error})"),
3718 };
3719 }
3720 Ok(metadata) if !metadata.is_file() => {
3721 return BinaryPath::NotExecutable {
3722 recorded: recorded.to_path_buf(),
3723 detail: "what is there is not a file".to_string(),
3724 };
3725 }
3726 Ok(_) => {}
3727 }
3728 if let Some(registered) = registered
3729 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3730 {
3731 return BinaryPath::Diverged {
3732 recorded: recorded.to_path_buf(),
3733 registered: registered.to_path_buf(),
3734 };
3735 }
3736 BinaryPath::Current {
3737 path: recorded.to_path_buf(),
3738 }
3739}
3740
3741#[derive(Debug, Clone, PartialEq, Eq)]
3747pub struct Registration {
3748 pub manager: DefinitionKind,
3753 pub start_mode: StartMode,
3755 pub command_line: String,
3757 pub account: Option<String>,
3759 pub running: bool,
3761 pub starts_automatically: bool,
3768 pub restart_delay: Option<Duration>,
3777}
3778
3779impl Registration {
3780 #[must_use]
3782 pub fn binary(&self) -> Option<PathBuf> {
3783 executable_from_command_line(&self.command_line)
3784 }
3785}
3786
3787pub trait ServiceControl: fmt::Debug {
3793 fn manager(&self) -> DefinitionKind;
3795
3796 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3802
3803 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3814
3815 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3821
3822 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3829
3830 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3836}
3837
3838pub trait ControlFactory: fmt::Debug + Send + Sync {
3844 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3850
3851 fn installed_definition(&self, _path: &Path) -> Option<String> {
3855 None
3856 }
3857}
3858
3859#[derive(Debug, Clone, Copy, Default)]
3861pub struct HostControls;
3862
3863#[derive(Debug, Clone)]
3869pub struct Installed {
3870 pub plan: InstallPlan,
3872 pub definition: ServiceDefinition,
3874 pub record: InstallRecord,
3876 pub review: PrivilegeReview,
3878 pub runner_root: RootAccessSummary,
3883 pub replaced_existing: bool,
3891}
3892
3893#[derive(Debug, Clone, PartialEq, Eq)]
3895pub struct Uninstalled {
3896 pub removed_registration: bool,
3898 pub removed_record: bool,
3900 pub removed_definition: Option<PathBuf>,
3902 pub preserved: Vec<PathBuf>,
3909}
3910
3911impl fmt::Display for Uninstalled {
3912 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3913 if self.removed_registration {
3914 writeln!(f, "The service registration was removed.")?;
3915 } else {
3916 writeln!(f, "There was no service registration to remove.")?;
3917 }
3918 writeln!(f, "Nothing else was deleted. These are untouched:")?;
3919 for path in &self.preserved {
3920 writeln!(f, " {}", path.display())?;
3921 }
3922 write!(
3923 f,
3924 "The stored GitHub token is untouched too; `auth logout` is what purges it."
3925 )
3926 }
3927}
3928
3929#[derive(Debug, Clone, PartialEq, Eq)]
3931pub struct StartModeChange {
3932 pub from: StartMode,
3934 pub to: StartMode,
3936 pub changed: bool,
3938 pub store_scope: crate::secrets::SecretScope,
3941 pub runner_root: RootAccessSummary,
3947}
3948
3949impl fmt::Display for StartModeChange {
3950 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3951 if !self.changed {
3952 return write!(f, "The service already starts at {}.", self.to);
3953 }
3954 write!(
3955 f,
3956 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3957 if the token was stored under the other scope, run `auth login` again. {}",
3958 self.to, self.from, self.store_scope, self.runner_root
3959 )
3960 }
3961}
3962
3963#[derive(Debug, Clone)]
3971pub struct ServiceOperations {
3972 paths: AppPaths,
3973 identity: ServiceIdentity,
3974 controls: std::sync::Arc<dyn ControlFactory>,
3975 runner_root: Option<LocalAbsolutePath>,
3976}
3977
3978impl ServiceOperations {
3979 #[must_use]
3981 pub fn on_this_host(paths: AppPaths) -> Self {
3982 Self::with_controls(
3983 paths,
3984 ServiceIdentity::product(),
3985 std::sync::Arc::new(HostControls),
3986 )
3987 }
3988
3989 #[must_use]
3997 pub fn with_controls(
3998 paths: AppPaths,
3999 identity: ServiceIdentity,
4000 controls: std::sync::Arc<dyn ControlFactory>,
4001 ) -> Self {
4002 Self {
4003 paths,
4004 identity,
4005 controls,
4006 runner_root: None,
4007 }
4008 }
4009
4010 #[must_use]
4029 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4030 self.runner_root = Some(root);
4031 self
4032 }
4033
4034 #[must_use]
4036 pub const fn paths(&self) -> &AppPaths {
4037 &self.paths
4038 }
4039
4040 #[must_use]
4042 pub const fn identity(&self) -> &ServiceIdentity {
4043 &self.identity
4044 }
4045
4046 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4066 self.paths
4067 .create_all()
4068 .map_err(|source| ServiceError::Paths {
4069 source: Box::new(source),
4070 })?;
4071
4072 let _guard = self.refuse_while_an_agent_runs()?;
4075
4076 let (replacing, previous_was_running) = match self.find_registration()? {
4105 Some((existing, registration)) if existing == request.start_mode() => {
4106 (true, registration.running)
4107 }
4108 Some((existing, _)) => {
4109 return Err(ServiceError::AlreadyInstalled {
4110 name: self.identity.name().to_string(),
4111 existing,
4112 requested: request.start_mode(),
4113 });
4114 }
4115 None => (false, false),
4116 };
4117
4118 let plan = InstallPlan::resolve(
4119 self.identity.clone(),
4120 request,
4121 ServiceDirectories::of(&self.paths),
4122 )?;
4123
4124 let control = self.controls.control(plan.start_mode())?;
4129
4130 let root = self.prepare_runner_root(plan.start_mode())?;
4136
4137 let previous = if replacing {
4142 InstallRecord::read(&self.paths).ok().flatten()
4143 } else {
4144 None
4145 };
4146 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4147 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4148 }
4149
4150 let definition = match control.install(&plan) {
4151 Ok(definition) => definition,
4152 Err(cause) => {
4153 let restored =
4156 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4157 return Err(rolled_back(
4158 retained_runner_root(&root),
4159 restored,
4160 "install",
4161 &self.identity,
4162 cause,
4163 ));
4164 }
4165 };
4166 let review = review_least_privilege(&definition, &plan);
4167 let record = InstallRecord::of(&plan, &definition, Utc::now());
4168 if let Err(cause) = record.write(&self.paths) {
4169 let rollback = control
4170 .uninstall(&self.identity)
4171 .map(|_| ())
4172 .and_then(|()| {
4173 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4174 });
4175 return Err(rolled_back(
4176 retained_runner_root(&root),
4177 rollback,
4178 "install",
4179 &self.identity,
4180 cause,
4181 ));
4182 }
4183
4184 if let Err(cause) = control.start(&self.identity) {
4191 let remove_new = control.uninstall(&self.identity).map(|_| ());
4192 let restore_registration = remove_new.and_then(|()| {
4193 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4194 });
4195 let restore_record = restore_registration.and_then(|()| match &previous {
4196 Some(previous) => previous.write(&self.paths),
4197 None => InstallRecord::remove(&self.paths).map(|_| ()),
4198 });
4199 return Err(rolled_back(
4200 retained_runner_root(&root),
4201 restore_record,
4202 "install and start",
4203 &self.identity,
4204 cause,
4205 ));
4206 }
4207 Ok(Installed {
4208 plan,
4209 definition,
4210 record,
4211 review,
4212 runner_root: root.summary().clone(),
4213 replaced_existing: replacing,
4214 })
4215 }
4216
4217 fn reinstate(
4228 &self,
4229 control: &dyn ServiceControl,
4230 previous: Option<&InstallRecord>,
4231 was_running: bool,
4232 ) -> Result<(), ServiceError> {
4233 let Some(record) = previous else {
4234 return Ok(());
4235 };
4236 control.install(&record.plan(self.identity.clone()))?;
4237 if was_running {
4238 control.start(&self.identity)?;
4239 }
4240 Ok(())
4241 }
4242
4243 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4252 let record = InstallRecord::read(&self.paths).ok().flatten();
4253 let removed_definition = record
4254 .as_ref()
4255 .and_then(|record| record.definition_path.clone());
4256
4257 let mut removed_registration = false;
4258 for mode in [StartMode::Boot, StartMode::Login] {
4262 let control = self.controls.control(mode)?;
4263 if control.uninstall(&self.identity)? {
4264 removed_registration = true;
4265 }
4266 }
4267 let removed_record = InstallRecord::remove(&self.paths)?;
4268 Ok(Uninstalled {
4269 removed_registration,
4270 removed_record,
4271 removed_definition: removed_definition.filter(|_| removed_registration),
4272 preserved: self
4273 .paths
4274 .all()
4275 .iter()
4276 .map(|(_, path)| (*path).to_path_buf())
4277 .collect(),
4278 })
4279 }
4280
4281 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4303 let Some(record) = InstallRecord::read(&self.paths)? else {
4304 return Err(ServiceError::NotInstalled {
4305 name: self.identity.name().to_string(),
4306 operation: "switch the start mode of",
4307 });
4308 };
4309 let from = record.start_mode;
4310 if from == to {
4311 return Ok(StartModeChange {
4315 from,
4316 to,
4317 changed: false,
4318 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4319 runner_root: RootAccessSummary::NotApplicable,
4320 });
4321 }
4322
4323 #[cfg(windows)]
4324 let arguments = {
4325 let mut arguments = record.arguments.clone();
4326 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4327 if to == StartMode::Boot {
4328 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4329 }
4330 arguments
4331 };
4332 #[cfg(not(windows))]
4333 let arguments = record.arguments.clone();
4334 let plan = InstallPlan::unchecked(
4335 self.identity.clone(),
4336 to,
4337 record.binary.clone(),
4338 record.directories.clone(),
4339 )
4340 .with_arguments(arguments)
4341 .with_restart(record.restart());
4342 let plan = if record.starts_on_demand {
4343 plan.started_on_demand()
4344 } else {
4345 plan
4346 };
4347 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4348 Ok(store) => plan.with_secret_guard(store.guard()),
4349 Err(_) => plan,
4350 };
4351
4352 let target = self.controls.control(to)?;
4359 let previous = self.controls.control(from)?;
4364
4365 let root = self.prepare_runner_root(to)?;
4373
4374 let definition = match target.install(&plan) {
4375 Ok(definition) => definition,
4376 Err(cause) => {
4377 return Err(undo_runner_root(
4378 &root,
4379 "switch start mode",
4380 &self.identity,
4381 cause,
4382 ));
4383 }
4384 };
4385 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4386 if let Err(cause) = next_record.write(&self.paths) {
4387 return Err(rolled_back(
4388 retained_runner_root(&root),
4389 target.uninstall(&self.identity),
4390 "switch start mode",
4391 &self.identity,
4392 cause,
4393 ));
4394 }
4395
4396 if let Err(cause) = previous.uninstall(&self.identity) {
4400 let target_rollback = target.uninstall(&self.identity);
4401 let record_rollback = record.write(&self.paths);
4402 return Err(rolled_back(
4403 retained_runner_root(&root),
4404 target_rollback.and(record_rollback),
4405 "switch start mode",
4406 &self.identity,
4407 cause,
4408 ));
4409 }
4410 Ok(StartModeChange {
4411 from,
4412 to,
4413 changed: true,
4414 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4415 runner_root: root.summary().clone(),
4416 })
4417 }
4418
4419 pub fn start(&self) -> Result<(), ServiceError> {
4425 let Some((mode, _)) = self.find_registration()? else {
4426 return Err(ServiceError::NotInstalled {
4427 name: self.identity.name().to_string(),
4428 operation: "start",
4429 });
4430 };
4431 self.controls.control(mode)?.start(&self.identity)
4432 }
4433
4434 pub fn stop(&self) -> Result<bool, ServiceError> {
4440 let Some((mode, _)) = self.find_registration()? else {
4441 return Err(ServiceError::NotInstalled {
4442 name: self.identity.name().to_string(),
4443 operation: "stop",
4444 });
4445 };
4446 self.controls.control(mode)?.stop(&self.identity)
4447 }
4448
4449 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4468 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4469 Ok(record) => (record, None),
4470 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4471 Err(error) => return Err(error),
4472 };
4473 let found = self.find_registration()?;
4474 let last_github_contact = last_github_contact(&self.paths)?;
4475 let drift = record
4476 .as_ref()
4477 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4478 let credential_rejected_since =
4479 github_credential_rejected_since(&self.paths).ok().flatten();
4480 let credential_unreadable_since = credential_unreadable_since(&self.paths).ok().flatten();
4481 Ok(ServiceStatus::compose(
4482 self.identity.clone(),
4483 record,
4484 record_refused.as_ref(),
4485 found.map(|(_, registration)| registration),
4486 last_github_contact,
4487 &self.paths,
4488 )
4489 .with_definition_drift(drift)
4490 .with_credential_rejection(credential_rejected_since)
4491 .with_credential_unreadable(credential_unreadable_since)
4492 .with_launches_blocked(crate::launch_health::launches_blocked(
4493 &self.paths,
4494 Utc::now(),
4495 )))
4496 }
4497
4498 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4505 Ok(InstallRecord::read(&self.paths)?
4506 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4507 }
4508
4509 #[cfg(target_os = "macos")]
4531 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4532 let root = unsafe { libc::getuid() } == 0;
4538 if root != (drift.start_mode == StartMode::Boot) {
4539 return Err(ServiceError::Control {
4540 operation: "rewrite",
4541 name: self.identity.name().to_string(),
4542 manager: "launchd",
4543 detail: format!(
4544 "the install record says --start-at {}, but this daemon runs as {}; the plist \
4545 is left as it is",
4546 drift.start_mode,
4547 if root { "root" } else { "the operator" }
4548 ),
4549 });
4550 }
4551 replace_definition(self.identity.name(), drift)?;
4552 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4556 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4557 let _ = record.write(&self.paths);
4558 }
4559 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4560 }
4561
4562 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4572 #[cfg(not(windows))]
4573 {
4574 let _ = (mode, &self.runner_root);
4577 Ok(RootAccessChange::not_applicable())
4578 }
4579 #[cfg(windows)]
4580 {
4581 let wrap = |source| ServiceError::RunnerRoot {
4582 source: Box::new(source),
4583 };
4584 if let Some(root) = self
4602 .runner_root
4603 .as_ref()
4604 .filter(|_| self.identity.is_fixture() || cfg!(test))
4605 {
4606 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4607 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4608 .map_err(wrap);
4609 }
4610
4611 let admission = match ServiceAccount::for_start_mode(mode) {
4612 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4615 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4619 RootAdmission::of_this_account().map_err(wrap)?
4620 }
4621 };
4622 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4623 }
4624 }
4625
4626 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4628 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4629 |source| match source {
4630 held @ LockError::Held { .. } => ServiceError::LockHeld {
4631 source: Box::new(held),
4632 },
4633 other => ServiceError::LockUnreadable {
4634 source: Box::new(other),
4635 },
4636 },
4637 )
4638 }
4639
4640 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4642 for mode in [StartMode::Boot, StartMode::Login] {
4643 let control = self.controls.control(mode)?;
4644 if let Some(registration) = control.query(&self.identity)? {
4645 return Ok(Some((registration.start_mode, registration)));
4646 }
4647 }
4648 Ok(None)
4649 }
4650}
4651
4652#[derive(Debug, Clone, PartialEq, Eq)]
4658pub struct StatusProblem {
4659 pub subject: &'static str,
4661 pub detail: String,
4663}
4664
4665impl fmt::Display for StatusProblem {
4666 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4667 write!(f, "{}: {}", self.subject, self.detail)
4668 }
4669}
4670
4671#[derive(Debug, Clone)]
4680pub struct ServiceStatus {
4681 identity: ServiceIdentity,
4682 record: Option<InstallRecord>,
4683 registration: Option<Registration>,
4684 binary: Option<BinaryPath>,
4685 log_file: PathBuf,
4686 store: Option<crate::secrets::ActiveStore>,
4687 last_github_contact: Option<DateTime<Utc>>,
4688 runner_root: Option<(PathBuf, RootAccessReport)>,
4689 credential_rejected_since: Option<DateTime<Utc>>,
4690 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
4691 problems: Vec<StatusProblem>,
4692 notes: Vec<String>,
4693}
4694
4695impl ServiceStatus {
4696 fn compose(
4701 identity: ServiceIdentity,
4702 record: Option<InstallRecord>,
4703 record_refused: Option<&ServiceError>,
4704 registration: Option<Registration>,
4705 last_github_contact: Option<DateTime<Utc>>,
4706 paths: &AppPaths,
4707 ) -> Self {
4708 let mut problems = Vec::new();
4709 let mut notes = Vec::new();
4710
4711 if let Some(refusal) = record_refused {
4712 problems.push(StatusProblem {
4717 subject: "install record",
4718 detail: refusal.to_string(),
4719 });
4720 }
4721
4722 let log_file = record.as_ref().map_or_else(
4723 || ServiceDirectories::of(paths).log_file(),
4724 |record| record.log_file.clone(),
4725 );
4726
4727 let binary = record.as_ref().map(|record| {
4728 inspect_binary(
4729 &record.binary,
4730 registration
4731 .as_ref()
4732 .and_then(Registration::binary)
4733 .as_deref(),
4734 )
4735 });
4736 if let Some(state) = &binary
4737 && state.is_error()
4738 {
4739 problems.push(StatusProblem {
4740 subject: "binary",
4741 detail: state.to_string(),
4742 });
4743 }
4744
4745 match (&record, ®istration) {
4746 (Some(_), None) => problems.push(StatusProblem {
4747 subject: "registration",
4748 detail: "this host has a service record but no service manager knows the \
4749 registration. Run `service install` again; it deletes nothing."
4750 .to_string(),
4751 }),
4752 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4757 subject: "record",
4758 detail: format!(
4759 "{} holds a registration for this service but there is no install record, so \
4760 the path it was installed from and the directories it was installed against \
4761 are unknown. Run `service uninstall` and `service install`.",
4762 found.manager
4763 ),
4764 }),
4765 (None, Some(_)) => {}
4766 (Some(record), Some(found)) => {
4767 if record.start_mode != found.start_mode {
4768 problems.push(StatusProblem {
4769 subject: "start mode",
4770 detail: format!(
4771 "the record says {} and {} holds a {} registration. Switch the start \
4772 mode again to make them agree.",
4773 record.start_mode, found.manager, found.start_mode
4774 ),
4775 });
4776 }
4777 if record.start_mode == StartMode::Boot && !found.starts_automatically {
4778 problems.push(StatusProblem {
4779 subject: "start mode",
4780 detail: format!(
4781 "{} holds the registration but will not start it by itself, so this \
4782 host does not resume work after a reboot.",
4783 found.manager
4784 ),
4785 });
4786 }
4787 if let Some(actual) = found.restart_delay {
4788 let expected = record.restart().effective_delay(found.manager);
4789 if actual != expected {
4790 problems.push(StatusProblem {
4791 subject: "restart policy",
4792 detail: format!(
4793 "the record says the service restarts after {}s and {} reports \
4794 {}s. Something has edited the registration since it was \
4795 installed.",
4796 expected.as_secs(),
4797 found.manager,
4798 actual.as_secs()
4799 ),
4800 });
4801 } else if expected != record.restart().delay() {
4802 notes.push(format!(
4807 "{} expresses the restart delay in whole minutes, so the {}s asked \
4808 for is enforced as {}s. The service therefore never restarts faster \
4809 than the configured bound.",
4810 found.manager,
4811 record.restart().delay().as_secs(),
4812 expected.as_secs()
4813 ));
4814 }
4815 }
4816 if found.starts_automatically && !found.running {
4817 problems.push(StatusProblem {
4818 subject: "runtime",
4819 detail: format!(
4820 "{} holds an automatic registration, but the daemon is stopped. Run \
4821 `runner-manager service install --start-at {}`; if the service \
4822 manager denies replacement, retry from an elevated terminal. If it \
4823 stops again, inspect {}.",
4824 found.manager,
4825 found.start_mode,
4826 log_file.display()
4827 ),
4828 });
4829 }
4830 }
4831 (None, None) => {}
4832 }
4833
4834 let store = record.as_ref().and_then(|record| {
4839 let registered_mode = registration
4840 .as_ref()
4841 .map_or(record.start_mode, |found| found.start_mode);
4842 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4843 .ok()
4844 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4845 });
4846 if let Some(store) = &store
4847 && !store.agrees_with_start_mode()
4848 {
4849 problems.push(StatusProblem {
4850 subject: "secret store",
4851 detail: format!(
4852 "{store}. Run `auth login` again so the token is stored where the registered \
4853 start mode can read it."
4854 ),
4855 });
4856 }
4857
4858 if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4859 notes.push(
4860 "This registration starts at login, so the agent does not run until the operator \
4861 signs in; this host does not resume work after an unattended reboot."
4862 .to_string(),
4863 );
4864 }
4865 if last_github_contact.is_none() {
4866 notes.push(
4867 "GitHub has not been reached successfully since this host's state directory was \
4868 created."
4869 .to_string(),
4870 );
4871 }
4872
4873 let runner_root = crate::runner_root::default_runner_root(paths)
4880 .ok()
4881 .map(|root| {
4882 let path = root.as_path().to_path_buf();
4883 let report = crate::runner_root_access::report(&path);
4884 (path, report)
4885 });
4886 if let Some((
4896 path,
4897 RootAccessReport::Present {
4898 broad_write: true, ..
4899 },
4900 )) = &runner_root
4901 {
4902 notes.push(format!(
4903 "the platform default runner root {} can be written by ordinary local users, so \
4904 it is not a safe place to run jobs. `service install` refuses it rather than \
4905 tightening it, because the contents of a directory anybody could write cannot \
4906 be trusted: remove or empty it, or choose another root with `runner-manager \
4907 host set-runtime-root --path <PATH>`.",
4908 path.display()
4909 ));
4910 }
4911
4912 match runner_root_refusals(paths) {
4935 Ok(refusals) => {
4936 for refusal in refusals {
4937 notes.push(format!(
4938 "policy {} started no runner: its runner root {} refused the launch \
4939 ({}), last at {}. {} This clears when that policy next places a \
4940 runner.",
4941 refusal.policy,
4942 refusal.root,
4943 refusal.kind,
4944 refusal.at.to_rfc3339(),
4945 refusal.detail,
4946 ));
4947 }
4948 }
4949 Err(error) => notes.push(format!(
4950 "whether the agent could use its runner roots could not be read: {error}"
4951 )),
4952 }
4953
4954 Self {
4955 identity,
4956 record,
4957 registration,
4958 binary,
4959 log_file,
4960 store,
4961 last_github_contact,
4962 runner_root,
4963 credential_rejected_since: None,
4964 launches_blocked: None,
4965 problems,
4966 notes,
4967 }
4968 }
4969
4970 #[must_use]
4972 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
4973 if let Some(drift) = drift {
4974 if drift.manager == DefinitionKind::LaunchdPlist {
4975 let restart = match drift.start_mode {
4979 StartMode::Boot => format!(
4980 "sudo launchctl kickstart -k system/{}",
4981 self.identity.launchd_label()
4982 ),
4983 StartMode::Login => format!(
4984 "launchctl kickstart -k gui/$(id -u)/{}",
4985 self.identity.launchd_label()
4986 ),
4987 };
4988 self.problems.push(StatusProblem {
4989 subject: "definition",
4990 detail: format!(
4991 "{} was written by runner-manager {} with a `ProcessType` other than \
4992 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
4993 daemon starts, so their jobs run below normal priority. The daemon \
4994 rewrites it and reloads itself when it starts with no runner, which it \
4995 does after every upgrade; to do it now, restart the service while no \
4996 job is running: `{restart}`. Configuration and credentials are kept.",
4997 drift.path.display(),
4998 drift.installed_by_version,
4999 ),
5000 });
5001 return self;
5002 }
5003 let repair = match drift.start_mode {
5004 StartMode::Boot => {
5005 "`sudo systemctl stop runner-manager && sudo runner-manager service install \
5006 --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
5007 --distribution <name>` from Windows)"
5008 }
5009 StartMode::Login => {
5010 "`systemctl --user stop runner-manager && runner-manager service install \
5011 --start-at login`"
5012 }
5013 };
5014 self.problems.push(StatusProblem {
5015 subject: "definition",
5016 detail: format!(
5017 "{} was written by runner-manager {} and differs from what {} renders, so \
5018 the daemon runs under outdated settings (for example a credential frozen \
5019 at service start). Re-register it with {repair}; configuration and \
5020 credentials are kept.",
5021 drift.path.display(),
5022 drift.installed_by_version,
5023 env!("CARGO_PKG_VERSION"),
5024 ),
5025 });
5026 }
5027 self
5028 }
5029
5030 #[must_use]
5032 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
5033 if let Some(since) = since {
5034 self.credential_rejected_since = Some(since);
5035 self.problems.push(StatusProblem {
5036 subject: "github credential",
5037 detail: format!(
5038 "GitHub has rejected the service's credential since {}, so the service \
5039 cannot see queued jobs or start runners. Run `runner-manager auth login` \
5040 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
5041 from Windows).",
5042 since.to_rfc3339()
5043 ),
5044 });
5045 }
5046 self
5047 }
5048
5049 #[must_use]
5051 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
5052 self.credential_rejected_since
5053 }
5054
5055 #[must_use]
5058 pub fn with_launches_blocked(
5059 mut self,
5060 blocked: Option<crate::launch_health::LaunchesBlocked>,
5061 ) -> Self {
5062 if let Some(blocked) = &blocked {
5063 self.problems.push(StatusProblem {
5064 subject: LAUNCHES_BLOCKED_SUBJECT,
5065 detail: format!(
5066 "the service has started no runner {blocked}. Fix: {}.",
5067 blocked.remedy
5068 ),
5069 });
5070 }
5071 self.launches_blocked = blocked;
5072 self
5073 }
5074
5075 #[must_use]
5077 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
5078 self.launches_blocked.as_ref()
5079 }
5080
5081 #[must_use]
5084 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
5085 if let Some(since) = since {
5086 let mode = self.start_mode().unwrap_or_default();
5087 self.problems.push(StatusProblem {
5088 subject: CREDENTIAL_UNREADABLE_SUBJECT,
5089 detail: format!(
5090 "the service has been unable to read its stored GitHub credential since {}, \
5091 so it starts no runner; the diagnostic log has the store's own answer.{} \
5092 Sign in again: {}.",
5093 since.to_rfc3339(),
5094 if cfg!(target_os = "macos") {
5095 " That is what a keychain item written by a different build looks like."
5096 } else {
5097 ""
5098 },
5099 sign_in_instruction(mode)
5100 ),
5101 });
5102 }
5103 self
5104 }
5105
5106 #[must_use]
5114 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
5115 self.runner_root
5116 .as_ref()
5117 .map(|(path, report)| (path.as_path(), report))
5118 }
5119
5120 #[must_use]
5122 pub const fn is_installed(&self) -> bool {
5123 self.registration.is_some() || self.record.is_some()
5124 }
5125
5126 #[must_use]
5128 pub fn is_running(&self) -> bool {
5129 self.registration
5130 .as_ref()
5131 .is_some_and(|registration| registration.running)
5132 }
5133
5134 #[must_use]
5136 pub fn is_healthy(&self) -> bool {
5137 self.problems.is_empty()
5138 }
5139
5140 #[must_use]
5142 pub fn problems(&self) -> &[StatusProblem] {
5143 &self.problems
5144 }
5145
5146 #[must_use]
5149 pub fn notes(&self) -> &[String] {
5150 &self.notes
5151 }
5152
5153 #[must_use]
5155 pub fn start_mode(&self) -> Option<StartMode> {
5156 self.record.as_ref().map(|record| record.start_mode)
5157 }
5158
5159 #[must_use]
5162 pub const fn binary(&self) -> Option<&BinaryPath> {
5163 self.binary.as_ref()
5164 }
5165
5166 #[must_use]
5168 pub fn log_file(&self) -> &Path {
5169 &self.log_file
5170 }
5171
5172 #[must_use]
5174 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
5175 self.last_github_contact
5176 }
5177
5178 #[must_use]
5181 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
5182 self.store.as_ref()
5183 }
5184
5185 #[must_use]
5187 pub const fn record(&self) -> Option<&InstallRecord> {
5188 self.record.as_ref()
5189 }
5190
5191 #[must_use]
5193 pub const fn registration(&self) -> Option<&Registration> {
5194 self.registration.as_ref()
5195 }
5196}
5197
5198impl fmt::Display for ServiceStatus {
5199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5200 writeln!(f, "Service: {}", self.identity)?;
5201 match (&self.record, &self.registration) {
5202 (None, None) => {
5203 writeln!(
5204 f,
5205 " installed no. `service install` registers `{} {}`.",
5206 SERVICE_NAME,
5207 DAEMON_ARGUMENTS.join(" ")
5208 )?;
5209 }
5210 _ => {
5211 let manager = self
5212 .registration
5213 .as_ref()
5214 .map(|registration| registration.manager.manager());
5215 writeln!(
5216 f,
5217 " installed {}",
5218 manager.unwrap_or("yes, but no service manager knows it")
5219 )?;
5220 writeln!(
5221 f,
5222 " state {}",
5223 if self.is_running() {
5224 "running"
5225 } else {
5226 "not running"
5227 }
5228 )?;
5229 }
5230 }
5231 if let Some(record) = &self.record {
5232 writeln!(f, " start mode {}", record.start_mode)?;
5233 writeln!(f, " account {}", record.account)?;
5234 writeln!(f, " restart on failure {}", record.restart())?;
5235 writeln!(
5236 f,
5237 " arguments {}",
5238 record.arguments.join(" ")
5239 )?;
5240 }
5241 if let Some(binary) = &self.binary {
5242 writeln!(f, " binary {binary}")?;
5243 }
5244 writeln!(f, " diagnostic log {}", self.log_file.display())?;
5245 if let Some((path, report)) = &self.runner_root {
5246 writeln!(f, " default runner root {}", path.display())?;
5253 if *report != RootAccessReport::NotApplicable {
5254 writeln!(f, " default root access {report}")?;
5255 }
5256 }
5257 if let Some(store) = &self.store {
5258 writeln!(f, " secret store {store}")?;
5259 }
5260 writeln!(
5261 f,
5262 " last GitHub contact {}",
5263 match self.last_github_contact {
5264 Some(at) => at.to_rfc3339(),
5265 None => "never".to_string(),
5266 }
5267 )?;
5268 for note in &self.notes {
5269 writeln!(f, " note {note}")?;
5270 }
5271 for problem in &self.problems {
5272 writeln!(f, " ERROR {problem}")?;
5273 }
5274 write!(
5275 f,
5276 " verdict {}",
5277 if self.is_healthy() {
5278 "healthy"
5279 } else {
5280 "NOT healthy"
5281 }
5282 )
5283 }
5284}
5285
5286#[derive(Debug, Clone, Default)]
5303pub struct RecordingControls {
5304 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5305}
5306
5307#[derive(Debug, Default)]
5308struct RecordingState {
5309 registrations: BTreeMap<(StartMode, String), Registration>,
5310 definitions: BTreeMap<String, ServiceDefinition>,
5311 calls: Vec<String>,
5312 #[cfg(test)]
5313 install_failures: BTreeMap<StartMode, String>,
5314 #[cfg(test)]
5315 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5316}
5317
5318#[cfg(test)]
5319#[derive(Debug, Clone)]
5320enum TestInstallSideEffect {
5321 HideDirectory { directory: PathBuf, hidden: PathBuf },
5322}
5323
5324impl RecordingControls {
5325 #[must_use]
5327 pub fn new() -> Self {
5328 Self::default()
5329 }
5330
5331 #[must_use]
5334 pub fn calls(&self) -> Vec<String> {
5335 self.state.lock().expect("not poisoned").calls.clone()
5336 }
5337
5338 #[must_use]
5340 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5341 self.state
5342 .lock()
5343 .expect("not poisoned")
5344 .registrations
5345 .iter()
5346 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5347 .collect()
5348 }
5349
5350 #[must_use]
5352 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5353 self.state
5354 .lock()
5355 .expect("not poisoned")
5356 .definitions
5357 .get(name)
5358 .cloned()
5359 }
5360
5361 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5371 let mut state = self.state.lock().expect("not poisoned");
5372 if let Some((_, registration)) = state
5373 .registrations
5374 .iter_mut()
5375 .find(|((_, held), _)| held == name)
5376 {
5377 edit(registration);
5378 }
5379 }
5380
5381 #[cfg(test)]
5382 fn fail_next_install(&self, mode: StartMode, detail: &str) {
5383 self.state
5384 .lock()
5385 .expect("not poisoned")
5386 .install_failures
5387 .insert(mode, detail.to_string());
5388 }
5389
5390 #[cfg(test)]
5391 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5392 self.state
5393 .lock()
5394 .expect("not poisoned")
5395 .after_install
5396 .insert(
5397 mode,
5398 TestInstallSideEffect::HideDirectory { directory, hidden },
5399 );
5400 }
5401}
5402
5403impl ControlFactory for RecordingControls {
5404 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5405 Ok(Box::new(RecordingControl {
5406 mode,
5407 state: std::sync::Arc::clone(&self.state),
5408 }))
5409 }
5410}
5411
5412#[derive(Debug)]
5413struct RecordingControl {
5414 mode: StartMode,
5415 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5416}
5417
5418impl RecordingControl {
5419 fn note(&self, operation: &str, name: &str) {
5420 self.state
5421 .lock()
5422 .expect("not poisoned")
5423 .calls
5424 .push(format!("{operation} {name} ({})", self.mode));
5425 }
5426}
5427
5428impl ServiceControl for RecordingControl {
5429 fn manager(&self) -> DefinitionKind {
5430 host_definition_kind(self.mode)
5434 }
5435
5436 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5437 self.note("install", plan.identity().name());
5438 #[cfg(test)]
5439 if let Some(detail) = self
5440 .state
5441 .lock()
5442 .expect("not poisoned")
5443 .install_failures
5444 .remove(&self.mode)
5445 {
5446 return Err(ServiceError::Control {
5447 operation: "install",
5448 name: plan.identity().name().to_string(),
5449 manager: "recording control",
5450 detail,
5451 });
5452 }
5453 let definition = ServiceDefinition::for_host(plan)?;
5458 let mut state = self.state.lock().expect("not poisoned");
5459 state.registrations.insert(
5460 (self.mode, plan.identity().name().to_string()),
5461 Registration {
5462 manager: host_definition_kind(self.mode),
5463 start_mode: self.mode,
5464 command_line: plan.command_line(),
5465 account: Some(plan.account().as_str().to_string()),
5466 running: false,
5467 starts_automatically: true,
5468 restart_delay: Some(plan.restart().delay()),
5469 },
5470 );
5471 state
5472 .definitions
5473 .insert(plan.identity().name().to_string(), definition.clone());
5474 #[cfg(test)]
5475 let side_effect = state.after_install.remove(&self.mode);
5476 drop(state);
5477 #[cfg(test)]
5478 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5479 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5480 std::fs::write(&directory, b"blocks recreation")
5481 .expect("test fault can block record directory recreation");
5482 }
5483 Ok(definition)
5484 }
5485
5486 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5487 self.note("uninstall", identity.name());
5488 let mut state = self.state.lock().expect("not poisoned");
5489 state.definitions.remove(identity.name());
5490 Ok(state
5491 .registrations
5492 .remove(&(self.mode, identity.name().to_string()))
5493 .is_some())
5494 }
5495
5496 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5497 self.note("query", identity.name());
5498 Ok(self
5499 .state
5500 .lock()
5501 .expect("not poisoned")
5502 .registrations
5503 .get(&(self.mode, identity.name().to_string()))
5504 .cloned())
5505 }
5506
5507 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5508 self.note("start", identity.name());
5509 let mut state = self.state.lock().expect("not poisoned");
5510 match state
5511 .registrations
5512 .get_mut(&(self.mode, identity.name().to_string()))
5513 {
5514 Some(registration) => {
5515 registration.running = true;
5516 Ok(())
5517 }
5518 None => Err(ServiceError::NotInstalled {
5519 name: identity.name().to_string(),
5520 operation: "start",
5521 }),
5522 }
5523 }
5524
5525 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5526 self.note("stop", identity.name());
5527 let mut state = self.state.lock().expect("not poisoned");
5528 match state
5529 .registrations
5530 .get_mut(&(self.mode, identity.name().to_string()))
5531 {
5532 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5533 None => Err(ServiceError::NotInstalled {
5534 name: identity.name().to_string(),
5535 operation: "stop",
5536 }),
5537 }
5538 }
5539}
5540
5541impl ControlFactory for HostControls {
5542 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5543 sys::control(mode)
5544 }
5545
5546 fn installed_definition(&self, path: &Path) -> Option<String> {
5547 std::fs::read_to_string(path).ok()
5548 }
5549}
5550
5551fn host_home() -> Option<PathBuf> {
5558 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5559}
5560
5561#[cfg(unix)]
5563fn home_directory() -> Option<PathBuf> {
5564 host_home()
5565}
5566
5567fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5576 let output = std::process::Command::new(program)
5577 .args(arguments)
5578 .output()?;
5579 Ok((
5580 output.status.success(),
5581 String::from_utf8_lossy(&output.stdout).into_owned(),
5582 String::from_utf8_lossy(&output.stderr).into_owned(),
5583 ))
5584}
5585
5586#[must_use]
5589pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5590 if cfg!(windows) {
5591 match mode {
5592 StartMode::Boot => DefinitionKind::WindowsService,
5593 StartMode::Login => DefinitionKind::WindowsScheduledTask,
5594 }
5595 } else if cfg!(target_os = "macos") {
5596 DefinitionKind::LaunchdPlist
5597 } else {
5598 DefinitionKind::SystemdUnit
5599 }
5600}
5601
5602#[cfg(any(target_os = "macos", test))]
5609fn enable_launchd_registration(
5610 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5611 domain: &str,
5612 service_target: &str,
5613 plist: &Path,
5614 name: &str,
5615 elevation_remedy: &'static str,
5616) -> Result<(), ServiceError> {
5617 let (enabled, cause) = launchctl(&[
5618 std::ffi::OsStr::new("enable"),
5619 std::ffi::OsStr::new(service_target),
5620 ]);
5621 if enabled {
5622 return Ok(());
5623 }
5624
5625 let (booted_out, bootout_detail) = launchctl(&[
5626 std::ffi::OsStr::new("bootout"),
5627 std::ffi::OsStr::new(service_target),
5628 ]);
5629 let removed = std::fs::remove_file(plist);
5630 if !booted_out || removed.is_err() {
5631 return Err(ServiceError::Rollback {
5632 operation: "enable launchd registration",
5633 name: name.to_string(),
5634 cause,
5635 rollback: format!(
5636 "launchctl bootout {domain}: {}; remove {}: {}",
5637 if booted_out {
5638 "succeeded".to_string()
5639 } else {
5640 bootout_detail
5641 },
5642 plist.display(),
5643 removed
5644 .err()
5645 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5646 ),
5647 });
5648 }
5649
5650 if cause.to_ascii_lowercase().contains("permission denied") {
5651 Err(ServiceError::NeedsElevation {
5652 operation: "enable",
5653 name: name.to_string(),
5654 detail: cause,
5655 remedy: elevation_remedy,
5656 })
5657 } else {
5658 Err(ServiceError::Control {
5659 operation: "enable",
5660 name: name.to_string(),
5661 manager: "launchd",
5662 detail: cause,
5663 })
5664 }
5665}
5666
5667#[derive(Debug, Clone)]
5676pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5677
5678impl ServiceShutdown {
5679 pub async fn wait(mut self) {
5681 if !*self.0.borrow() {
5682 let _ = self.0.changed().await;
5683 }
5684 }
5685}
5686
5687#[cfg(windows)]
5694pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5695where
5696 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5697{
5698 windows_host::run(Box::new(run))
5699}
5700
5701#[cfg(windows)]
5702mod windows_host {
5703 use std::ffi::OsString;
5704 use std::sync::{Arc, Mutex, OnceLock, mpsc};
5705 use std::time::Duration;
5706
5707 use windows_service::service::{
5708 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5709 ServiceType,
5710 };
5711 use windows_service::service_control_handler::{
5712 self, ServiceControlHandlerResult, ServiceStatusHandle,
5713 };
5714
5715 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5716
5717 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5718
5719 struct Invocation {
5720 run: Runner,
5721 result: mpsc::SyncSender<Result<u8, String>>,
5722 }
5723
5724 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5725
5726 windows_service::define_windows_service!(ffi_service_main, service_main);
5727
5728 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5729 let (result_tx, result_rx) = mpsc::sync_channel(1);
5730 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5731 let mut invocation = slot
5732 .lock()
5733 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5734 if invocation.is_some() {
5735 return Err(host_error(
5736 "prepare",
5737 "the service-host slot was already used",
5738 ));
5739 }
5740 *invocation = Some(Invocation {
5741 run,
5742 result: result_tx,
5743 });
5744 drop(invocation);
5745
5746 windows_service::service_dispatcher::start("", ffi_service_main)
5747 .map_err(|error| host_error("connect", &error.to_string()))?;
5748 result_rx
5749 .recv()
5750 .map_err(|error| host_error("finish", &error.to_string()))?
5751 .map_err(|detail| host_error("run", &detail))
5752 }
5753
5754 fn service_main(_arguments: Vec<OsString>) {
5755 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5756 return;
5757 };
5758 let result = run_service(invocation.run);
5759 let _ = invocation.result.send(result);
5760 }
5761
5762 fn run_service(run: Runner) -> Result<u8, String> {
5763 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5764 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5765 let handler_status = Arc::clone(&status);
5766 let handler = move |control| match control {
5767 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5768 ServiceControl::Stop | ServiceControl::Shutdown => {
5769 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5770 let _ = handle.set_service_status(service_status(
5771 ServiceState::StopPending,
5772 ServiceControlAccept::empty(),
5773 1,
5774 Duration::from_secs(300),
5775 0,
5776 ));
5777 }
5778 let _ = stop_tx.send(true);
5779 ServiceControlHandlerResult::NoError
5780 }
5781 _ => ServiceControlHandlerResult::NotImplemented,
5782 };
5783 let handle = service_control_handler::register("", handler)
5784 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5785 *status
5786 .lock()
5787 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5788 handle
5789 .set_service_status(service_status(
5790 ServiceState::Running,
5791 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5792 0,
5793 Duration::default(),
5794 0,
5795 ))
5796 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5797
5798 let exit = run(ServiceShutdown(stop_rx));
5799 handle
5800 .set_service_status(service_status(
5801 ServiceState::Stopped,
5802 ServiceControlAccept::empty(),
5803 0,
5804 Duration::default(),
5805 u32::from(exit),
5806 ))
5807 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5808 Ok(exit)
5809 }
5810
5811 fn service_status(
5812 state: ServiceState,
5813 accepted: ServiceControlAccept,
5814 checkpoint: u32,
5815 wait_hint: Duration,
5816 exit: u32,
5817 ) -> ServiceStatus {
5818 ServiceStatus {
5819 service_type: ServiceType::OWN_PROCESS,
5820 current_state: state,
5821 controls_accepted: accepted,
5822 exit_code: if exit == 0 {
5823 ServiceExitCode::Win32(0)
5824 } else {
5825 ServiceExitCode::ServiceSpecific(exit)
5826 },
5827 checkpoint,
5828 wait_hint,
5829 process_id: None,
5830 }
5831 }
5832
5833 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5834 ServiceError::Control {
5835 operation,
5836 name: SERVICE_NAME.to_string(),
5837 manager: "the Windows Service Control Manager",
5838 detail: detail.to_string(),
5839 }
5840 }
5841}
5842
5843#[cfg(windows)]
5844mod sys {
5845 use std::ffi::{OsStr, OsString};
5855 use std::time::{Duration, Instant};
5856
5857 use runner_manager_domain::model::StartMode;
5858 use windows_service::service::{
5859 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5860 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5861 ServiceState, ServiceType,
5862 };
5863 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5864
5865 use super::{
5866 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5867 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5868 };
5869
5870 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5873 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5877 const ACCESS_DENIED: i32 = 5;
5879 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5880 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5881
5882 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5883 choose \"Run as administrator\".";
5884
5885 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5886 Ok(match mode {
5887 StartMode::Boot => Box::new(ScmControl),
5888 StartMode::Login => Box::new(TaskControl),
5889 })
5890 }
5891
5892 #[derive(Debug)]
5895 struct ScmControl;
5896
5897 fn scm_error(
5901 operation: &'static str,
5902 name: &str,
5903 error: &windows_service::Error,
5904 ) -> ServiceError {
5905 let raw = match error {
5906 windows_service::Error::Winapi(io) => io.raw_os_error(),
5907 _ => None,
5908 };
5909 let detail = match error {
5910 windows_service::Error::Winapi(io) => io.to_string(),
5911 other => other.to_string(),
5912 };
5913 if raw == Some(ACCESS_DENIED) {
5914 return ServiceError::NeedsElevation {
5915 operation,
5916 name: name.to_string(),
5917 detail,
5918 remedy: ELEVATION_REMEDY,
5919 };
5920 }
5921 ServiceError::Control {
5922 operation,
5923 name: name.to_string(),
5924 manager: "the Windows Service Control Manager",
5925 detail,
5926 }
5927 }
5928
5929 fn open_manager(
5930 access: ServiceManagerAccess,
5931 operation: &'static str,
5932 name: &str,
5933 ) -> Result<ServiceManager, ServiceError> {
5934 ServiceManager::local_computer(None::<&OsStr>, access)
5935 .map_err(|error| scm_error(operation, name, &error))
5936 }
5937
5938 impl ServiceControl for ScmControl {
5939 fn manager(&self) -> DefinitionKind {
5940 DefinitionKind::WindowsService
5941 }
5942
5943 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5944 let spec = windows_service_spec(plan);
5945 let manager = open_manager(
5946 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5947 "install",
5948 &spec.name,
5949 )?;
5950 let info = ServiceInfo {
5951 name: OsString::from(&spec.name),
5952 display_name: OsString::from(&spec.display_name),
5953 service_type: ServiceType::OWN_PROCESS,
5955 start_type: if spec.automatic_start {
5956 ServiceStartType::AutoStart
5957 } else {
5958 ServiceStartType::OnDemand
5959 },
5960 error_control: ServiceErrorControl::Normal,
5961 executable_path: plan.binary().to_path_buf(),
5962 launch_arguments: plan.arguments().to_vec(),
5963 dependencies: Vec::new(),
5964 account_name: spec.account.as_ref().map(OsString::from),
5966 account_password: None,
5969 };
5970 let service = manager
5971 .create_service(
5972 &info,
5973 ServiceAccess::CHANGE_CONFIG
5974 | ServiceAccess::QUERY_CONFIG
5975 | ServiceAccess::QUERY_STATUS
5976 | ServiceAccess::START
5977 | ServiceAccess::STOP
5978 | ServiceAccess::DELETE,
5979 )
5980 .map_err(|error| scm_error("install", &spec.name, &error))?;
5981 service
5982 .set_description(&spec.description)
5983 .map_err(|error| scm_error("describe", &spec.name, &error))?;
5984 service
5985 .update_failure_actions(ServiceFailureActions {
5986 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5987 reboot_msg: None,
5988 command: None,
5989 actions: Some(vec![
5996 ServiceAction {
5997 action_type: ServiceActionType::Restart,
5998 delay: spec.restart.delay(),
5999 };
6000 3
6001 ]),
6002 })
6003 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
6004 service
6007 .set_failure_actions_on_non_crash_failures(true)
6008 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
6009 Ok(ServiceDefinition::windows_service(plan))
6010 }
6011
6012 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6013 let manager =
6014 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
6015 let service = match manager.open_service(
6016 identity.name(),
6017 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
6018 ) {
6019 Ok(service) => Some(service),
6020 Err(error) if is_missing(&error) => return Ok(false),
6021 Err(error) if is_marked_for_delete(&error) => None,
6026 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
6027 };
6028 if let Some(service) = service {
6029 if let Ok(status) = service.query_status()
6033 && status.current_state != ServiceState::Stopped
6034 {
6035 let _ = service.stop();
6036 }
6037 match service.delete() {
6038 Ok(()) => {}
6039 Err(error) if is_marked_for_delete(&error) => {}
6043 Err(error) => {
6044 return Err(scm_error("uninstall", identity.name(), &error));
6045 }
6046 }
6047 drop(service);
6048 }
6049
6050 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
6057 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
6058 Ok(service) => {
6059 drop(service);
6060 Ok(false)
6061 }
6062 Err(error) if is_missing(&error) => Ok(true),
6063 Err(error) if is_marked_for_delete(&error) => Ok(false),
6064 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
6065 }
6066 })?;
6067 if !absent {
6068 return Err(ServiceError::Control {
6069 operation: "verify uninstall of",
6070 name: identity.name().to_string(),
6071 manager: "the Windows Service Control Manager",
6072 detail: format!(
6073 "the registration was still visible {} seconds after DeleteService; \
6074 retry `service uninstall` from an elevated prompt",
6075 DELETE_TIMEOUT.as_secs()
6076 ),
6077 });
6078 }
6079 Ok(true)
6080 }
6081
6082 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6083 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
6084 let service = match manager.open_service(
6085 identity.name(),
6086 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
6087 ) {
6088 Ok(service) => service,
6089 Err(error) if is_missing(&error) => return Ok(None),
6090 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
6091 };
6092 let config = service
6093 .query_config()
6094 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6095 let status = service
6096 .query_status()
6097 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
6098 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
6099 actions
6100 .actions
6101 .and_then(|actions| actions.into_iter().next())
6102 .filter(|action| action.action_type == ServiceActionType::Restart)
6103 .map(|action| action.delay)
6104 });
6105 Ok(Some(Registration {
6106 manager: DefinitionKind::WindowsService,
6107 start_mode: StartMode::Boot,
6108 command_line: config.executable_path.to_string_lossy().into_owned(),
6112 account: config
6113 .account_name
6114 .map(|account| account.to_string_lossy().into_owned()),
6115 running: status.current_state == ServiceState::Running,
6116 starts_automatically: config.start_type == ServiceStartType::AutoStart,
6117 restart_delay,
6118 }))
6119 }
6120
6121 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6122 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
6123 let service = manager
6124 .open_service(identity.name(), ServiceAccess::START)
6125 .map_err(|error| scm_error("start", identity.name(), &error))?;
6126 service
6127 .start::<&OsStr>(&[])
6128 .map_err(|error| scm_error("start", identity.name(), &error))
6129 }
6130
6131 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6132 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
6133 let service = manager
6134 .open_service(
6135 identity.name(),
6136 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
6137 )
6138 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6139 let status = service
6140 .query_status()
6141 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6142 if status.current_state == ServiceState::Stopped {
6143 return Ok(false);
6144 }
6145 service
6146 .stop()
6147 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6148 Ok(true)
6149 }
6150 }
6151
6152 fn is_missing(error: &windows_service::Error) -> bool {
6153 matches!(error, windows_service::Error::Winapi(io)
6154 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
6155 }
6156
6157 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
6158 matches!(error, windows_service::Error::Winapi(io)
6159 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
6160 }
6161
6162 pub(super) fn wait_until_scm_absent(
6163 timeout: Duration,
6164 poll_interval: Duration,
6165 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
6166 ) -> Result<bool, ServiceError> {
6167 let deadline = Instant::now() + timeout;
6168 loop {
6169 if probe_absent()? {
6170 return Ok(true);
6171 }
6172 if Instant::now() >= deadline {
6173 return Ok(false);
6174 }
6175 std::thread::sleep(poll_interval);
6176 }
6177 }
6178
6179 #[derive(Debug)]
6182 struct TaskControl;
6183
6184 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
6185 if detail.to_ascii_lowercase().contains("access is denied") {
6186 return ServiceError::NeedsElevation {
6187 operation,
6188 name: name.to_string(),
6189 detail,
6190 remedy: ELEVATION_REMEDY,
6191 };
6192 }
6193 ServiceError::Control {
6194 operation,
6195 name: name.to_string(),
6196 manager: "Windows Task Scheduler",
6197 detail,
6198 }
6199 }
6200
6201 fn schtasks(
6202 operation: &'static str,
6203 name: &str,
6204 arguments: &[&OsStr],
6205 ) -> Result<(bool, String), ServiceError> {
6206 match run("schtasks.exe", arguments) {
6207 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
6208 Err(error) => Err(task_error(operation, name, error.to_string())),
6209 }
6210 }
6211
6212 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
6215 let mut bytes = vec![0xFF, 0xFE];
6216 for unit in text.encode_utf16() {
6217 bytes.extend_from_slice(&unit.to_le_bytes());
6218 }
6219 std::fs::write(path, bytes)
6220 }
6221
6222 impl ServiceControl for TaskControl {
6223 fn manager(&self) -> DefinitionKind {
6224 DefinitionKind::WindowsScheduledTask
6225 }
6226
6227 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6228 let name = plan.identity().name().to_string();
6229 let principal = TaskPrincipal::current()?;
6230 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
6231 let directory = tempfile::tempdir()
6232 .map_err(|error| task_error("install", &name, error.to_string()))?;
6233 let document = directory.path().join("task.xml");
6234 write_utf16(&document, definition.text())
6235 .map_err(|error| task_error("install", &name, error.to_string()))?;
6236 let (ok, message) = schtasks(
6237 "install",
6238 &name,
6239 &[
6240 OsStr::new("/Create"),
6241 OsStr::new("/TN"),
6242 OsStr::new(&name),
6243 OsStr::new("/XML"),
6244 document.as_os_str(),
6245 ],
6246 )?;
6247 if !ok {
6248 return Err(task_error("install", &name, message));
6249 }
6250 Ok(definition)
6251 }
6252
6253 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6254 if self.query(identity)?.is_none() {
6255 return Ok(false);
6256 }
6257 let name = identity.name().to_string();
6258 let (ok, message) = schtasks(
6259 "uninstall",
6260 &name,
6261 &[
6262 OsStr::new("/Delete"),
6263 OsStr::new("/TN"),
6264 OsStr::new(&name),
6265 OsStr::new("/F"),
6266 ],
6267 )?;
6268 if !ok {
6269 return Err(task_error("uninstall", &name, message));
6270 }
6271 Ok(true)
6272 }
6273
6274 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6275 let name = identity.name().to_string();
6276 let (ok, document) = schtasks(
6277 "inspect",
6278 &name,
6279 &[
6280 OsStr::new("/Query"),
6281 OsStr::new("/TN"),
6282 OsStr::new(&name),
6283 OsStr::new("/XML"),
6284 OsStr::new("ONE"),
6285 ],
6286 )?;
6287 if !ok {
6288 return Ok(None);
6294 }
6295 let command = xml_value(&document, "Command").unwrap_or_default();
6296 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6297 let command_line = if arguments.is_empty() {
6298 super::quote_argument(&command)
6299 } else {
6300 format!("{} {arguments}", super::quote_argument(&command))
6301 };
6302 Ok(Some(Registration {
6303 manager: DefinitionKind::WindowsScheduledTask,
6304 start_mode: StartMode::Login,
6305 command_line,
6306 account: xml_value(&document, "UserId"),
6307 running: task_is_running(&name),
6308 starts_automatically: super::windows_login_task_starts_automatically(&document),
6309 restart_delay: xml_value(&document, "Interval")
6310 .as_deref()
6311 .and_then(parse_iso8601),
6312 }))
6313 }
6314
6315 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6316 let name = identity.name().to_string();
6317 let (ok, message) = schtasks(
6318 "start",
6319 &name,
6320 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6321 )?;
6322 if ok {
6323 Ok(())
6324 } else {
6325 Err(task_error("start", &name, message))
6326 }
6327 }
6328
6329 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6330 let running = self
6331 .query(identity)?
6332 .is_some_and(|registration| registration.running);
6333 if !running {
6334 return Ok(false);
6335 }
6336 let name = identity.name().to_string();
6337 let (ok, message) = schtasks(
6338 "stop",
6339 &name,
6340 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6341 )?;
6342 if ok {
6343 Ok(true)
6344 } else {
6345 Err(task_error("stop", &name, message))
6346 }
6347 }
6348 }
6349
6350 fn task_is_running(name: &str) -> bool {
6364 let Ok((true, stdout, _)) = run(
6365 "schtasks.exe",
6366 &[
6367 OsStr::new("/Query"),
6368 OsStr::new("/TN"),
6369 OsStr::new(name),
6370 OsStr::new("/FO"),
6371 OsStr::new("CSV"),
6372 OsStr::new("/NH"),
6373 ],
6374 ) else {
6375 return false;
6376 };
6377 stdout
6378 .lines()
6379 .filter_map(|line| line.rsplit(',').next())
6380 .any(|status| {
6381 status
6382 .trim()
6383 .trim_matches('"')
6384 .eq_ignore_ascii_case("running")
6385 })
6386 }
6387
6388 fn parse_iso8601(value: &str) -> Option<Duration> {
6392 let rest = value.strip_prefix("PT")?;
6393 if let Some(minutes) = rest.strip_suffix('M') {
6394 return minutes
6395 .parse::<u64>()
6396 .ok()
6397 .map(|minutes| Duration::from_secs(minutes * 60));
6398 }
6399 rest.strip_suffix('S')?
6400 .parse::<u64>()
6401 .ok()
6402 .map(Duration::from_secs)
6403 }
6404}
6405
6406#[cfg(unix)]
6418fn write_definition(
6419 operation: &'static str,
6420 name: &str,
6421 path: &Path,
6422 text: &str,
6423 remedy: &'static str,
6424) -> Result<(), ServiceError> {
6425 if let Some(parent) = path.parent()
6426 && let Err(error) = std::fs::create_dir_all(parent)
6427 && error.kind() != std::io::ErrorKind::AlreadyExists
6428 {
6429 return Err(definition_error(operation, name, error, remedy, parent));
6430 }
6431 std::fs::write(path, text)
6432 .map_err(|error| definition_error(operation, name, error, remedy, path))
6433}
6434
6435#[cfg(all(unix, any(target_os = "macos", test)))]
6442fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
6443 use std::io::Write as _;
6444
6445 let path = drift.path.as_path();
6446 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
6447 let failed = |error: std::io::Error, at: &Path| {
6448 definition_error("rewrite", name, error, SUDO_REMEDY, at)
6449 };
6450 let mut staged =
6451 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
6452 staged
6453 .write_all(drift.rendered.as_bytes())
6454 .and_then(|()| staged.as_file().sync_all())
6455 .map_err(|error| failed(error, staged.path()))?;
6456 std::fs::set_permissions(
6457 staged.path(),
6458 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
6459 )
6460 .map_err(|error| failed(error, path))?;
6461 staged
6462 .persist(path)
6463 .map(drop)
6464 .map_err(|error| failed(error.error, path))
6465}
6466
6467#[cfg(unix)]
6468fn definition_error(
6469 operation: &'static str,
6470 name: &str,
6471 error: std::io::Error,
6472 remedy: &'static str,
6473 path: &Path,
6474) -> ServiceError {
6475 let detail = format!("{}: {error}", path.display());
6476 if error.kind() == std::io::ErrorKind::PermissionDenied {
6477 ServiceError::NeedsElevation {
6478 operation,
6479 name: name.to_string(),
6480 detail,
6481 remedy,
6482 }
6483 } else {
6484 ServiceError::Control {
6485 operation,
6486 name: name.to_string(),
6487 manager: "the local service manager",
6488 detail,
6489 }
6490 }
6491}
6492
6493#[cfg(unix)]
6495const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6496 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6497 agent not running until you sign in.";
6498
6499#[cfg(target_os = "macos")]
6504mod sys {
6505 use std::ffi::OsStr;
6518 use std::path::PathBuf;
6519 use std::time::Duration;
6520
6521 use runner_manager_domain::model::StartMode;
6522
6523 use super::{
6524 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6525 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6526 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6527 write_definition, xml_value,
6528 };
6529
6530 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6531 Ok(Box::new(LaunchdControl { mode }))
6532 }
6533
6534 #[derive(Debug)]
6535 struct LaunchdControl {
6536 mode: StartMode,
6537 }
6538
6539 impl LaunchdControl {
6540 fn domain(&self) -> String {
6542 match self.mode {
6543 StartMode::Boot => "system".to_string(),
6544 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6547 }
6548 }
6549
6550 fn service_target(&self, identity: &ServiceIdentity) -> String {
6551 format!("{}/{}", self.domain(), identity.launchd_label())
6552 }
6553
6554 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6557 let file = format!("{}.plist", identity.launchd_label());
6558 match self.mode {
6559 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6560 StartMode::Login => {
6561 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6562 }
6563 }
6564 }
6565
6566 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6567 ServiceError::Control {
6568 operation,
6569 name: name.to_string(),
6570 manager: "launchd",
6571 detail,
6572 }
6573 }
6574
6575 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6576 match run("launchctl", arguments) {
6577 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6578 Err(error) => (false, error.to_string()),
6579 }
6580 }
6581 }
6582
6583 impl ServiceControl for LaunchdControl {
6584 fn manager(&self) -> DefinitionKind {
6585 DefinitionKind::LaunchdPlist
6586 }
6587
6588 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6589 let name = plan.identity().name().to_string();
6590 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6591 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6592 return Err(self.failed(
6593 "install",
6594 &name,
6595 "this account has no home directory, so there is nowhere to put a \
6596 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6597 /Library/LaunchDaemons."
6598 .to_string(),
6599 ));
6600 };
6601 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6602 let target = self.domain();
6603 let (ok, message) = self.launchctl(&[
6604 OsStr::new("bootstrap"),
6605 OsStr::new(&target),
6606 path.as_os_str(),
6607 ]);
6608 if !ok {
6609 let _ = std::fs::remove_file(&path);
6613 if message.to_ascii_lowercase().contains("permission denied") {
6614 return Err(ServiceError::NeedsElevation {
6615 operation: "install",
6616 name,
6617 detail: message,
6618 remedy: SUDO_REMEDY,
6619 });
6620 }
6621 return Err(self.failed("install", &name, message));
6622 }
6623 let service_target = self.service_target(plan.identity());
6626 enable_launchd_registration(
6627 |arguments| self.launchctl(arguments),
6628 &target,
6629 &service_target,
6630 &path,
6631 &name,
6632 SUDO_REMEDY,
6633 )?;
6634 Ok(definition)
6635 }
6636
6637 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6638 let Some(path) = self.plist_path(identity) else {
6639 return Ok(false);
6640 };
6641 if !path.exists() {
6642 return Ok(false);
6643 }
6644 let target = self.service_target(identity);
6645 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6646 if !ok
6647 && !message.to_ascii_lowercase().contains("no such process")
6648 && !message.contains("113")
6649 {
6650 if message.to_ascii_lowercase().contains("permission denied") {
6651 return Err(ServiceError::NeedsElevation {
6652 operation: "uninstall",
6653 name: identity.name().to_string(),
6654 detail: message,
6655 remedy: SUDO_REMEDY,
6656 });
6657 }
6658 return Err(self.failed("uninstall", identity.name(), message));
6659 }
6660 std::fs::remove_file(&path).map_err(|error| {
6663 super::definition_error(
6664 "uninstall",
6665 identity.name(),
6666 error,
6667 SUDO_REMEDY,
6668 path.as_path(),
6669 )
6670 })?;
6671 Ok(true)
6672 }
6673
6674 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6675 let Some(path) = self.plist_path(identity) else {
6676 return Ok(None);
6677 };
6678 let Ok(document) = std::fs::read_to_string(&path) else {
6679 return Ok(None);
6680 };
6681 let target = self.service_target(identity);
6682 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6683 Ok(Some(Registration {
6684 manager: DefinitionKind::LaunchdPlist,
6685 start_mode: self.mode,
6686 command_line: program_arguments(&document),
6687 account: plist_string_value(&document, "UserName")
6688 .or_else(|| Some("the invoking user".to_string())),
6689 running: loaded && printed.contains("state = running"),
6690 starts_automatically: document.contains("<key>RunAtLoad</key>")
6691 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6692 restart_delay: xml_value(
6693 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6694 "integer",
6695 )
6696 .and_then(|value| value.parse::<u64>().ok())
6697 .map(Duration::from_secs),
6698 }))
6699 }
6700
6701 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6702 let target = self.service_target(identity);
6703 let (ok, message) = self.launchctl(&[
6704 OsStr::new("kickstart"),
6705 OsStr::new("-k"),
6706 OsStr::new(&target),
6707 ]);
6708 if ok {
6709 Ok(())
6710 } else {
6711 Err(self.failed("start", identity.name(), message))
6712 }
6713 }
6714
6715 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6716 let running = self
6717 .query(identity)?
6718 .is_some_and(|registration| registration.running);
6719 if !running {
6720 return Ok(false);
6721 }
6722 let target = self.service_target(identity);
6723 let (ok, message) = self.launchctl(&[
6724 OsStr::new("kill"),
6725 OsStr::new("SIGTERM"),
6726 OsStr::new(&target),
6727 ]);
6728 if ok {
6729 Ok(true)
6730 } else {
6731 Err(self.failed("stop", identity.name(), message))
6732 }
6733 }
6734 }
6735
6736 pub(super) fn reload_detached(
6751 identity: &ServiceIdentity,
6752 mode: StartMode,
6753 plist: &std::path::Path,
6754 ) -> Result<(), ServiceError> {
6755 use std::os::unix::process::CommandExt;
6756
6757 const SCRIPT: &str = r#"(
6758 sleep 1
6759 /bin/launchctl bootout "$1"
6760 tries=0
6761 until /bin/launchctl bootstrap "$2" "$3"; do
6762 tries=$((tries + 1))
6763 if [ "$tries" -ge 60 ]; then
6764 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
6765 exit 1
6766 fi
6767 sleep 1
6768 done
6769) </dev/null &"#;
6770
6771 let control = LaunchdControl { mode };
6772 let reinstall = match mode {
6775 StartMode::Boot => "sudo runner-manager service install --start-at boot",
6776 StartMode::Login => "runner-manager service install --start-at login",
6777 };
6778 let mut command = std::process::Command::new("/bin/sh");
6779 command
6780 .args(["-c", SCRIPT, "runner-manager-reload"])
6781 .arg(control.service_target(identity))
6782 .arg(control.domain())
6783 .arg(plist)
6784 .arg(reinstall)
6785 .stdin(std::process::Stdio::null());
6786 unsafe {
6789 command.pre_exec(|| {
6790 if libc::setsid() == -1 {
6791 return Err(std::io::Error::last_os_error());
6792 }
6793 Ok(())
6794 });
6795 }
6796 let status = command.status().map_err(|error| {
6797 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
6798 })?;
6799 if status.success() {
6800 Ok(())
6801 } else {
6802 Err(control.failed(
6803 "reload",
6804 identity.name(),
6805 format!("the reload helper exited with {status}"),
6806 ))
6807 }
6808 }
6809
6810 fn program_arguments(document: &str) -> String {
6812 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6813 return String::new();
6814 };
6815 let Some(end) = rest.find("</array>") else {
6816 return String::new();
6817 };
6818 let mut out = Vec::new();
6819 let mut cursor = &rest[..end];
6820 while let Some(open) = cursor.find("<string>") {
6821 let after = &cursor[open + "<string>".len()..];
6822 let Some(close) = after.find("</string>") else {
6823 break;
6824 };
6825 out.push(quote_argument(&super::xml_unescape(&after[..close])));
6826 cursor = &after[close..];
6827 }
6828 out.join(" ")
6829 }
6830}
6831
6832#[cfg(all(unix, not(target_os = "macos")))]
6837mod sys {
6838 use std::ffi::OsStr;
6848 use std::path::PathBuf;
6849 use std::time::Duration;
6850
6851 use runner_manager_domain::model::StartMode;
6852
6853 use super::{
6854 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6855 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6856 home_directory, ini_directives, run, write_definition,
6857 };
6858
6859 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6860 Ok(Box::new(SystemdControl { mode }))
6861 }
6862
6863 #[derive(Debug)]
6864 struct SystemdControl {
6865 mode: StartMode,
6866 }
6867
6868 impl SystemdControl {
6869 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6870 let file = identity.systemd_unit();
6871 match self.mode {
6872 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6873 StartMode::Login => {
6874 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6875 }
6876 }
6877 }
6878
6879 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6881 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6882 if self.mode == StartMode::Login {
6883 all.push(OsStr::new("--user"));
6884 }
6885 all.extend(arguments.iter().map(OsStr::new));
6886 match run("systemctl", &all) {
6887 Ok((ok, stdout, stderr)) => (
6888 ok,
6889 if stdout.trim().is_empty() {
6890 stderr
6891 } else {
6892 stdout
6893 },
6894 ),
6895 Err(error) => (false, error.to_string()),
6896 }
6897 }
6898
6899 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6900 ServiceError::Control {
6901 operation,
6902 name: name.to_string(),
6903 manager: "systemd",
6904 detail,
6905 }
6906 }
6907 }
6908
6909 impl ServiceControl for SystemdControl {
6910 fn manager(&self) -> DefinitionKind {
6911 DefinitionKind::SystemdUnit
6912 }
6913
6914 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6915 let name = plan.identity().name().to_string();
6916 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6917 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6918 return Err(self.failed(
6919 "install",
6920 &name,
6921 "this account has no home directory, so there is nowhere to put a systemd \
6922 user unit. Use --start-at boot, which installs a system unit under \
6923 /etc/systemd/system."
6924 .to_string(),
6925 ));
6926 };
6927 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6928 let unit = plan.identity().systemd_unit();
6929 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6930 if !reloaded {
6931 let _ = std::fs::remove_file(&path);
6932 return Err(self.failed("install", &name, message));
6933 }
6934 let (enabled, message) = self.systemctl(&["enable", &unit]);
6935 if !enabled {
6936 let _ = std::fs::remove_file(&path);
6937 let _ = self.systemctl(&["daemon-reload"]);
6938 return Err(self.failed("install", &name, message));
6939 }
6940 Ok(definition)
6941 }
6942
6943 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6944 let Some(path) = self.unit_path(identity) else {
6945 return Ok(false);
6946 };
6947 if !path.exists() {
6948 return Ok(false);
6949 }
6950 let unit = identity.systemd_unit();
6951 let _ = self.systemctl(&["disable", "--now", &unit]);
6956 std::fs::remove_file(&path).map_err(|error| {
6957 super::definition_error(
6958 "uninstall",
6959 identity.name(),
6960 error,
6961 SUDO_REMEDY,
6962 path.as_path(),
6963 )
6964 })?;
6965 let _ = self.systemctl(&["daemon-reload"]);
6966 Ok(true)
6967 }
6968
6969 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6970 let Some(path) = self.unit_path(identity) else {
6971 return Ok(None);
6972 };
6973 let Ok(document) = std::fs::read_to_string(&path) else {
6974 return Ok(None);
6975 };
6976 let unit = identity.systemd_unit();
6977 let directives = ini_directives(&document, "Service");
6978 let (_, active) = self.systemctl(&["is-active", &unit]);
6979 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6980 Ok(Some(Registration {
6981 manager: DefinitionKind::SystemdUnit,
6982 start_mode: self.mode,
6983 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6984 account: directives.get("User").cloned().or_else(|| {
6985 Some(match self.mode {
6986 StartMode::Boot => "root".to_string(),
6987 StartMode::Login => "the invoking user".to_string(),
6988 })
6989 }),
6990 running: active.trim() == "active",
6991 starts_automatically: enabled.trim() == "enabled",
6992 restart_delay: directives
6993 .get("RestartSec")
6994 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6995 .map(Duration::from_secs),
6996 }))
6997 }
6998
6999 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7000 let unit = identity.systemd_unit();
7001 let (ok, message) = self.systemctl(&["start", &unit]);
7002 if ok {
7003 Ok(())
7004 } else {
7005 Err(self.failed("start", identity.name(), message))
7006 }
7007 }
7008
7009 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7010 let running = self
7011 .query(identity)?
7012 .is_some_and(|registration| registration.running);
7013 if !running {
7014 return Ok(false);
7015 }
7016 let unit = identity.systemd_unit();
7017 let (ok, message) = self.systemctl(&["stop", &unit]);
7018 if ok {
7019 Ok(true)
7020 } else {
7021 Err(self.failed("stop", identity.name(), message))
7022 }
7023 }
7024 }
7025}
7026
7027#[cfg(all(test, target_os = "macos"))]
7029pub(crate) mod launchd_fixture {
7030 use std::path::{Path, PathBuf};
7031
7032 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
7036
7037 pub(crate) fn enabled() -> bool {
7040 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
7041 if !enabled {
7042 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
7043 }
7044 enabled
7045 }
7046
7047 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
7055 let binary = directory.join("launch-agent-job");
7056 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
7057 .expect("test binary copied");
7058 binary
7059 }
7060
7061 pub(crate) struct LoadedAgent(String);
7063
7064 impl LoadedAgent {
7065 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
7068 let domain = format!("gui/{}", unsafe { libc::getuid() });
7071 let loaded = Self(format!("{domain}/{label}"));
7073 let bootstrap = std::process::Command::new("launchctl")
7074 .arg("bootstrap")
7075 .arg(&domain)
7076 .arg(plist)
7077 .output()
7078 .expect("launchctl runs");
7079 assert!(
7080 bootstrap.status.success(),
7081 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
7082 String::from_utf8_lossy(&bootstrap.stderr)
7083 );
7084 loaded
7085 }
7086
7087 pub(crate) fn target(&self) -> &str {
7089 &self.0
7090 }
7091 }
7092
7093 impl Drop for LoadedAgent {
7094 fn drop(&mut self) {
7095 let _ = std::process::Command::new("launchctl")
7096 .args(["bootout", &self.0])
7097 .output();
7098 }
7099 }
7100}
7101
7102#[cfg(test)]
7103mod tests {
7104 use super::*;
7105
7106 use std::collections::BTreeMap;
7107
7108 #[test]
7109 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
7110 assert!(windows_login_task_starts_automatically(
7111 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
7112 ));
7113 assert!(windows_login_task_starts_automatically(
7114 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
7115 ));
7116 assert!(!windows_login_task_starts_automatically(
7117 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
7118 ));
7119 assert!(!windows_login_task_starts_automatically(
7120 "<Task><BootTrigger></BootTrigger></Task>"
7121 ));
7122 }
7123
7124 fn linux_plan(mode: StartMode) -> InstallPlan {
7131 InstallPlan::unchecked(
7132 ServiceIdentity::product(),
7133 mode,
7134 "/opt/runner-manager/bin/runner-manager",
7135 ServiceDirectories {
7136 config: PathBuf::from("/var/lib/runner-manager/config"),
7137 state: PathBuf::from("/var/lib/runner-manager/state"),
7138 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
7139 logs: PathBuf::from("/var/log/runner-manager"),
7140 },
7141 )
7142 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
7143 }
7144
7145 fn windows_plan(mode: StartMode) -> InstallPlan {
7146 InstallPlan::unchecked(
7147 ServiceIdentity::product(),
7148 mode,
7149 "C:\\Program Files\\runner-manager\\runner-manager.exe",
7150 ServiceDirectories {
7151 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
7152 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
7153 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
7154 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
7155 },
7156 )
7157 }
7158
7159 fn edited(text: &str, from: &str, to: &str) -> String {
7167 assert!(
7168 text.contains(from),
7169 "the rendered definition does not contain `{from}`, so this test would assert \
7170 nothing about a widened one"
7171 );
7172 text.replace(from, to)
7173 }
7174
7175 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
7177 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
7178 let Ok(entries) = std::fs::read_dir(directory) else {
7179 return;
7180 };
7181 for entry in entries.flatten() {
7182 let path = entry.path();
7183 if path.is_dir() {
7184 walk(&path, out);
7185 } else if let Ok(bytes) = std::fs::read(&path) {
7186 out.insert(path, bytes);
7187 }
7188 }
7189 }
7190 let mut out = BTreeMap::new();
7191 for root in roots {
7192 walk(root, &mut out);
7193 }
7194 out
7195 }
7196
7197 struct Host {
7198 _root: tempfile::TempDir,
7199 paths: AppPaths,
7200 binary: PathBuf,
7201 runner_root: LocalAbsolutePath,
7210 controls: RecordingControls,
7211 }
7212
7213 impl Host {
7214 fn new() -> Self {
7215 let root = tempfile::tempdir().expect("a temporary directory");
7216 let paths = AppPaths::rooted_at(root.path());
7217 paths.create_all().expect("the four directories");
7218 let binary = root.path().join(if cfg!(windows) {
7219 "runner-manager.exe"
7220 } else {
7221 "runner-manager"
7222 });
7223 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
7224 let runner_root = LocalAbsolutePath::new(
7225 root.path()
7226 .join("runner-root")
7227 .to_str()
7228 .expect("a unicode temporary path"),
7229 )
7230 .expect("a local absolute path");
7231 Self {
7232 _root: root,
7233 paths,
7234 binary,
7235 runner_root,
7236 controls: RecordingControls::new(),
7237 }
7238 }
7239
7240 fn operations(&self) -> ServiceOperations {
7241 ServiceOperations::with_controls(
7242 self.paths.clone(),
7243 ServiceIdentity::product(),
7244 std::sync::Arc::new(self.controls.clone()),
7245 )
7246 .with_runner_root(self.runner_root.clone())
7247 }
7248
7249 fn request(&self, mode: StartMode) -> InstallRequest {
7250 InstallRequest::new(mode).for_binary(&self.binary)
7251 }
7252 }
7253
7254 #[cfg(windows)]
7255 #[test]
7256 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
7257 let probes = std::cell::Cell::new(0);
7258 let absent =
7259 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
7260 let next = probes.get() + 1;
7261 probes.set(next);
7262 Ok(next == 3)
7263 })
7264 .expect("the simulated SCM probe succeeds");
7265
7266 assert!(absent);
7267 assert_eq!(
7268 probes.get(),
7269 3,
7270 "uninstall must recheck after transient presence instead of treating it as a leak"
7271 );
7272 }
7273
7274 #[cfg(windows)]
7275 #[test]
7276 fn windows_recognises_an_already_pending_service_deletion() {
7277 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
7278
7279 assert!(super::sys::is_marked_for_delete(&error));
7280 }
7281
7282 #[test]
7283 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
7284 let root = tempfile::tempdir().expect("a temporary directory");
7285 let plist = root.path().join("fixture.plist");
7286 std::fs::write(&plist, b"fixture").expect("a plist fixture");
7287 let calls = std::cell::RefCell::new(Vec::new());
7288
7289 let error = enable_launchd_registration(
7290 |arguments| {
7291 let call = arguments
7292 .iter()
7293 .map(|argument| argument.to_string_lossy().into_owned())
7294 .collect::<Vec<_>>();
7295 let operation = call[0].clone();
7296 calls.borrow_mut().push(call);
7297 if operation == "enable" {
7298 (false, "label remains disabled".to_string())
7299 } else {
7300 (true, String::new())
7301 }
7302 },
7303 "system",
7304 "system/com.openai.runner-manager-selftest",
7305 &plist,
7306 "runner-manager-selftest",
7307 "rerun with administrative rights",
7308 )
7309 .expect_err("enable failure must fail the install");
7310
7311 assert!(
7312 matches!(
7313 error,
7314 ServiceError::Control {
7315 operation: "enable",
7316 ..
7317 }
7318 ),
7319 "{error}"
7320 );
7321 assert_eq!(calls.borrow().len(), 2);
7322 assert_eq!(calls.borrow()[0][0], "enable");
7323 assert_eq!(calls.borrow()[1][0], "bootout");
7324 assert!(!plist.exists(), "rollback must remove the plist");
7325 }
7326
7327 #[test]
7332 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
7333 let plan = windows_plan(StartMode::Boot);
7334 let command_line = plan.command_line();
7335 assert!(
7336 command_line.starts_with('"'),
7337 "a path with a space must be quoted, got {command_line}"
7338 );
7339 assert_eq!(
7340 executable_from_command_line(&command_line).as_deref(),
7341 Some(plan.binary())
7342 );
7343 }
7344
7345 #[test]
7346 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
7347 let plan = linux_plan(StartMode::Boot);
7348 let command_line = plan.command_line();
7349 assert!(!command_line.starts_with('"'), "got {command_line}");
7350 assert_eq!(
7351 executable_from_command_line(&command_line).as_deref(),
7352 Some(plan.binary())
7353 );
7354 }
7355
7356 #[test]
7357 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
7358 let awkward = r#"C:\odd "name"\rm.exe"#;
7361 let quoted = quote_argument(awkward);
7362 assert_eq!(
7363 executable_from_command_line(&format!("{quoted} daemon run"))
7364 .as_deref()
7365 .map(Path::to_string_lossy)
7366 .as_deref(),
7367 Some(awkward)
7368 );
7369 }
7370
7371 #[test]
7372 fn an_empty_command_line_has_no_executable() {
7373 assert_eq!(executable_from_command_line(" "), None);
7374 assert_eq!(executable_from_command_line(""), None);
7375 }
7376
7377 #[test]
7378 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
7379 let awkward = r#"DOMAIN\R&D <team> "ops""#;
7380 assert_eq!(
7381 xml_escape(awkward),
7382 "DOMAIN\\R&D <team> "ops""
7383 );
7384 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
7385 }
7386
7387 #[test]
7392 fn a_restart_delay_under_the_floor_is_refused() {
7393 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
7394 .expect_err("half a second is under the one-second floor");
7395 assert!(
7396 matches!(error, ServiceError::RestartDelay { .. }),
7397 "{error}"
7398 );
7399 }
7400
7401 #[test]
7402 fn a_restart_delay_over_the_ceiling_is_refused() {
7403 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
7404 .expect_err("an hour is over the five-minute ceiling");
7405 assert!(
7406 matches!(error, ServiceError::RestartDelay { .. }),
7407 "{error}"
7408 );
7409 }
7410
7411 #[test]
7412 fn a_reset_window_no_longer_than_the_delay_is_refused() {
7413 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
7414 .expect_err("a window equal to the delay can never elapse between restarts");
7415 assert!(
7416 matches!(error, ServiceError::RestartResetWindow { .. }),
7417 "{error}"
7418 );
7419 }
7420
7421 #[test]
7422 fn a_delay_inside_the_bound_is_accepted() {
7423 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
7424 .expect("twenty seconds is inside the bound");
7425 assert_eq!(policy.delay(), Duration::from_secs(20));
7426 assert_eq!(policy.reset_after(), Duration::from_secs(300));
7427 }
7428
7429 #[test]
7434 fn a_fixture_identity_can_never_be_the_product_identity() {
7435 let fixture = ServiceIdentity::fixture("abc123");
7436 assert!(fixture.is_fixture());
7437 assert!(!ServiceIdentity::product().is_fixture());
7438 assert_ne!(fixture.name(), ServiceIdentity::product().name());
7439 assert_ne!(
7440 fixture.launchd_label(),
7441 ServiceIdentity::product().launchd_label()
7442 );
7443 assert_ne!(
7444 fixture.systemd_unit(),
7445 ServiceIdentity::product().systemd_unit()
7446 );
7447 }
7448
7449 #[test]
7450 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
7451 let fixture = ServiceIdentity::fixture("A b/c:\\d");
7452 assert!(
7453 fixture
7454 .name()
7455 .chars()
7456 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7457 "got {}",
7458 fixture.name()
7459 );
7460 }
7461
7462 #[test]
7467 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7468 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7469 assert!(unit.contains("KillMode=process\n"), "{unit}");
7470 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7471 assert!(unit.contains("RestartSec=15\n"), "{unit}");
7472 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7473 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7474 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7475 }
7476
7477 #[test]
7478 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7479 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7480 assert!(
7481 !unit.contains("LoadCredential="),
7482 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7483 );
7484 }
7485
7486 #[test]
7487 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7488 let unit = systemd_unit(&linux_plan(StartMode::Login));
7489 assert!(
7490 !unit.contains("LoadCredential="),
7491 "a user unit must not name a root-owned credential file, got:\n{unit}"
7492 );
7493 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7494 }
7495
7496 #[test]
7497 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7498 let plan = linux_plan(StartMode::Boot);
7499 let unit = systemd_unit(&plan);
7500 let directives = ini_directives(&unit, "Service");
7501 let listed = split_quoted(
7502 directives
7503 .get("ReadWritePaths")
7504 .expect("the unit names its writable paths"),
7505 );
7506 assert_eq!(listed.len(), 5, "{listed:?}");
7507 for path in plan.directories().all() {
7508 assert!(
7509 listed.iter().any(|entry| entry == &path.to_string_lossy()),
7510 "{} is missing from {listed:?}",
7511 path.display()
7512 );
7513 }
7514 assert!(
7515 listed
7516 .iter()
7517 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7518 "atomic credential replacement needs its parent directory in {listed:?}"
7519 );
7520 }
7521
7522 #[test]
7523 fn the_unit_records_the_absolute_binary_path() {
7524 let plan = linux_plan(StartMode::Boot);
7525 let unit = systemd_unit(&plan);
7526 assert!(
7527 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7528 "{unit}"
7529 );
7530 }
7531
7532 #[test]
7537 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7538 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7539 assert!(
7542 plist.contains(
7543 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
7544 ),
7545 "{plist}"
7546 );
7547 assert!(
7548 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
7549 "{plist}"
7550 );
7551 }
7552
7553 #[test]
7554 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7555 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7556 assert_eq!(
7557 plist_string_value(&daemon, "UserName").as_deref(),
7558 Some("root")
7559 );
7560 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7561
7562 let agent = launchd_plist(&linux_plan(StartMode::Login));
7563 assert_eq!(
7564 plist_string_value(&agent, "UserName"),
7565 None,
7566 "a LaunchAgent already runs as the operator:\n{agent}"
7567 );
7568 }
7569
7570 #[test]
7571 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
7572 for mode in [StartMode::Boot, StartMode::Login] {
7573 let plan = linux_plan(mode);
7574 let plist = launchd_plist(&plan);
7575 assert_eq!(
7576 plist_string_value(&plist, "ProcessType").as_deref(),
7577 Some("Interactive"),
7578 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
7579 );
7580 let review = review_least_privilege(
7581 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
7582 &plan,
7583 );
7584 assert!(
7585 review
7586 .controls()
7587 .iter()
7588 .any(|control| control == "ProcessType=Interactive"),
7589 "{review}"
7590 );
7591 assert!(
7592 !review
7593 .findings()
7594 .iter()
7595 .any(|finding| finding.subject == "ProcessType"),
7596 "{review}"
7597 );
7598 }
7599 }
7600
7601 #[test]
7605 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
7606 let plan = linux_plan(StartMode::Login);
7607 let rendered = launchd_plist(&plan);
7608 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
7609 for (label, throttled) in [
7610 (
7611 "Background",
7612 edited(
7613 &rendered,
7614 interactive,
7615 "<key>ProcessType</key>\n <string>Background</string>\n",
7616 ),
7617 ),
7618 ("absent", edited(&rendered, interactive, "")),
7619 ] {
7620 let review = review_least_privilege(
7621 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
7622 &plan,
7623 );
7624 assert!(review.is_least_privilege(), "{label}: {review}");
7625 assert!(
7626 review.findings().iter().any(|finding| {
7627 finding.kind == FindingKind::Shortfall
7628 && finding.subject == "ProcessType"
7629 && finding.detail.contains(label)
7630 }),
7631 "{label}: {review}"
7632 );
7633 }
7634 }
7635
7636 #[test]
7637 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
7638 let host = Host::new();
7639 let identity = ServiceIdentity::product();
7640 let plan = InstallPlan::resolve(
7641 identity.clone(),
7642 &host.request(StartMode::Login),
7643 ServiceDirectories::of(&host.paths),
7644 )
7645 .expect("a resolvable plan");
7646 let installed = launchd_plist(&plan);
7647 let definition =
7648 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
7649 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7650 record.installed_by_version = "0.4.31".into();
7651 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
7652
7653 assert_eq!(
7654 launchd_definition_drift(&identity, &record, path.clone(), &installed),
7655 None
7656 );
7657 let edited_by_operator = edited(
7658 &installed,
7659 "</dict>\n</plist>\n",
7660 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
7661 );
7662 assert_eq!(
7663 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
7664 None,
7665 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
7666 );
7667
7668 let written_by_0_4_31 = edited(
7669 &installed,
7670 "<string>Interactive</string>",
7671 "<string>Background</string>",
7672 );
7673 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
7674 .expect("a Background plist is drift");
7675 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
7676 assert_eq!(drift.start_mode, StartMode::Login);
7677 assert_eq!(
7678 drift.rendered, installed,
7679 "the repair must write exactly what `service install` writes"
7680 );
7681
7682 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7683 .with_definition_drift(Some(drift));
7684 let problem = status
7685 .problems()
7686 .iter()
7687 .find(|problem| problem.subject == "definition")
7688 .expect("the drift is reported");
7689 assert!(
7692 problem.detail.contains("0.4.31")
7693 && problem.detail.contains(
7694 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
7695 )
7696 && !problem.detail.contains("service install")
7697 && !problem.detail.contains("systemctl")
7698 && !problem.detail.contains("sudo"),
7699 "{}",
7700 problem.detail
7701 );
7702 }
7703
7704 #[cfg(unix)]
7705 #[test]
7706 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
7707 use std::os::unix::fs::PermissionsExt as _;
7708
7709 let root = tempfile::tempdir().expect("a temporary directory");
7710 let path = root
7711 .path()
7712 .join("io.github.IvanMurzak.runner-manager.plist");
7713 std::fs::write(&path, "<string>Background</string>").expect("old plist");
7714 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
7715 let drift = DefinitionDrift {
7716 path: path.clone(),
7717 installed_by_version: "0.4.31".into(),
7718 rendered: launchd_plist(&linux_plan(StartMode::Login)),
7719 start_mode: StartMode::Login,
7720 manager: DefinitionKind::LaunchdPlist,
7721 };
7722
7723 replace_definition("runner-manager", &drift).expect("replaced");
7724
7725 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
7726 assert_eq!(
7727 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
7728 0o644
7729 );
7730 assert_eq!(
7731 std::fs::read_dir(root.path()).unwrap().count(),
7732 1,
7733 "no staging file is left beside the plist"
7734 );
7735 }
7736
7737 #[cfg(target_os = "macos")]
7746 #[test]
7747 #[ignore = "run only by launchd, as the job of the detached-reload test"]
7748 fn detached_reload_job() {
7749 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
7750 let tag = label
7751 .split_once("-selftest-")
7752 .map(|(_, tag)| tag.to_string())
7753 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
7754 let identity = ServiceIdentity::fixture(&tag);
7755 assert_eq!(identity.launchd_label(), label);
7756 let path = std::env::current_dir()
7757 .expect("working directory")
7758 .join(format!("{label}.plist"));
7759 let installed = std::fs::read_to_string(&path).expect("own plist");
7760 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
7761 let drift = DefinitionDrift {
7762 path: path.clone(),
7763 installed_by_version: "0.4.31".into(),
7764 rendered: installed.replace(
7765 "<string>Background</string>",
7766 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
7767 ),
7768 start_mode: StartMode::Login,
7769 manager: DefinitionKind::LaunchdPlist,
7770 };
7771 replace_definition(identity.name(), &drift).expect("plist replaced");
7772 super::sys::reload_detached(&identity, StartMode::Login, &path)
7773 .expect("helper started");
7774 }
7775 std::thread::sleep(Duration::from_secs(120));
7776 }
7777
7778 #[cfg(target_os = "macos")]
7788 #[test]
7789 fn a_job_reloads_itself_from_its_rewritten_plist() {
7790 if !super::launchd_fixture::enabled() {
7791 return;
7792 }
7793 let root = tempfile::tempdir().expect("a temporary directory");
7794 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
7795 let label = identity.launchd_label();
7796 let path = root.path().join(format!("{label}.plist"));
7797 let binary = super::launchd_fixture::job_binary(root.path());
7798 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
7799 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
7800 plist.push_str(&plist_string("Label", &label));
7801 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
7802 for argument in [
7803 binary.to_string_lossy().as_ref(),
7804 "--ignored",
7805 "--exact",
7806 "service::tests::detached_reload_job",
7807 "--test-threads=1",
7808 ] {
7809 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
7810 }
7811 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
7812 plist.push_str(&plist_string(
7813 "WorkingDirectory",
7814 &root.path().to_string_lossy(),
7815 ));
7816 plist.push_str(&plist_string("ProcessType", "Background"));
7817 plist.push_str("</dict>\n</plist>\n");
7818 std::fs::write(&path, plist).expect("plist written");
7819
7820 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
7821 let spawn_type = || {
7822 let printed = std::process::Command::new("launchctl")
7823 .args(["print", loaded.target()])
7824 .output()
7825 .expect("launchctl runs");
7826 String::from_utf8_lossy(&printed.stdout)
7827 .lines()
7828 .find_map(|line| {
7829 line.trim()
7830 .strip_prefix("spawn type = ")
7831 .map(str::to_string)
7832 })
7833 };
7834 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
7835
7836 let deadline = std::time::Instant::now() + Duration::from_secs(90);
7837 while spawn_type().as_deref() != Some("interactive (4)") {
7838 assert!(
7839 std::time::Instant::now() < deadline,
7840 "the job did not come back under its rewritten plist; launchd reports {:?}",
7841 spawn_type()
7842 );
7843 std::thread::sleep(Duration::from_millis(250));
7844 }
7845 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
7846 assert_eq!(
7847 plist_string_value(&rewritten, "ProcessType").as_deref(),
7848 Some(LAUNCHD_PROCESS_TYPE)
7849 );
7850 }
7851
7852 #[test]
7853 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
7854 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7855 assert!(
7856 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
7857 "{plist}"
7858 );
7859 assert!(plist.contains("<string>daemon</string>"), "{plist}");
7860 assert!(plist.contains("<string>run</string>"), "{plist}");
7861 }
7862
7863 #[test]
7864 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
7865 assert_eq!(
7866 ServiceIdentity::product().launchd_label(),
7867 "io.github.IvanMurzak.runner-manager"
7868 );
7869 }
7870
7871 #[test]
7876 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
7877 let xml = windows_scheduled_task_xml(
7878 &windows_plan(StartMode::Login),
7879 &TaskPrincipal::named("HOST\\operator"),
7880 );
7881 assert!(xml.contains("<LogonTrigger>"), "{xml}");
7882 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
7883 assert!(
7884 xml.contains("<LogonType>InteractiveToken</LogonType>"),
7885 "{xml}"
7886 );
7887 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
7888 assert!(
7889 xml.contains("<Interval>PT1M</Interval>"),
7890 "Task Scheduler takes whole minutes only, and rejects the registration outright \
7891 for anything finer:\n{xml}"
7892 );
7893 }
7894
7895 #[test]
7896 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
7897 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
7902 let policy =
7903 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
7904 .expect("inside the supported range");
7905 assert_eq!(
7906 policy
7907 .effective_delay(DefinitionKind::WindowsScheduledTask)
7908 .as_secs(),
7909 enforced,
7910 "a {asked}s delay must be enforced as {enforced}s"
7911 );
7912 assert!(
7913 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
7914 "rounding must never shorten the bound"
7915 );
7916 }
7917 }
7918
7919 #[test]
7920 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
7921 let policy = RestartPolicy::default();
7922 for kind in [
7923 DefinitionKind::WindowsService,
7924 DefinitionKind::LaunchdPlist,
7925 DefinitionKind::SystemdUnit,
7926 ] {
7927 assert_eq!(
7928 policy.effective_delay(kind),
7929 policy.delay(),
7930 "{kind:?} takes seconds and enforces exactly what it is given"
7931 );
7932 }
7933 }
7934
7935 #[test]
7936 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
7937 let host = Host::new();
7938 let operations = host.operations();
7939 operations
7940 .install(&host.request(StartMode::Login))
7941 .expect("an install at login");
7942 host.controls.edit("runner-manager", |registration| {
7944 registration.manager = DefinitionKind::WindowsScheduledTask;
7945 registration.restart_delay = Some(Duration::from_secs(60));
7946 });
7947
7948 let status = operations.status().expect("a status");
7949 assert!(
7950 status.is_healthy(),
7951 "minute granularity is the manager's, not a mis-registration: {status}"
7952 );
7953 assert!(
7954 status
7955 .notes()
7956 .iter()
7957 .any(|note| note.contains("whole minutes")),
7958 "but the operator must be told why 15 became 60: {status}"
7959 );
7960
7961 host.controls.edit("runner-manager", |registration| {
7964 registration.restart_delay = Some(Duration::from_secs(1));
7965 });
7966 assert!(
7967 !operations.status().expect("a status").is_healthy(),
7968 "a one-second delay is not what any manager was asked for"
7969 );
7970 }
7971
7972 #[test]
7973 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
7974 let plan = windows_plan(StartMode::Login);
7975 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
7976 assert_eq!(
7977 xml_value(&xml, "Command").as_deref(),
7978 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
7979 "{xml}"
7980 );
7981 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
7982 }
7983
7984 #[test]
7985 fn an_account_name_holding_xml_punctuation_is_escaped() {
7986 let xml = windows_scheduled_task_xml(
7987 &windows_plan(StartMode::Login),
7988 &TaskPrincipal::named("R&D\\ops"),
7989 );
7990 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
7991 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
7992 }
7993
7994 #[test]
7999 fn the_service_starts_automatically_under_the_account_the_store_admits() {
8000 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
8001 let directives = ini_directives(&text, "windows-service");
8002 assert_eq!(
8003 directives.get("StartType").map(String::as_str),
8004 Some("AutoStart")
8005 );
8006 assert_eq!(
8007 directives.get("Account").map(String::as_str),
8008 Some("NT AUTHORITY\\SYSTEM")
8009 );
8010 assert_eq!(
8011 directives.get("ServiceType").map(String::as_str),
8012 Some("OWN_PROCESS")
8013 );
8014 assert_eq!(
8015 directives
8016 .get("FailureActionRestartDelaySecs")
8017 .map(String::as_str),
8018 Some("15")
8019 );
8020 assert_eq!(
8021 directives
8022 .get("FailureActionsOnNonCrashFailures")
8023 .map(String::as_str),
8024 Some("true"),
8025 "without this flag a non-zero exit is not a failure the manager restarts"
8026 );
8027 }
8028
8029 #[test]
8030 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
8031 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
8032 assert_eq!(spec.account, None);
8033 assert!(spec.automatic_start);
8034 assert!(
8035 spec.command_line.contains("daemon run"),
8036 "{}",
8037 spec.command_line
8038 );
8039 }
8040
8041 #[test]
8046 fn each_definition_goes_where_its_platform_expects_it() {
8047 let home = PathBuf::from("/home/op");
8048 assert_eq!(
8049 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8050 Some(Path::new(
8051 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
8052 ))
8053 );
8054 assert_eq!(
8055 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8056 Some(Path::new(
8057 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
8058 ))
8059 );
8060 assert_eq!(
8061 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
8062 Some(Path::new("/etc/systemd/system/runner-manager.service"))
8063 );
8064 assert_eq!(
8065 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
8066 Some(Path::new(
8067 "/home/op/.config/systemd/user/runner-manager.service"
8068 ))
8069 );
8070 assert_eq!(
8071 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
8072 None,
8073 "the Service Control Manager has no file"
8074 );
8075 }
8076
8077 #[test]
8078 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
8079 assert_eq!(
8080 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
8081 None
8082 );
8083 assert_eq!(
8084 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
8085 None
8086 );
8087 }
8088
8089 #[test]
8094 fn the_rendered_definitions_are_all_least_privilege() {
8095 let linux = linux_plan(StartMode::Boot);
8096 let windows = windows_plan(StartMode::Boot);
8097 for (definition, plan) in [
8098 (ServiceDefinition::systemd(&linux, None), &linux),
8099 (ServiceDefinition::launchd(&linux, None), &linux),
8100 (ServiceDefinition::windows_service(&windows), &windows),
8101 ] {
8102 let review = review_least_privilege(&definition, plan);
8103 assert!(
8104 review.is_least_privilege(),
8105 "{:?} should be least privilege, got:\n{review}",
8106 definition.kind()
8107 );
8108 assert!(
8109 !review.controls().is_empty(),
8110 "a review that confirms nothing proves nothing: {:?}",
8111 definition.kind()
8112 );
8113 }
8114 }
8115
8116 #[test]
8117 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
8118 let plan = windows_plan(StartMode::Login);
8119 let definition =
8120 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
8121 let review = review_least_privilege(&definition, &plan);
8122 assert!(review.is_least_privilege(), "{review}");
8123 assert!(
8124 review
8125 .controls()
8126 .iter()
8127 .any(|control| control.contains("LeastPrivilege")),
8128 "{review}"
8129 );
8130 }
8131
8132 #[test]
8133 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
8134 let plan = linux_plan(StartMode::Boot);
8135 let rendered = systemd_unit(&plan);
8136 let widened = edited(
8137 &rendered,
8138 "ReadWritePaths=/var/lib/runner-manager/config",
8139 "ReadWritePaths=/etc /var/lib/runner-manager/config",
8140 );
8141 let review = review_least_privilege(
8142 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8143 &plan,
8144 );
8145 assert!(!review.is_least_privilege(), "{review}");
8146 assert!(
8147 review
8148 .excesses()
8149 .iter()
8150 .any(|finding| finding.detail.contains("/etc")),
8151 "the review must name the directory it objects to: {review}"
8152 );
8153 }
8154
8155 #[test]
8156 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
8157 let plan = linux_plan(StartMode::Boot);
8158 let rendered = systemd_unit(&plan);
8159 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
8160 let review = review_least_privilege(
8161 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
8162 &plan,
8163 );
8164 assert!(!review.is_least_privilege(), "{review}");
8165 assert!(
8166 review
8167 .excesses()
8168 .iter()
8169 .any(|finding| finding.subject == "NoNewPrivileges"),
8170 "{review}"
8171 );
8172 }
8173
8174 #[test]
8175 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
8176 let plan = linux_plan(StartMode::Boot);
8177 let rendered = systemd_unit(&plan);
8178 let widened = edited(
8179 &rendered,
8180 "CapabilityBoundingSet=\n",
8181 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
8182 );
8183 let review = review_least_privilege(
8184 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8185 &plan,
8186 );
8187 assert!(!review.is_least_privilege(), "{review}");
8188 assert!(
8189 review
8190 .excesses()
8191 .iter()
8192 .any(|finding| finding.subject == "CapabilityBoundingSet"),
8193 "{review}"
8194 );
8195 }
8196
8197 #[test]
8198 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
8199 let plan = linux_plan(StartMode::Boot);
8200 let rendered = systemd_unit(&plan);
8201 let widened = edited(
8202 &rendered,
8203 "[Install]",
8204 "ListenStream=127.0.0.1:9000\n\n[Install]",
8205 );
8206 let review = review_least_privilege(
8207 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8208 &plan,
8209 );
8210 assert!(
8211 !review.is_least_privilege(),
8212 "07-security.md rule 2 forbids any inbound surface: {review}"
8213 );
8214 }
8215
8216 #[test]
8217 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
8218 let plan = linux_plan(StartMode::Boot);
8219 let rendered = systemd_unit(&plan);
8220 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
8221 let review = review_least_privilege(
8222 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
8223 &plan,
8224 );
8225 assert!(
8226 review.is_least_privilege(),
8227 "too little authority is not an excess: {review}"
8228 );
8229 assert!(
8230 review
8231 .findings()
8232 .iter()
8233 .any(|finding| finding.kind == FindingKind::Shortfall
8234 && finding.detail.contains("runtime")),
8235 "{review}"
8236 );
8237 }
8238
8239 #[test]
8240 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
8241 let plan = linux_plan(StartMode::Login);
8242 let rendered = launchd_plist(&plan);
8243 let widened = edited(
8244 &rendered,
8245 "<key>ProcessType</key>",
8246 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
8247 );
8248 let review = review_least_privilege(
8249 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8250 &plan,
8251 );
8252 assert!(!review.is_least_privilege(), "{review}");
8253 assert!(
8254 review
8255 .excesses()
8256 .iter()
8257 .any(|finding| finding.subject == "UserName"),
8258 "{review}"
8259 );
8260 }
8261
8262 #[test]
8263 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
8264 let plan = linux_plan(StartMode::Boot);
8265 let rendered = launchd_plist(&plan);
8266 let widened = edited(
8267 &rendered,
8268 "<key>SessionCreate</key>\n <false/>",
8269 "<key>SessionCreate</key>\n <true/>",
8270 );
8271 let review = review_least_privilege(
8272 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8273 &plan,
8274 );
8275 assert!(!review.is_least_privilege(), "{review}");
8276 assert!(
8277 review
8278 .excesses()
8279 .iter()
8280 .any(|finding| finding.subject == "SessionCreate"),
8281 "{review}"
8282 );
8283 }
8284
8285 #[test]
8286 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
8287 let plan = linux_plan(StartMode::Boot);
8288 let rendered = launchd_plist(&plan);
8289 let widened = edited(
8290 &rendered,
8291 "<key>ProcessType</key>",
8292 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
8293 );
8294 let review = review_least_privilege(
8295 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8296 &plan,
8297 );
8298 assert!(!review.is_least_privilege(), "{review}");
8299 }
8300
8301 #[test]
8302 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
8303 let plan = windows_plan(StartMode::Login);
8304 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8305 let widened = edited(
8306 &rendered,
8307 "<RunLevel>LeastPrivilege</RunLevel>",
8308 "<RunLevel>HighestAvailable</RunLevel>",
8309 );
8310 let review = review_least_privilege(
8311 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8312 &plan,
8313 );
8314 assert!(!review.is_least_privilege(), "{review}");
8315 assert!(
8316 review
8317 .excesses()
8318 .iter()
8319 .any(|finding| finding.subject == "RunLevel"),
8320 "{review}"
8321 );
8322 }
8323
8324 #[test]
8325 fn a_task_that_would_store_a_password_is_not_least_privilege() {
8326 let plan = windows_plan(StartMode::Login);
8327 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8328 let widened = edited(
8329 &rendered,
8330 "<LogonType>InteractiveToken</LogonType>",
8331 "<LogonType>Password</LogonType>",
8332 );
8333 let review = review_least_privilege(
8334 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8335 &plan,
8336 );
8337 assert!(!review.is_least_privilege(), "{review}");
8338 }
8339
8340 #[test]
8341 fn an_interactive_windows_service_is_not_least_privilege() {
8342 let plan = windows_plan(StartMode::Boot);
8343 let rendered = windows_service_descriptor(&plan);
8344 let widened = edited(
8345 &rendered,
8346 "ServiceType=OWN_PROCESS",
8347 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
8348 );
8349 let review = review_least_privilege(
8350 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
8351 &plan,
8352 );
8353 assert!(!review.is_least_privilege(), "{review}");
8354 assert!(
8355 review
8356 .excesses()
8357 .iter()
8358 .any(|finding| finding.subject == "ServiceType"),
8359 "{review}"
8360 );
8361 }
8362
8363 #[test]
8364 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
8365 let plan = windows_plan(StartMode::Boot);
8366 let rendered = windows_service_descriptor(&plan);
8367 let changed = edited(
8368 &rendered,
8369 "Account=NT AUTHORITY\\SYSTEM",
8370 "Account=NT AUTHORITY\\LocalService",
8371 );
8372 let review = review_least_privilege(
8373 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
8374 &plan,
8375 );
8376 assert!(review.is_least_privilege(), "{review}");
8382 assert!(
8383 review.findings().iter().any(|finding| {
8384 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
8385 }),
8386 "{review}"
8387 );
8388 }
8389
8390 #[test]
8395 fn a_recorded_path_that_is_still_there_is_current() {
8396 let host = Host::new();
8397 let state = inspect_binary(&host.binary, Some(&host.binary));
8398 assert!(!state.is_error(), "{state}");
8399 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
8400 }
8401
8402 #[test]
8403 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
8404 let host = Host::new();
8405 let recorded = host.binary.clone();
8409 let healthy = inspect_binary(&recorded, Some(&recorded));
8410 assert!(
8411 !healthy.is_error(),
8412 "the discriminator: before the binary moves, this must be healthy"
8413 );
8414
8415 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
8416
8417 let state = inspect_binary(&recorded, Some(&recorded));
8418 assert!(state.is_error(), "{state}");
8419 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
8420 assert!(
8421 state.to_string().contains("npm"),
8422 "the message must name the cause an operator will not otherwise connect: {state}"
8423 );
8424 }
8425
8426 #[test]
8427 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
8428 let root = tempfile::tempdir().expect("a temporary directory");
8429 let state = inspect_binary(root.path(), None);
8430 assert!(state.is_error(), "{state}");
8431 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
8432 }
8433
8434 #[test]
8435 fn a_registration_naming_a_different_binary_is_a_divergence() {
8436 let host = Host::new();
8437 let other = host.binary.with_file_name("something-else");
8438 let state = inspect_binary(&host.binary, Some(&other));
8439 assert!(state.is_error(), "{state}");
8440 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
8441 }
8442
8443 #[test]
8444 fn absence_is_reported_before_divergence() {
8445 let host = Host::new();
8446 let recorded = host.binary.clone();
8447 std::fs::remove_file(&recorded).expect("removable");
8448 let other = recorded.with_file_name("something-else");
8449 assert!(matches!(
8453 inspect_binary(&recorded, Some(&other)),
8454 BinaryPath::Missing { .. }
8455 ));
8456 }
8457
8458 #[test]
8463 fn the_record_round_trips_through_toml() {
8464 let host = Host::new();
8465 let plan = InstallPlan::resolve(
8466 ServiceIdentity::product(),
8467 &host.request(StartMode::Boot),
8468 ServiceDirectories::of(&host.paths),
8469 )
8470 .expect("a resolvable plan");
8471 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8472 let record = InstallRecord::of(&plan, &definition, Utc::now());
8473 record.write(&host.paths).expect("a writable record");
8474 let read = InstallRecord::read(&host.paths)
8475 .expect("a readable record")
8476 .expect("a record is there");
8477 assert_eq!(read, record);
8478 assert_eq!(read.binary, host.binary);
8479 assert!(read.binary.is_absolute());
8480 }
8481
8482 #[test]
8483 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
8484 let host = Host::new();
8485 let identity = ServiceIdentity::product();
8486 let plan = InstallPlan::resolve(
8487 identity.clone(),
8488 &host.request(StartMode::Boot),
8489 ServiceDirectories::of(&host.paths),
8490 )
8491 .expect("a resolvable plan");
8492 let installed = systemd_unit(&plan);
8493 let definition =
8494 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
8495 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8496 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
8497
8498 assert_eq!(
8499 systemd_definition_drift(&identity, &record, path.clone(), &installed),
8500 None,
8501 "the record must rebuild exactly the unit `service install` wrote"
8502 );
8503
8504 record.installed_by_version = "0.4.7".into();
8505 let frozen = installed.replace(
8506 "[Service]\n",
8507 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
8508 );
8509 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
8510 .expect("an outdated unit is drift");
8511 assert_eq!(drift.installed_by_version, "0.4.7");
8512 assert_eq!(drift.rendered, installed);
8513
8514 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8515 .with_definition_drift(Some(drift));
8516 assert!(!status.is_healthy());
8517 assert!(
8518 status
8519 .problems()
8520 .iter()
8521 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
8522 "{status}"
8523 );
8524 }
8525
8526 #[test]
8527 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
8528 let host = Host::new();
8529 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8530 let first = Utc::now() - chrono::Duration::hours(9);
8531 record_github_credential_rejected(&host.paths, first).unwrap();
8532 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
8533 let since = github_credential_rejected_since(&host.paths).unwrap();
8534 assert_eq!(since, Some(first));
8535
8536 let status = ServiceStatus::compose(
8537 ServiceIdentity::product(),
8538 None,
8539 None,
8540 None,
8541 None,
8542 &host.paths,
8543 )
8544 .with_credential_rejection(since);
8545 assert!(!status.is_healthy());
8546 assert_eq!(status.credential_rejected_since(), Some(first));
8547 assert!(status.to_string().contains("auth login"), "{status}");
8548
8549 clear_github_credential_rejection(&host.paths).unwrap();
8550 clear_github_credential_rejection(&host.paths).unwrap();
8551 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8552 }
8553
8554 #[test]
8556 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
8557 use crate::launch_health::{
8558 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
8559 record_launches_blocked,
8560 };
8561
8562 let host = Host::new();
8563 let compose = || {
8564 ServiceStatus::compose(
8565 ServiceIdentity::product(),
8566 None,
8567 None,
8568 None,
8569 None,
8570 &host.paths,
8571 )
8572 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
8573 };
8574 assert!(
8575 !compose()
8576 .problems()
8577 .iter()
8578 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
8579 );
8580 let blocked = BlockCause::FenceHeld {
8581 directory: PathBuf::from("launch-fence"),
8582 owner: None,
8583 }
8584 .blocked_since(Utc::now(), Viewpoint::Guest);
8585 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
8586
8587 let status = compose();
8588 assert!(!status.is_healthy());
8589 assert_eq!(status.launches_blocked(), Some(&blocked));
8590 let problem = status
8591 .problems()
8592 .iter()
8593 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
8594 .expect("reported under its own subject");
8595 assert!(problem.detail.contains("systemctl restart"), "{status}");
8596
8597 clear_launches_blocked(&host.paths).unwrap();
8598 assert_eq!(compose().launches_blocked(), None);
8599 }
8600
8601 #[test]
8604 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
8605 let host = Host::new();
8606 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8607 let first = Utc::now() - chrono::Duration::minutes(40);
8608 record_credential_unreadable(&host.paths, first).unwrap();
8609 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
8610 let since = credential_unreadable_since(&host.paths).unwrap();
8611 assert_eq!(since, Some(first), "a restart loop must not move the start");
8612
8613 let status = ServiceStatus::compose(
8614 ServiceIdentity::product(),
8615 None,
8616 None,
8617 None,
8618 None,
8619 &host.paths,
8620 )
8621 .with_credential_unreadable(since);
8622 assert!(!status.is_healthy());
8623 let problem = status
8624 .problems()
8625 .iter()
8626 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
8627 .expect("reported under its own subject");
8628 assert!(
8630 problem
8631 .detail
8632 .contains(&sign_in_instruction(StartMode::default())),
8633 "{status}"
8634 );
8635
8636 clear_credential_unreadable(&host.paths).unwrap();
8637 clear_credential_unreadable(&host.paths).unwrap();
8638 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
8639 let healthy = ServiceStatus::compose(
8640 ServiceIdentity::product(),
8641 None,
8642 None,
8643 None,
8644 None,
8645 &host.paths,
8646 )
8647 .with_credential_unreadable(None);
8648 assert!(
8649 !healthy
8650 .problems()
8651 .iter()
8652 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
8653 "{healthy}"
8654 );
8655 }
8656
8657 #[test]
8658 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
8659 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
8660 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
8661 assert_eq!(
8662 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
8663 cfg!(unix),
8664 "a machine-scoped store needs root on Unix"
8665 );
8666 assert_eq!(
8667 sign_in_instruction(StartMode::Login).contains("SSH"),
8668 cfg!(target_os = "macos"),
8669 "only the macOS login keychain is out of an SSH session's reach"
8670 );
8671 }
8672
8673 #[cfg(unix)]
8682 #[test]
8683 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
8684 use std::os::unix::fs::PermissionsExt as _;
8685
8686 let host = Host::new();
8687 let plan = InstallPlan::resolve(
8688 ServiceIdentity::product(),
8689 &host.request(StartMode::Boot),
8690 ServiceDirectories::of(&host.paths),
8691 )
8692 .expect("a resolvable plan");
8693 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8694 InstallRecord::of(&plan, &definition, Utc::now())
8695 .write(&host.paths)
8696 .expect("a writable record");
8697
8698 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
8699 .expect("the record is there")
8700 .permissions()
8701 .mode()
8702 & 0o777;
8703 assert_eq!(
8704 mode, 0o644,
8705 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
8706 service install` wrote, and `service status` fails on their own host. It holds no \
8707 credential and sits in a 0700 directory, so 0644 discloses nothing"
8708 );
8709 }
8710
8711 #[test]
8715 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
8716 let host = Host::new();
8717 let path = InstallRecord::path(&host.paths);
8718 std::fs::write(
8719 &path,
8720 format!(
8721 "schema_version = {RECORD_SCHEMA_VERSION}
8722service_name = \"runner-manager\"
8723 manager = \"systemd\"
8724start_mode = \"boot\"
8725account = \"root\"
8726 binary = \"/x\"
8727arguments = []
8728restart_delay_secs = 15
8729 restart_reset_secs = 600
8730log_file = \"/x\"
8731 installed_at = \"2026-01-01T00:00:00Z\"
8732installed_by_version = \"0.1.0\"
8733 [directories]
8734config = \"/a\"
8735state = \"/b\"
8736runtime = \"/c\"
8737logs = \"/d\"
8738"
8739 ),
8740 )
8741 .expect("a writable record");
8742 let read = InstallRecord::read(&host.paths)
8743 .expect("a record missing an optional field is still readable")
8744 .expect("a record is there");
8745 assert_eq!(
8746 read.source_binary, None,
8747 "the legacy layout has no source, and must not invent one"
8748 );
8749 }
8750
8751 #[test]
8753 fn a_registration_remembers_the_file_it_was_copied_from() {
8754 let host = Host::new();
8755 let source = host.binary.with_file_name("npm-installed-runner-manager");
8756 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
8757 let plan = InstallPlan::resolve(
8758 ServiceIdentity::product(),
8759 &host.request(StartMode::Boot).copied_from(&source),
8760 ServiceDirectories::of(&host.paths),
8761 )
8762 .expect("a resolvable plan");
8763 let definition = ServiceDefinition::from_text(
8764 DefinitionKind::SystemdUnit,
8765 "[Service]
8766",
8767 );
8768 let record = InstallRecord::of(&plan, &definition, Utc::now());
8769 record.write(&host.paths).expect("a writable record");
8770
8771 let read = InstallRecord::read(&host.paths)
8772 .expect("a readable record")
8773 .expect("a record is there");
8774 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
8775 assert_ne!(
8776 read.source_binary.as_deref(),
8777 Some(read.binary.as_path()),
8778 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
8779 );
8780 }
8781
8782 #[test]
8783 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
8784 let host = Host::new();
8785 let path = InstallRecord::path(&host.paths);
8786 std::fs::write(
8787 &path,
8788 format!(
8789 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
8790 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
8791 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
8792 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
8793 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
8794 RECORD_SCHEMA_VERSION + 1
8795 ),
8796 )
8797 .expect("a writable record");
8798 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
8799 assert!(
8800 matches!(error, ServiceError::RecordUnreadable { .. }),
8801 "{error}"
8802 );
8803 assert!(
8804 error.to_string().contains("service uninstall"),
8805 "the message must say how to recover: {error}"
8806 );
8807 }
8808
8809 #[test]
8810 fn no_record_is_not_an_error() {
8811 let host = Host::new();
8812 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
8813 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
8814 }
8815
8816 #[test]
8821 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
8822 let host = Host::new();
8823 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
8824 }
8825
8826 #[test]
8827 fn the_heartbeat_round_trips_to_the_second() {
8828 let host = Host::new();
8829 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
8830 .expect("a valid timestamp")
8831 .with_timezone(&Utc);
8832 record_github_contact(&host.paths, at).expect("a writable heartbeat");
8833 assert_eq!(
8834 last_github_contact(&host.paths).expect("readable"),
8835 Some(at)
8836 );
8837 }
8838
8839 #[test]
8840 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
8841 let host = Host::new();
8842 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
8843 let error = last_github_contact(&host.paths)
8844 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
8845 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8846 }
8847
8848 #[test]
8854 fn a_runner_root_refusal_round_trips_for_service_status() {
8855 let host = Host::new();
8856 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8857
8858 assert!(
8859 runner_root_refusals(&host.paths)
8860 .expect("readable")
8861 .is_empty(),
8862 "no record means every policy is placing runners"
8863 );
8864
8865 record_runner_root_refusal(
8866 &host.paths,
8867 "policy-a",
8868 at,
8869 "denied_by_privacy_policy",
8870 "/Volumes/NVME/runners",
8871 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
8872 )
8873 .expect("a writable record");
8874
8875 let refusals = runner_root_refusals(&host.paths).expect("readable");
8876 assert_eq!(refusals.len(), 1);
8877 assert_eq!(refusals[0].policy, "policy-a");
8878 assert_eq!(refusals[0].at, at);
8879 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
8880 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
8881 assert!(
8882 refusals[0].detail.contains("/Volumes/NVME/runners")
8883 && refusals[0].detail.contains("Full Disk Access"),
8884 "the path and the remediation are the whole point of this file: {refusals:?}"
8885 );
8886 }
8887
8888 #[test]
8896 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
8897 let host = Host::new();
8898 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8899 record_runner_root_refusal(
8900 &host.paths,
8901 "broken",
8902 at,
8903 "denied_by_privacy_policy",
8904 "/Volumes/NVME/runners",
8905 "detail",
8906 )
8907 .expect("a writable record");
8908 record_runner_root_refusal(
8909 &host.paths,
8910 "also-broken",
8911 at,
8912 "not_writable",
8913 "/srv/other",
8914 "detail",
8915 )
8916 .expect("a writable record");
8917
8918 clear_runner_root_refusal(&host.paths, "healthy")
8920 .expect("clearing an absent policy is fine");
8921 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
8922
8923 let refusals = runner_root_refusals(&host.paths).expect("readable");
8924 assert_eq!(
8925 refusals
8926 .iter()
8927 .map(|r| r.policy.as_str())
8928 .collect::<Vec<_>>(),
8929 vec!["broken"],
8930 "the policy that is still refused must keep its record"
8931 );
8932 }
8933
8934 #[test]
8937 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
8938 let host = Host::new();
8939 record_runner_root_refusal(
8940 &host.paths,
8941 "p",
8942 Utc::now(),
8943 "not_writable",
8944 "/srv/x",
8945 "detail",
8946 )
8947 .expect("a writable record");
8948
8949 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
8950 assert!(
8951 !root_refusal_path(&host.paths).exists(),
8952 "a host with nothing refused leaves nothing behind"
8953 );
8954 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
8955 }
8956
8957 #[test]
8960 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
8961 let host = Host::new();
8962 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
8963 let error = runner_root_refusals(&host.paths)
8964 .expect_err("an unparseable record is not the same as no record");
8965 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8966 }
8967
8968 #[test]
8978 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
8979 let host = Host::new();
8980 record_runner_root_refusal(
8981 &host.paths,
8982 "policy-a",
8983 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
8984 "denied_by_privacy_policy",
8985 "/Volumes/NVME/runners",
8986 "Grant Full Disk Access to the program that runs the service",
8987 )
8988 .expect("a writable record");
8989
8990 let status = host.operations().status().expect("a readable status");
8991 let notes = status.notes().join("\n");
8992
8993 assert!(
8994 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
8995 "the directory and the remediation the log had to scrub must appear here: {notes}"
8996 );
8997 assert!(
8998 notes.contains("policy-a"),
8999 "the operator has to know which target placed no runner: {notes}"
9000 );
9001 assert!(
9002 !status
9003 .problems()
9004 .iter()
9005 .any(|problem| problem.subject == "runner root"),
9006 "a record nothing an operator types can clear must not drive the exit code"
9007 );
9008 }
9009
9010 #[test]
9016 fn install_records_the_absolute_binary_path_and_the_four_directories() {
9017 let host = Host::new();
9018 let installed = host
9019 .operations()
9020 .install(&host.request(StartMode::Boot))
9021 .expect("an install against the recording controls");
9022
9023 assert_eq!(installed.record.binary, host.binary);
9024 assert!(installed.record.binary.is_absolute());
9025 assert_eq!(installed.record.start_mode, StartMode::Boot);
9026 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
9027 assert_eq!(
9028 installed.record.directories,
9029 ServiceDirectories::of(&host.paths)
9030 );
9031 assert_eq!(
9032 installed.record.log_file,
9033 host.paths.logs_dir().join(LOG_FILE_STEM)
9034 );
9035 assert_eq!(installed.record.restart_delay_secs, 15);
9036
9037 let registrations = host.controls.registrations();
9038 assert_eq!(registrations.len(), 1);
9039 assert_eq!(registrations[0].0, StartMode::Boot);
9040 assert_eq!(registrations[0].1, "runner-manager");
9041 }
9042
9043 #[test]
9044 fn install_is_refused_while_the_single_instance_lock_is_held() {
9045 let host = Host::new();
9046 {
9048 let operations = host.operations();
9049 operations
9050 .install(&host.request(StartMode::Boot))
9051 .expect("an install with the lock free");
9052 operations.uninstall().expect("a clean slate");
9053 }
9054
9055 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
9056 .expect("this process takes the lock first");
9057
9058 let error = host
9059 .operations()
9060 .install(&host.request(StartMode::Boot))
9061 .expect_err("a second agent must not be registered while one is running");
9062 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
9063 assert!(
9064 error.to_string().contains("already running"),
9065 "the message must be actionable: {error}"
9066 );
9067 assert!(
9068 host.controls.registrations().is_empty(),
9069 "a refused install must register nothing"
9070 );
9071 assert_eq!(
9072 InstallRecord::read(&host.paths).expect("readable"),
9073 None,
9074 "a refused install must write no record"
9075 );
9076 }
9077
9078 #[test]
9086 fn installing_over_the_same_start_mode_replaces_the_registration() {
9087 let host = Host::new();
9088 let operations = host.operations();
9089 operations
9090 .install(&host.request(StartMode::Boot))
9091 .expect("the first install");
9092
9093 let again = operations
9094 .install(&host.request(StartMode::Boot))
9095 .expect("an install over the same mode replaces rather than refusing");
9096
9097 assert!(
9098 again.replaced_existing,
9099 "the operator is told this replaced something rather than made it"
9100 );
9101 assert_eq!(
9102 host.controls.registrations().len(),
9103 1,
9104 "replacing must not leave two registrations behind"
9105 );
9106 assert_eq!(
9107 InstallRecord::read(&host.paths)
9108 .expect("readable")
9109 .expect("a record")
9110 .start_mode,
9111 StartMode::Boot
9112 );
9113 }
9114
9115 #[test]
9119 fn installing_over_the_other_start_mode_is_refused() {
9120 let host = Host::new();
9121 let operations = host.operations();
9122 operations
9123 .install(&host.request(StartMode::Boot))
9124 .expect("the first install");
9125
9126 let error = operations
9127 .install(&host.request(StartMode::Login))
9128 .expect_err("a mode change is not an install");
9129 assert!(
9130 matches!(
9131 error,
9132 ServiceError::AlreadyInstalled {
9133 existing: StartMode::Boot,
9134 requested: StartMode::Login,
9135 ..
9136 }
9137 ),
9138 "{error}"
9139 );
9140 assert!(
9141 !error
9142 .to_string()
9143 .contains("switch the start mode in place,"),
9144 "the old remedy named a capability no command offers; the terminal UI is where \
9145 the start mode moves: {error}"
9146 );
9147 assert_eq!(host.controls.registrations().len(), 1);
9148 }
9149
9150 #[test]
9151 fn install_rolls_back_the_registration_when_record_persistence_fails() {
9152 let host = Host::new();
9153 let record_path = InstallRecord::path(&host.paths);
9154 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
9155
9156 let error = host
9157 .operations()
9158 .install(&host.request(StartMode::Boot))
9159 .expect_err("record persistence must fail");
9160
9161 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9162 assert!(
9163 host.controls.registrations().is_empty(),
9164 "a failed install must not leave a live unrecorded registration"
9165 );
9166 assert!(
9167 host.controls
9168 .calls()
9169 .iter()
9170 .any(|call| call == "uninstall runner-manager (boot)"),
9171 "the registration must be explicitly rolled back: {:?}",
9172 host.controls.calls()
9173 );
9174 assert!(
9175 !host.runner_root.as_path().exists(),
9176 "the rollback must take the runner root this install created with it; a directory \
9177 prepared for a registration that does not exist is litter, and on Windows it is \
9178 litter with a security descriptor"
9179 );
9180 }
9181
9182 #[test]
9187 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
9188 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
9189
9190 assert_eq!(
9194 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9195 ServiceAccount::LocalSystem
9196 );
9197 assert_eq!(
9198 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
9199 ServiceAccount::InvokingUser
9200 );
9201
9202 let boot = default_root_sddl(&RootAdmission::LocalSystem);
9203 assert!(!grants_broad_write(&boot), "{boot}");
9204 assert!(
9205 !boot.contains("S-1-5-21"),
9206 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
9207 );
9208
9209 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
9214 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
9215 assert!(!grants_broad_write(&login), "{login}");
9216 }
9217
9218 #[test]
9219 fn an_install_reports_the_runner_root_it_prepared() {
9220 let host = Host::new();
9221 let installed = host
9222 .operations()
9223 .install(&host.request(StartMode::Boot))
9224 .expect("an install");
9225 let rendered = installed.runner_root.to_string();
9226 assert!(
9227 !rendered.contains("S-1-5-21"),
9228 "the report must add no identity to the output: {rendered}"
9229 );
9230 if cfg!(windows) {
9231 assert_eq!(
9232 installed.runner_root.path(),
9233 Some(host.runner_root.as_path())
9234 );
9235 assert!(
9236 host.runner_root.as_path().is_dir(),
9237 "the directory jobs would run in has to exist once the service is registered"
9238 );
9239 } else {
9240 assert_eq!(
9241 installed.runner_root,
9242 crate::runner_root_access::RootAccessSummary::NotApplicable,
9243 "macOS and Linux keep the runtime directory they have always used"
9244 );
9245 }
9246 }
9247
9248 #[test]
9249 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
9250 let host = Host::new();
9251 let operations = host.operations();
9252 operations
9253 .install(&host.request(StartMode::Boot))
9254 .expect("an install at boot");
9255
9256 let change = operations
9257 .set_start_mode(StartMode::Login)
9258 .expect("a switch to login");
9259
9260 assert!(change.changed);
9261 if cfg!(windows) {
9262 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
9263 assert!(
9264 host.runner_root.as_path().is_dir(),
9265 "the switch must not remove the directory it reconciled"
9266 );
9267 }
9268 assert!(
9271 change.to_string().contains("runner root"),
9272 "{}",
9273 change.to_string()
9274 );
9275 }
9276
9277 #[test]
9278 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
9279 let host = Host::new();
9280 let operations = host.operations();
9281 operations
9282 .install(&host.request(StartMode::Boot))
9283 .expect("an install at boot");
9284
9285 let change = operations
9286 .set_start_mode(StartMode::Boot)
9287 .expect("a switch to the mode already in force");
9288
9289 assert!(!change.changed);
9290 assert_eq!(
9291 change.runner_root,
9292 crate::runner_root_access::RootAccessSummary::NotApplicable,
9293 "nothing moves, so nothing about the root's access control has to; reconciling here \
9294 would turn a no-op command into one that can fail on a permission it does not need"
9295 );
9296 }
9297
9298 #[cfg(windows)]
9299 #[test]
9300 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
9301 let host = Host::new();
9302 host.controls
9303 .fail_next_install(StartMode::Boot, "injected registration failure");
9304
9305 let error = host
9306 .operations()
9307 .install(&host.request(StartMode::Boot))
9308 .expect_err("the manager refuses the registration");
9309
9310 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9311 assert!(
9312 !host.runner_root.as_path().exists(),
9313 "the directory was created for a registration that does not exist"
9314 );
9315 }
9316
9317 #[cfg(windows)]
9318 #[test]
9319 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
9320 let host = Host::new();
9321 crate::runner_root_access::create_with_descriptor_for_tests(
9325 host.runner_root.as_path(),
9326 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
9327 )
9328 .expect("a deliberately open runner root");
9329 let before = crate::runner_root_access::report(host.runner_root.as_path());
9330
9331 let error = host
9332 .operations()
9333 .install(&host.request(StartMode::Boot))
9334 .expect_err("an open runner root is refused");
9335
9336 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
9337 assert!(
9338 error.to_string().contains("nothing was registered"),
9339 "{error}"
9340 );
9341 assert!(
9342 host.controls.registrations().is_empty(),
9343 "the refusal has to come before the platform is asked to register anything: {:?}",
9344 host.controls.calls()
9345 );
9346 assert_eq!(
9347 crate::runner_root_access::report(host.runner_root.as_path()),
9348 before,
9349 "an open directory is refused rather than tightened: its contents cannot be trusted, \
9350 so adopting it would be worse than declining it"
9351 );
9352 }
9353
9354 #[cfg(windows)]
9355 #[test]
9356 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
9357 let host = Host::new();
9358 let operations = host.operations();
9359 operations
9360 .install(&host.request(StartMode::Boot))
9361 .expect("an install");
9362 assert!(host.runner_root.as_path().is_dir());
9363
9364 operations.uninstall().expect("an uninstall");
9365
9366 assert!(
9367 host.runner_root.as_path().is_dir(),
9368 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
9369 runner root may hold an operator's retained workspaces."
9370 );
9371 }
9372
9373 #[test]
9374 fn install_reviews_what_it_registered() {
9375 let host = Host::new();
9376 let installed = host
9377 .operations()
9378 .install(&host.request(StartMode::Boot))
9379 .expect("an install");
9380 assert!(
9381 installed.review.is_least_privilege(),
9382 "{}",
9383 installed.review
9384 );
9385 assert!(
9386 !installed.review.controls().is_empty(),
9387 "a review that confirms nothing proves nothing: {}",
9388 installed.review
9389 );
9390 assert_eq!(
9391 installed.review.kind(),
9392 host_definition_kind(StartMode::Boot),
9393 "the review must be of the definition this host's manager was given"
9394 );
9395 assert!(
9396 !installed.review.account().justification().is_empty(),
9397 "a privileged account with no stated reason is an unreviewed one"
9398 );
9399 }
9400
9401 #[test]
9406 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
9407 let host = Host::new();
9408 let operations = host.operations();
9409 operations
9410 .install(&host.request(StartMode::Boot))
9411 .expect("an install");
9412
9413 let config = host.paths.config_dir();
9417 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
9418 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
9419 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
9420 std::fs::write(
9421 host.paths
9422 .state_dir()
9423 .join("packages/2.330.0/runner.tar.gz"),
9424 b"cached package",
9425 )
9426 .expect("writable");
9427 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
9428 std::fs::write(
9429 host.paths.state_dir().join("secrets/user-access-token"),
9430 b"a stand-in for the stored credential",
9431 )
9432 .expect("writable");
9433 std::fs::write(
9434 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
9435 b"diagnostics",
9436 )
9437 .expect("writable");
9438
9439 let roots: Vec<PathBuf> = host
9440 .paths
9441 .all()
9442 .iter()
9443 .map(|(_, path)| (*path).to_path_buf())
9444 .collect();
9445 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
9446 let before = snapshot(&roots);
9447
9448 assert!(
9451 before.len() >= 6,
9452 "the fixture must actually contain the files this test is about, got {before:#?}"
9453 );
9454 let record_path = InstallRecord::path(&host.paths);
9455 assert!(
9456 before.contains_key(&record_path),
9457 "the install record must be present before uninstall"
9458 );
9459
9460 let uninstalled = operations.uninstall().expect("an uninstall");
9461 assert!(uninstalled.removed_registration);
9462 assert!(uninstalled.removed_record);
9463
9464 let after = snapshot(&roots);
9465
9466 let mut expected = before.clone();
9468 expected.remove(&record_path);
9469 assert_eq!(
9470 after, expected,
9471 "uninstall must remove its own record and nothing else"
9472 );
9473 assert!(
9474 !record_path.exists(),
9475 "the record itself must go, or `uninstall` did nothing at all"
9476 );
9477 assert!(
9478 uninstalled
9479 .preserved
9480 .iter()
9481 .all(|path| roots.contains(&path.as_path())),
9482 "the preserved list must name the four directories: {uninstalled}"
9483 );
9484 }
9485
9486 #[test]
9487 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
9488 let host = Host::new();
9489 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
9490 assert!(!uninstalled.removed_registration);
9491 assert!(!uninstalled.removed_record);
9492 }
9493
9494 #[test]
9495 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
9496 let host = Host::new();
9497 let operations = host.operations();
9498 operations
9499 .install(&host.request(StartMode::Boot))
9500 .expect("an install");
9501 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
9502
9503 let uninstalled = operations.uninstall().expect("an uninstall");
9504 assert!(
9505 uninstalled.removed_registration,
9506 "a lost record must not strand a registration"
9507 );
9508 assert!(host.controls.registrations().is_empty());
9509 }
9510
9511 #[test]
9516 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
9517 let host = Host::new();
9518 let operations = host.operations();
9519 operations
9520 .install(&host.request(StartMode::Boot))
9521 .expect("an install at boot");
9522
9523 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
9527
9528 let change = operations
9529 .set_start_mode(StartMode::Login)
9530 .expect("a switch that does not reinstall the product");
9531 assert!(change.changed);
9532 assert_eq!(change.from, StartMode::Boot);
9533 assert_eq!(change.to, StartMode::Login);
9534 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
9535
9536 let record = InstallRecord::read(&host.paths)
9537 .expect("readable")
9538 .expect("a record");
9539 assert_eq!(record.start_mode, StartMode::Login);
9540 assert_eq!(
9541 record.binary, host.binary,
9542 "the recorded path must survive the switch untouched"
9543 );
9544
9545 let registrations = host.controls.registrations();
9546 assert_eq!(registrations.len(), 1, "{registrations:?}");
9547 assert_eq!(registrations[0].0, StartMode::Login);
9548 assert!(
9549 registrations[0]
9550 .2
9551 .command_line
9552 .contains(&host.binary.to_string_lossy().into_owned()),
9553 "{:?}",
9554 registrations[0].2
9555 );
9556 }
9557
9558 #[test]
9559 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
9560 let host = Host::new();
9561 let operations = host.operations();
9562 operations
9563 .install(&host.request(StartMode::Boot))
9564 .expect("an install at boot");
9565 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9566 host.controls
9567 .fail_next_install(StartMode::Login, "injected target failure");
9568
9569 let error = operations
9570 .set_start_mode(StartMode::Login)
9571 .expect_err("the target manager refuses the install");
9572
9573 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9574 assert_eq!(
9575 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9576 record_before
9577 );
9578 let registrations = host.controls.registrations();
9579 assert_eq!(registrations.len(), 1, "{registrations:?}");
9580 assert_eq!(registrations[0].0, StartMode::Boot);
9581 }
9582
9583 #[test]
9584 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
9585 let host = Host::new();
9586 let operations = host.operations();
9587 operations
9588 .install(&host.request(StartMode::Boot))
9589 .expect("an install at boot");
9590 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9591 let config = host.paths.config_dir().to_path_buf();
9592 let hidden = config.with_file_name("config-hidden-by-fault");
9593 host.controls.hide_directory_after_install(
9594 StartMode::Login,
9595 config.clone(),
9596 hidden.clone(),
9597 );
9598
9599 let error = operations
9600 .set_start_mode(StartMode::Login)
9601 .expect_err("the injected filesystem fault prevents persistence");
9602
9603 std::fs::remove_file(&config).expect("remove the injected blocker");
9604 std::fs::rename(&hidden, &config).expect("restore the record directory");
9605 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9606 assert_eq!(
9607 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9608 record_before
9609 );
9610 let registrations = host.controls.registrations();
9611 assert_eq!(registrations.len(), 1, "{registrations:?}");
9612 assert_eq!(registrations[0].0, StartMode::Boot);
9613 assert!(
9614 host.controls
9615 .calls()
9616 .iter()
9617 .any(|call| call == "uninstall runner-manager (login)"),
9618 "the target must be rolled back: {:?}",
9619 host.controls.calls()
9620 );
9621 }
9622
9623 #[test]
9624 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
9625 let host = Host::new();
9626 let operations = host.operations();
9627 operations
9628 .install(&host.request(StartMode::Boot))
9629 .expect("an install");
9630 let before = host.controls.calls().len();
9631
9632 let change = operations
9633 .set_start_mode(StartMode::Boot)
9634 .expect("a no-op switch");
9635 assert!(!change.changed);
9636 assert_eq!(
9637 host.controls.calls().len(),
9638 before,
9639 "a no-op switch must not touch the service manager"
9640 );
9641 }
9642
9643 #[test]
9644 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
9645 let host = Host::new();
9646 let error = host
9647 .operations()
9648 .set_start_mode(StartMode::Login)
9649 .expect_err("there is nothing to switch");
9650 assert!(
9651 matches!(error, ServiceError::NotInstalled { .. }),
9652 "{error}"
9653 );
9654 }
9655
9656 #[test]
9661 fn status_reports_the_four_facts_journey_five_asks_for() {
9662 let host = Host::new();
9663 let operations = host.operations();
9664 operations
9665 .install(&host.request(StartMode::Boot))
9666 .expect("an install");
9667 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
9668 .expect("a valid timestamp")
9669 .with_timezone(&Utc);
9670 record_github_contact(&host.paths, at).expect("a heartbeat");
9671
9672 let status = operations.status().expect("a status");
9673 assert_eq!(status.start_mode(), Some(StartMode::Boot));
9674 assert_eq!(
9675 status.binary().map(BinaryPath::recorded),
9676 Some(host.binary.as_path())
9677 );
9678 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
9679 assert_eq!(status.last_github_contact(), Some(at));
9680 assert!(status.is_installed());
9681 assert!(status.is_healthy(), "{status}");
9682
9683 let printed = status.to_string();
9684 for fragment in [
9685 "start mode",
9686 "diagnostic log",
9687 "last GitHub contact",
9688 "binary",
9689 ] {
9690 assert!(printed.contains(fragment), "{printed}");
9691 }
9692 }
9693
9694 #[cfg(unix)]
9706 #[test]
9707 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
9708 use std::os::unix::fs::PermissionsExt as _;
9709
9710 if unsafe { libc::geteuid() } == 0 {
9714 return;
9715 }
9716
9717 let host = Host::new();
9718 let operations = host.operations();
9719 operations
9720 .install(&host.request(StartMode::Boot))
9721 .expect("an install");
9722 assert!(
9723 operations.status().expect("a status").is_healthy(),
9724 "the discriminator: healthy before the record is made unreadable"
9725 );
9726
9727 let record = InstallRecord::path(&host.paths);
9728 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
9729 .expect("the mode is applied");
9730
9731 let status = operations
9732 .status()
9733 .expect("a record this account may not read is reported, not thrown");
9734 assert!(status.is_installed(), "{status}");
9735 assert!(!status.is_healthy(), "{status}");
9736
9737 let printed = status.to_string();
9738 assert!(
9739 printed.contains("this account may not read it"),
9740 "the operator is told which of the two states this is: {printed}"
9741 );
9742 assert!(
9743 !printed.contains("there is no install record"),
9744 "a record that is there and unreadable is not a record that is missing, and the \
9745 missing one's remedy starts with `service uninstall`: {printed}"
9746 );
9747
9748 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
9751 .expect("the mode is restored");
9752 }
9753
9754 #[test]
9755 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
9756 let host = Host::new();
9757 let operations = host.operations();
9758 operations
9759 .install(&host.request(StartMode::Boot))
9760 .expect("an install");
9761
9762 assert!(
9765 operations.status().expect("a status").is_healthy(),
9766 "the freshly installed host must be healthy"
9767 );
9768
9769 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
9770
9771 let status = operations.status().expect("a status");
9772 assert!(!status.is_healthy(), "{status}");
9773 assert!(
9774 status
9775 .problems()
9776 .iter()
9777 .any(|problem| problem.subject == "binary"),
9778 "{status}"
9779 );
9780 assert!(status.to_string().contains("STALE"), "{status}");
9781 }
9782
9783 #[test]
9784 fn status_reports_a_registration_that_would_not_start_at_boot() {
9785 let host = Host::new();
9786 let operations = host.operations();
9787 operations
9788 .install(&host.request(StartMode::Boot))
9789 .expect("an install");
9790 assert!(operations.status().expect("a status").is_healthy());
9791
9792 host.controls.edit("runner-manager", |registration| {
9793 registration.starts_automatically = false;
9794 });
9795
9796 let status = operations.status().expect("a status");
9797 assert!(!status.is_healthy(), "{status}");
9798 assert!(
9799 status
9800 .problems()
9801 .iter()
9802 .any(|problem| problem.detail.contains("after a reboot")),
9803 "{status}"
9804 );
9805 }
9806
9807 #[test]
9808 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
9809 let host = Host::new();
9810 let operations = host.operations();
9811 operations
9812 .install(&host.request(StartMode::Login))
9813 .expect("install and immediate start");
9814
9815 assert!(
9816 operations.status().expect("running status").is_running(),
9817 "install must not wait for the next login trigger"
9818 );
9819 operations.stop().expect("stop the registration");
9820 let status = operations.status().expect("stopped status");
9821 assert!(!status.is_healthy(), "{status}");
9822 assert!(
9823 status
9824 .problems()
9825 .iter()
9826 .any(|problem| problem.subject == "runtime"),
9827 "{status}"
9828 );
9829 let rendered = status.to_string();
9830 assert!(
9831 rendered.contains("runner-manager service install --start-at login"),
9832 "{rendered}"
9833 );
9834 assert!(
9835 !rendered.contains("runner-manager service start"),
9836 "{rendered}"
9837 );
9838 }
9839
9840 #[test]
9841 fn status_reports_a_restart_policy_something_else_edited() {
9842 let host = Host::new();
9843 let operations = host.operations();
9844 operations
9845 .install(&host.request(StartMode::Boot))
9846 .expect("an install");
9847 assert!(operations.status().expect("a status").is_healthy());
9848
9849 host.controls.edit("runner-manager", |registration| {
9850 registration.restart_delay = Some(Duration::from_secs(1));
9851 });
9852
9853 let status = operations.status().expect("a status");
9854 assert!(!status.is_healthy(), "{status}");
9855 assert!(
9856 status
9857 .problems()
9858 .iter()
9859 .any(|problem| problem.subject == "restart policy"),
9860 "{status}"
9861 );
9862 }
9863
9864 #[test]
9865 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
9866 let host = Host::new();
9867 let operations = host.operations();
9868 operations
9869 .install(&host.request(StartMode::Boot))
9870 .expect("an install");
9871 let other = host.binary.with_file_name("someone-elses.exe");
9872 std::fs::write(&other, b"x").expect("writable");
9873
9874 host.controls.edit("runner-manager", |registration| {
9875 registration.command_line = quote_argument(&other.to_string_lossy());
9876 });
9877
9878 let status = operations.status().expect("a status");
9879 assert!(!status.is_healthy(), "{status}");
9880 assert!(
9881 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
9882 "{status}"
9883 );
9884 }
9885
9886 #[test]
9887 fn status_reports_a_record_no_service_manager_knows_about() {
9888 let host = Host::new();
9889 let operations = host.operations();
9890 operations
9891 .install(&host.request(StartMode::Boot))
9892 .expect("an install");
9893 for mode in [StartMode::Boot, StartMode::Login] {
9895 host.controls
9896 .control(mode)
9897 .expect("a control")
9898 .uninstall(&ServiceIdentity::product())
9899 .expect("removed");
9900 }
9901
9902 let status = operations.status().expect("a status");
9903 assert!(!status.is_healthy(), "{status}");
9904 assert!(
9905 status
9906 .problems()
9907 .iter()
9908 .any(|problem| problem.subject == "registration"),
9909 "{status}"
9910 );
9911 }
9912
9913 #[test]
9914 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
9915 let host = Host::new();
9916 let status = host.operations().status().expect("a status");
9917 assert!(!status.is_installed());
9918 assert!(
9919 status.is_healthy(),
9920 "a host that never installed the service has no fault to report: {status}"
9921 );
9922 assert!(status.to_string().contains("installed"), "{status}");
9923 }
9924
9925 #[test]
9926 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
9927 let host = Host::new();
9928 let operations = host.operations();
9929 operations
9930 .install(&host.request(StartMode::Login))
9931 .expect("an install at login");
9932 let status = operations.status().expect("a status");
9933 assert!(
9934 status
9935 .notes()
9936 .iter()
9937 .any(|note| note.contains("does not run until the operator signs in")),
9938 "05-infrastructure.md requires `service status` to say so: {status}"
9939 );
9940 }
9941
9942 #[test]
9947 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
9948 let host = Host::new();
9949 let operations = host.operations();
9950 operations
9951 .install(&host.request(StartMode::Login))
9952 .expect("an install at login");
9953 operations.start().expect("a start");
9954 assert!(operations.status().expect("a status").is_running());
9955 assert!(operations.stop().expect("a stop"));
9956 assert!(!operations.status().expect("a status").is_running());
9957 }
9958
9959 #[test]
9960 fn starting_a_host_with_no_registration_is_refused() {
9961 let host = Host::new();
9962 let error = host.operations().start().expect_err("nothing to start");
9963 assert!(
9964 matches!(error, ServiceError::NotInstalled { .. }),
9965 "{error}"
9966 );
9967 }
9968
9969 #[cfg(windows)]
9985 #[test]
9986 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
9987 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
9988
9989 let root = tempfile::tempdir().expect("a temporary directory");
9990 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
9991 .expect("a rooted machine-scoped store");
9992 store
9993 .store(&secrecy::SecretString::from("a stand-in for the token"))
9994 .expect("the store accepts a value");
9995 let protection = store.protection().expect("the DACL can be read back");
9996
9997 assert!(
9998 protection.description().contains(";;;SY)"),
9999 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
10000 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
10001 protection.description()
10002 );
10003 assert_eq!(
10004 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
10005 ServiceAccount::LocalSystem,
10006 "and that is the account this installer registers, which is why SY is what matters"
10007 );
10008 assert!(
10009 !protection.readable_by_other_local_users(),
10010 "the same DACL must still exclude ordinary local users: {}",
10011 protection.description()
10012 );
10013
10014 for rejected in [";;;LS)", ";;;NS)"] {
10018 assert!(
10019 !protection.description().contains(rejected),
10020 "if the store ever admitted {rejected}, the least-privilege analysis in \
10021 docs/service-account.md would need redoing: {}",
10022 protection.description()
10023 );
10024 }
10025 }
10026}