1use std::collections::{BTreeMap, BTreeSet};
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106 Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109pub const SERVICE_NAME: &str = "runner-manager";
115pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126pub const DESCRIPTION: &str =
128 "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137pub const RECORD_FILE: &str = "service.toml";
145
146pub const CONTACT_FILE: &str = "github-contact.toml";
152
153pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168pub const LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
170
171pub const DAEMON_STALLED_SUBJECT: &str = "daemon progress";
173
174pub const HOST_UNFIT_SUBJECT: &str = "host fitness";
177
178pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
182
183pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
194
195#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ServiceIdentity {
209 name: String,
210 display_name: String,
211 description: String,
212}
213
214impl ServiceIdentity {
215 #[must_use]
217 pub fn product() -> Self {
218 Self {
219 name: SERVICE_NAME.to_string(),
220 display_name: DISPLAY_NAME.to_string(),
221 description: DESCRIPTION.to_string(),
222 }
223 }
224
225 #[must_use]
232 pub fn fixture(tag: &str) -> Self {
233 let tag: String = tag
234 .chars()
235 .map(|c| {
236 if c.is_ascii_alphanumeric() {
237 c.to_ascii_lowercase()
238 } else {
239 '-'
240 }
241 })
242 .collect();
243 let name = format!("{SERVICE_NAME}-selftest-{tag}");
244 Self {
245 display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
246 description: "Disposable fixture created by runner-manager's own installer tests. \
247 Safe to remove."
248 .to_string(),
249 name,
250 }
251 }
252
253 #[must_use]
258 pub fn is_fixture(&self) -> bool {
259 self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
260 }
261
262 #[must_use]
265 pub fn name(&self) -> &str {
266 &self.name
267 }
268
269 #[must_use]
271 pub fn display_name(&self) -> &str {
272 &self.display_name
273 }
274
275 #[must_use]
277 pub fn description(&self) -> &str {
278 &self.description
279 }
280
281 #[must_use]
287 pub fn launchd_label(&self) -> String {
288 format!(
289 "{}.{}.{}",
290 crate::paths::QUALIFIER,
291 crate::paths::ORGANIZATION,
292 self.name
293 )
294 }
295
296 #[must_use]
298 pub fn systemd_unit(&self) -> String {
299 format!("{}.service", self.name)
300 }
301
302 #[must_use]
310 pub fn restart_command(&self, mode: StartMode) -> String {
311 let name = &self.name;
312 if cfg!(windows) {
313 match mode {
314 StartMode::Boot => {
315 format!("Restart-Service -Name {name}")
316 }
317 StartMode::Login => format!("schtasks /End /TN {name}; schtasks /Run /TN {name}"),
318 }
319 } else if cfg!(target_os = "macos") {
320 let label = self.launchd_label();
321 match mode {
322 StartMode::Boot => format!("sudo launchctl kickstart -k system/{label}"),
323 StartMode::Login => format!("launchctl kickstart -k gui/$(id -u)/{label}"),
324 }
325 } else {
326 let unit = self.systemd_unit();
327 match mode {
328 StartMode::Boot => format!("sudo systemctl restart {unit}"),
329 StartMode::Login => format!("systemctl --user restart {unit}"),
330 }
331 }
332 }
333}
334
335impl fmt::Display for ServiceIdentity {
336 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337 f.write_str(&self.name)
338 }
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub struct RestartPolicy {
356 delay: Duration,
357 reset_after: Duration,
358}
359
360impl RestartPolicy {
361 pub const MIN_DELAY: Duration = Duration::from_secs(1);
368
369 pub const MAX_DELAY: Duration = Duration::from_secs(300);
372
373 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
375
376 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
378
379 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
387 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
388 return Err(ServiceError::RestartDelay {
389 requested_secs: delay.as_secs(),
390 min_secs: Self::MIN_DELAY.as_secs(),
391 max_secs: Self::MAX_DELAY.as_secs(),
392 });
393 }
394 if reset_after <= delay {
395 return Err(ServiceError::RestartResetWindow {
396 reset_secs: reset_after.as_secs(),
397 delay_secs: delay.as_secs(),
398 });
399 }
400 Ok(Self { delay, reset_after })
401 }
402
403 #[must_use]
405 pub const fn delay(&self) -> Duration {
406 self.delay
407 }
408
409 #[must_use]
411 pub const fn reset_after(&self) -> Duration {
412 self.reset_after
413 }
414
415 #[must_use]
432 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
433 match kind {
434 DefinitionKind::WindowsScheduledTask => {
435 let seconds = self.delay.as_secs();
436 let minutes = seconds.div_ceil(60);
437 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
438 }
439 _ => self.delay,
440 }
441 }
442}
443
444impl Default for RestartPolicy {
445 fn default() -> Self {
446 Self {
447 delay: Self::DEFAULT_DELAY,
448 reset_after: Self::DEFAULT_RESET_AFTER,
449 }
450 }
451}
452
453impl fmt::Display for RestartPolicy {
454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455 write!(
456 f,
457 "restart after {}s, failure count resets after {}s",
458 self.delay.as_secs(),
459 self.reset_after.as_secs()
460 )
461 }
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum ServiceAccount {
477 LocalSystem,
480 Root,
483 InvokingUser,
485}
486
487impl ServiceAccount {
488 #[must_use]
497 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
498 match (kind, mode) {
499 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
500 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
501 (_, StartMode::Boot) => Self::Root,
502 }
503 }
504
505 #[must_use]
508 pub const fn for_start_mode(mode: StartMode) -> Self {
509 Self::for_definition(host_definition_kind(mode), mode)
510 }
511
512 #[must_use]
514 pub const fn as_str(&self) -> &'static str {
515 match self {
516 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
517 Self::Root => "root",
518 Self::InvokingUser => "the invoking user",
519 }
520 }
521
522 #[must_use]
529 pub const fn justification(&self) -> &'static str {
530 match self {
531 Self::LocalSystem => {
532 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
533 NetworkService cannot read it, and widening the DACL to reach them would grant \
534 every service on this host read access to the one credential this product holds"
535 }
536 Self::Root => {
537 "a boot-time registration runs outside every login session: on macOS the System \
538 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
539 machine-scoped store is a 0600 file under /var/lib that only root can open \
540 before a session exists"
541 }
542 Self::InvokingUser => {
543 "a login-mode registration reads the user-scoped store, which is deliberately \
544 readable by exactly one account and needs no elevation at all"
545 }
546 }
547 }
548
549 #[must_use]
551 pub const fn needs_elevation(&self) -> bool {
552 matches!(self, Self::LocalSystem | Self::Root)
553 }
554}
555
556impl fmt::Display for ServiceAccount {
557 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558 f.write_str(self.as_str())
559 }
560}
561
562#[derive(Debug, thiserror::Error)]
573pub enum ServiceError {
574 #[error("cannot install the service while an agent is already running on this host: {source}")]
580 LockHeld {
581 #[source]
583 source: Box<LockError>,
584 },
585
586 #[error(
589 "cannot tell whether an agent is already running on this host, so the service was not \
590 installed: {source}. Fix the reported problem with the state directory and try again."
591 )]
592 LockUnreadable {
593 #[source]
595 source: Box<LockError>,
596 },
597
598 #[error(
606 "this registration would run jobs under the default runner root, and that root could \
607 not be prepared, so nothing was registered: {source}"
608 )]
609 RunnerRoot {
610 #[source]
612 source: Box<RootAccessError>,
613 },
614
615 #[error(
617 "cannot resolve the absolute path of this executable, so there is nothing to register: \
618 {detail}. Run the installer from the installed binary rather than through a shell \
619 function or a wrapper that replaces argv[0]."
620 )]
621 BinaryPath {
622 detail: String,
624 },
625
626 #[error(
628 "{} is not a file, so registering it would create a service that cannot start. Install \
629 the product first, then run `service install` from the installed binary.",
630 path.display()
631 )]
632 BinaryMissing {
633 path: PathBuf,
635 },
636
637 #[error(
639 "a restart delay of {requested_secs}s is outside the supported range \
640 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
641 value, so `service status` would report a delay that is not the one in force."
642 )]
643 RestartDelay {
644 requested_secs: u64,
646 min_secs: u64,
648 max_secs: u64,
650 },
651
652 #[error(
654 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
655 restart delay, so the count would reset between every pair of restarts and no \
656 start limit could ever apply."
657 )]
658 RestartResetWindow {
659 reset_secs: u64,
661 delay_secs: u64,
663 },
664
665 #[error(
671 "{name} is already registered to start at {existing}, and this asks for {requested}. \
672 Changing the start mode moves the registration between two different service \
673 managers, so it is not something an install does by itself: run `service uninstall` \
674 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
675 switches it in place and keeps the registration running throughout."
676 )]
677 AlreadyInstalled {
678 name: String,
680 existing: StartMode,
682 requested: StartMode,
684 },
685
686 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
688 NotInstalled {
689 name: String,
691 operation: &'static str,
693 },
694
695 #[error("cannot {operation} the service record {}: {detail}", path.display())]
697 Record {
698 operation: &'static str,
700 path: PathBuf,
702 detail: String,
704 },
705
706 #[error(
708 "the service record {} was not written by this product, or was written by a version \
709 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
710 neither touches configuration, secrets, or the cache.",
711 path.display()
712 )]
713 RecordUnreadable {
714 path: PathBuf,
716 detail: String,
718 },
719
720 #[error(
734 "the service record {} is there and this account may not read it: {detail}. It was \
735 written by whichever account installed the service -- on a boot-mode host, `sudo \
736 service install`. Running `service install` again rewrites it readable; until then \
737 only what the service manager itself reports is available.",
738 path.display()
739 )]
740 RecordNotPermitted {
741 path: PathBuf,
743 detail: String,
745 },
746
747 #[error("cannot prepare this host's application-data directories: {source}")]
749 Paths {
750 #[source]
752 source: Box<crate::paths::PathsError>,
753 },
754
755 #[error("cannot {operation} {name} through {manager}: {detail}")]
757 Control {
758 operation: &'static str,
760 name: String,
762 manager: &'static str,
764 detail: String,
766 },
767
768 #[error(
770 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
771 Inspect `service status` before retrying."
772 )]
773 Rollback {
774 operation: &'static str,
776 name: String,
778 cause: String,
780 rollback: String,
782 },
783
784 #[error(
788 "this machine's {name} service was installed from {installed_by}, not from this \
789 account's data root, so this account does not {operation} it: doing so would \
790 re-register or remove that installation's service with this account's directories. \
791 Do it {act_as}. If that account no longer exists, remove the registration with the \
792 service manager itself first."
793 )]
794 ForeignInstallation {
795 name: String,
797 operation: &'static str,
799 installed_by: String,
801 act_as: String,
803 },
804
805 #[error("this account's local configuration is in use, so nothing was archived: {detail}.")]
808 LocalConfigurationInUse {
809 detail: String,
811 },
812
813 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
815 NeedsElevation {
816 operation: &'static str,
818 name: String,
820 detail: String,
822 remedy: &'static str,
824 },
825}
826
827#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
839pub struct ServiceDirectories {
840 pub config: PathBuf,
842 pub state: PathBuf,
844 pub runtime: PathBuf,
846 pub logs: PathBuf,
848}
849
850impl ServiceDirectories {
851 #[must_use]
853 pub fn of(paths: &AppPaths) -> Self {
854 Self {
855 config: paths.config_dir().to_path_buf(),
856 state: paths.state_dir().to_path_buf(),
857 runtime: paths.runtime_dir().to_path_buf(),
858 logs: paths.logs_dir().to_path_buf(),
859 }
860 }
861
862 #[must_use]
864 pub fn all(&self) -> [&Path; 4] {
865 [
866 self.config.as_path(),
867 self.state.as_path(),
868 self.runtime.as_path(),
869 self.logs.as_path(),
870 ]
871 }
872
873 #[must_use]
876 pub fn log_file(&self) -> PathBuf {
877 self.logs.join(LOG_FILE_STEM)
878 }
879
880 #[must_use]
883 pub fn arguments(&self) -> [OsString; 8] {
884 let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS.map(OsString::from);
885 [
886 config,
887 self.config.clone().into_os_string(),
888 state,
889 self.state.clone().into_os_string(),
890 runtime,
891 self.runtime.clone().into_os_string(),
892 logs,
893 self.logs.clone().into_os_string(),
894 ]
895 }
896
897 #[must_use]
904 pub fn from_command_line(command_line: &str) -> Option<Self> {
905 let arguments = split_command_line(command_line);
906 let value = |option: &str| {
907 let at = arguments.iter().position(|argument| argument == option)?;
908 arguments.get(at + 1).map(PathBuf::from)
909 };
910 let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS;
911 Some(Self {
912 config: value(config)?,
913 state: value(state)?,
914 runtime: value(runtime)?,
915 logs: value(logs)?,
916 })
917 }
918
919 #[must_use]
924 pub fn root(&self) -> PathBuf {
925 if *self == Self::of(&AppPaths::system()) {
926 return PathBuf::from(crate::paths::SYSTEM_DATA_ROOT);
927 }
928 let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
929 for other in [&self.state, &self.runtime, &self.logs] {
930 let common = shared
931 .iter()
932 .zip(other.components())
933 .take_while(|(left, right)| **left == *right)
934 .count();
935 shared.truncate(common);
936 }
937 if shared.is_empty() {
938 self.config.clone()
939 } else {
940 shared.iter().collect()
941 }
942 }
943
944 #[must_use]
947 pub fn paths(&self) -> AppPaths {
948 AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
949 }
950}
951
952pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
958 "--service-config-dir",
959 "--service-state-dir",
960 "--service-runtime-dir",
961 "--service-logs-dir",
962];
963
964#[derive(Debug, Clone, PartialEq, Eq)]
976pub struct InstallRequest {
977 start_mode: StartMode,
978 binary: Option<PathBuf>,
979 source_binary: Option<PathBuf>,
980 arguments: Vec<OsString>,
981 restart: RestartPolicy,
982 on_demand: bool,
983}
984
985impl InstallRequest {
986 #[must_use]
988 pub fn new(start_mode: StartMode) -> Self {
989 Self {
990 start_mode,
991 binary: None,
992 source_binary: None,
993 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
994 restart: RestartPolicy::default(),
995 on_demand: false,
996 }
997 }
998
999 #[must_use]
1009 pub const fn started_on_demand(mut self) -> Self {
1010 self.on_demand = true;
1011 self
1012 }
1013
1014 #[must_use]
1021 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1022 self.binary = Some(binary.into());
1023 self
1024 }
1025
1026 #[must_use]
1043 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1044 self.source_binary = Some(source.into());
1045 self
1046 }
1047
1048 #[must_use]
1050 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1051 where
1052 I: IntoIterator<Item = S>,
1053 S: Into<OsString>,
1054 {
1055 self.arguments = arguments.into_iter().map(Into::into).collect();
1056 self
1057 }
1058
1059 #[must_use]
1062 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1063 self.restart = restart;
1064 self
1065 }
1066
1067 #[must_use]
1069 pub const fn start_mode(&self) -> StartMode {
1070 self.start_mode
1071 }
1072}
1073
1074#[derive(Debug, Clone, PartialEq, Eq)]
1081pub struct InstallPlan {
1082 identity: ServiceIdentity,
1083 start_mode: StartMode,
1084 binary: PathBuf,
1085 source_binary: Option<PathBuf>,
1086 arguments: Vec<OsString>,
1087 account: ServiceAccount,
1088 restart: RestartPolicy,
1089 directories: ServiceDirectories,
1090 secret_guard: Option<PathBuf>,
1091 on_demand: bool,
1092}
1093
1094impl InstallPlan {
1095 pub fn resolve(
1103 identity: ServiceIdentity,
1104 request: &InstallRequest,
1105 directories: ServiceDirectories,
1106 ) -> Result<Self, ServiceError> {
1107 let binary = match &request.binary {
1108 Some(named) => absolute(named)?,
1109 None => running_executable()?,
1110 };
1111 if !binary.is_file() {
1112 return Err(ServiceError::BinaryMissing { path: binary });
1113 }
1114 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1119 .ok()
1120 .map(|store| store.guard());
1121 Ok(Self {
1122 identity,
1123 start_mode: request.start_mode,
1124 binary,
1125 source_binary: request.source_binary.clone(),
1126 arguments: request.arguments.clone(),
1127 account: ServiceAccount::for_start_mode(request.start_mode),
1128 restart: request.restart,
1129 directories,
1130 secret_guard,
1131 on_demand: request.on_demand,
1132 })
1133 }
1134
1135 #[must_use]
1142 pub fn unchecked(
1143 identity: ServiceIdentity,
1144 start_mode: StartMode,
1145 binary: impl Into<PathBuf>,
1146 directories: ServiceDirectories,
1147 ) -> Self {
1148 Self {
1149 identity,
1150 start_mode,
1151 binary: binary.into(),
1152 source_binary: None,
1153 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1154 account: ServiceAccount::for_start_mode(start_mode),
1155 restart: RestartPolicy::default(),
1156 directories,
1157 secret_guard: None,
1158 on_demand: false,
1159 }
1160 }
1161
1162 #[must_use]
1165 pub const fn started_on_demand(mut self) -> Self {
1166 self.on_demand = true;
1167 self
1168 }
1169
1170 #[must_use]
1172 pub const fn is_on_demand(&self) -> bool {
1173 self.on_demand
1174 }
1175
1176 #[must_use]
1183 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1184 self.secret_guard = Some(guard.into());
1185 self
1186 }
1187
1188 #[must_use]
1190 pub fn secret_guard(&self) -> Option<&Path> {
1191 self.secret_guard.as_deref()
1192 }
1193
1194 #[must_use]
1196 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1197 self.restart = restart;
1198 self
1199 }
1200
1201 #[must_use]
1203 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1204 where
1205 I: IntoIterator<Item = S>,
1206 S: Into<OsString>,
1207 {
1208 self.arguments = arguments.into_iter().map(Into::into).collect();
1209 self
1210 }
1211
1212 #[must_use]
1214 pub const fn identity(&self) -> &ServiceIdentity {
1215 &self.identity
1216 }
1217
1218 #[must_use]
1220 pub const fn start_mode(&self) -> StartMode {
1221 self.start_mode
1222 }
1223
1224 #[must_use]
1226 pub fn binary(&self) -> &Path {
1227 &self.binary
1228 }
1229
1230 #[must_use]
1236 pub fn source_binary(&self) -> Option<&Path> {
1237 self.source_binary.as_deref()
1238 }
1239
1240 #[must_use]
1242 pub fn arguments(&self) -> &[OsString] {
1243 &self.arguments
1244 }
1245
1246 #[must_use]
1248 pub const fn account(&self) -> &ServiceAccount {
1249 &self.account
1250 }
1251
1252 #[must_use]
1254 pub const fn restart(&self) -> RestartPolicy {
1255 self.restart
1256 }
1257
1258 #[must_use]
1260 pub const fn directories(&self) -> &ServiceDirectories {
1261 &self.directories
1262 }
1263
1264 #[must_use]
1266 pub fn command_line(&self) -> String {
1267 let mut out = quote_argument(&self.binary.to_string_lossy());
1268 for argument in &self.arguments {
1269 out.push(' ');
1270 out.push_str("e_argument(&argument.to_string_lossy()));
1271 }
1272 out
1273 }
1274}
1275
1276fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1284 let reversal = change.revert();
1285 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1286}
1287
1288fn rolled_back<T>(
1301 retained: Option<String>,
1302 rollback: Result<T, ServiceError>,
1303 operation: &'static str,
1304 identity: &ServiceIdentity,
1305 cause: ServiceError,
1306) -> ServiceError {
1307 let left_behind = match (rollback.err(), retained) {
1308 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1309 (Some(rollback), None) => Some(rollback.to_string()),
1310 (None, retained) => retained,
1311 };
1312 match left_behind {
1313 Some(rollback) => ServiceError::Rollback {
1314 operation,
1315 name: identity.name().to_string(),
1316 cause: cause.to_string(),
1317 rollback,
1318 },
1319 None => cause,
1320 }
1321}
1322
1323fn undo_runner_root(
1326 change: &RootAccessChange,
1327 operation: &'static str,
1328 identity: &ServiceIdentity,
1329 cause: ServiceError,
1330) -> ServiceError {
1331 rolled_back(
1332 retained_runner_root(change),
1333 Ok(()),
1334 operation,
1335 identity,
1336 cause,
1337 )
1338}
1339
1340fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1354 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1355 detail: format!("{} could not be made absolute: {error}", path.display()),
1356 })
1357}
1358
1359fn running_executable() -> Result<PathBuf, ServiceError> {
1361 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1362 detail: error.to_string(),
1363 })?;
1364 absolute(&raw)
1365}
1366
1367pub(crate) fn quote_argument(argument: &str) -> String {
1376 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1377 return argument.to_string();
1378 }
1379 let mut out = String::with_capacity(argument.len() + 2);
1380 out.push('"');
1381 let mut backslashes = 0usize;
1382 for c in argument.chars() {
1383 match c {
1384 '\\' => {
1385 backslashes += 1;
1386 out.push('\\');
1387 }
1388 '"' => {
1389 for _ in 0..=backslashes {
1392 out.push('\\');
1393 }
1394 out.push('"');
1395 backslashes = 0;
1396 }
1397 other => {
1398 backslashes = 0;
1399 out.push(other);
1400 }
1401 }
1402 }
1403 for _ in 0..backslashes {
1405 out.push('\\');
1406 }
1407 out.push('"');
1408 out
1409}
1410
1411#[must_use]
1422pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1423 split_command_line(command_line)
1424 .into_iter()
1425 .next()
1426 .filter(|executable| !executable.is_empty())
1427 .map(PathBuf::from)
1428}
1429
1430#[must_use]
1436pub fn split_command_line(command_line: &str) -> Vec<String> {
1437 let mut arguments = Vec::new();
1438 let mut current = String::new();
1439 let mut started = false;
1440 let mut quoted = false;
1441 let mut backslashes = 0usize;
1442 for c in command_line.chars() {
1443 match c {
1444 '\\' => {
1445 backslashes += 1;
1446 started = true;
1447 }
1448 '"' => {
1449 current.extend(std::iter::repeat_n('\\', backslashes / 2));
1450 if backslashes.is_multiple_of(2) {
1451 quoted = !quoted;
1452 } else {
1453 current.push('"');
1454 }
1455 backslashes = 0;
1456 started = true;
1457 }
1458 ' ' | '\t' if !quoted => {
1459 current.extend(std::iter::repeat_n('\\', backslashes));
1460 backslashes = 0;
1461 if started {
1462 arguments.push(std::mem::take(&mut current));
1463 started = false;
1464 }
1465 }
1466 other => {
1467 current.extend(std::iter::repeat_n('\\', backslashes));
1468 backslashes = 0;
1469 current.push(other);
1470 started = true;
1471 }
1472 }
1473 }
1474 current.extend(std::iter::repeat_n('\\', backslashes));
1475 if started {
1476 arguments.push(current);
1477 }
1478 arguments
1479}
1480
1481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1487pub enum DefinitionKind {
1488 WindowsService,
1492 WindowsScheduledTask,
1494 LaunchdPlist,
1496 SystemdUnit,
1498}
1499
1500impl DefinitionKind {
1501 #[must_use]
1503 pub const fn manager(self) -> &'static str {
1504 match self {
1505 Self::WindowsService => "the Windows Service Control Manager",
1506 Self::WindowsScheduledTask => "Windows Task Scheduler",
1507 Self::LaunchdPlist => "launchd",
1508 Self::SystemdUnit => "systemd",
1509 }
1510 }
1511}
1512
1513impl fmt::Display for DefinitionKind {
1514 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1515 f.write_str(self.manager())
1516 }
1517}
1518
1519#[derive(Debug, Clone, PartialEq, Eq)]
1526pub struct ServiceDefinition {
1527 kind: DefinitionKind,
1528 text: String,
1529 install_path: Option<PathBuf>,
1530}
1531
1532impl ServiceDefinition {
1533 #[must_use]
1535 pub const fn kind(&self) -> DefinitionKind {
1536 self.kind
1537 }
1538
1539 #[must_use]
1541 pub fn text(&self) -> &str {
1542 &self.text
1543 }
1544
1545 #[must_use]
1552 pub fn install_path(&self) -> Option<&Path> {
1553 self.install_path.as_deref()
1554 }
1555
1556 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1569 Ok(match host_definition_kind(plan.start_mode()) {
1570 DefinitionKind::WindowsService => Self::windows_service(plan),
1571 DefinitionKind::WindowsScheduledTask => {
1572 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1573 }
1574 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1575 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1576 })
1577 }
1578
1579 #[must_use]
1581 pub fn windows_service(plan: &InstallPlan) -> Self {
1582 Self {
1583 kind: DefinitionKind::WindowsService,
1584 text: windows_service_descriptor(plan),
1585 install_path: None,
1586 }
1587 }
1588
1589 #[must_use]
1591 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1592 Self {
1593 kind: DefinitionKind::WindowsScheduledTask,
1594 text: windows_scheduled_task_xml(plan, principal),
1595 install_path: None,
1596 }
1597 }
1598
1599 #[must_use]
1604 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1605 let file = format!("{}.plist", plan.identity().launchd_label());
1606 let install_path = match plan.start_mode() {
1607 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1608 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1609 };
1610 Self {
1611 kind: DefinitionKind::LaunchdPlist,
1612 text: launchd_plist(plan),
1613 install_path,
1614 }
1615 }
1616
1617 #[must_use]
1625 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1626 Self {
1627 kind,
1628 text: text.into(),
1629 install_path: None,
1630 }
1631 }
1632
1633 #[must_use]
1638 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1639 let file = plan.identity().systemd_unit();
1640 let install_path = match plan.start_mode() {
1641 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1642 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1643 };
1644 Self {
1645 kind: DefinitionKind::SystemdUnit,
1646 text: systemd_unit(plan),
1647 install_path,
1648 }
1649 }
1650}
1651
1652pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1654pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1656pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1658pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1660
1661const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1664
1665pub const START_LIMIT_BURST: u32 = 5;
1673
1674#[must_use]
1695pub fn systemd_unit(plan: &InstallPlan) -> String {
1696 let identity = plan.identity();
1697 let restart = plan.restart();
1698 let directories = plan.directories();
1699
1700 let mut out = String::new();
1701 out.push_str("[Unit]\n");
1702 out.push_str(&format!("Description={}\n", identity.display_name()));
1703 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1704 out.push_str("After=network-online.target\n");
1705 out.push_str("Wants=network-online.target\n");
1706 out.push_str(&format!(
1709 "StartLimitIntervalSec={}\n",
1710 restart.reset_after().as_secs()
1711 ));
1712 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1713
1714 out.push_str("\n[Service]\n");
1715 out.push_str("Type=simple\n");
1716 out.push_str("KillMode=process\n");
1720 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1721 out.push_str(&format!(
1722 "WorkingDirectory={}\n",
1723 directories.state.display()
1724 ));
1725 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1726 out.push_str("Restart=on-failure\n");
1727 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1728
1729 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1730 for directive in systemd_hardening(plan.start_mode()) {
1731 out.push_str(directive);
1732 out.push('\n');
1733 }
1734 let quoted = |paths: &[String]| {
1735 paths
1736 .iter()
1737 .map(|path| quote_argument(path))
1738 .collect::<Vec<_>>()
1739 .join(" ")
1740 };
1741 out.push_str(&format!(
1742 "ReadWritePaths={}\n",
1743 quoted(&permitted_paths(DefinitionKind::SystemdUnit, plan))
1744 ));
1745
1746 out.push_str("\n[Install]\n");
1747 out.push_str(match plan.start_mode() {
1748 StartMode::Boot => "WantedBy=multi-user.target\n",
1749 StartMode::Login => "WantedBy=default.target\n",
1750 });
1751 out
1752}
1753
1754const SYSTEMD_HARDENING: [&str; 12] = [
1779 "NoNewPrivileges=yes",
1780 "CapabilityBoundingSet=",
1781 "AmbientCapabilities=",
1782 "PrivateTmp=yes",
1783 "PrivateDevices=yes",
1784 "ProtectSystem=strict",
1785 "ProtectKernelTunables=yes",
1786 "ProtectKernelModules=yes",
1787 "ProtectControlGroups=yes",
1788 "RestrictNamespaces=yes",
1789 "RestrictRealtime=yes",
1790 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1791];
1792
1793fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1801 let mut directives = SYSTEMD_HARDENING.to_vec();
1802 if mode == StartMode::Boot {
1803 directives.push("ProtectHome=tmpfs");
1804 }
1805 directives
1806}
1807
1808#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1811const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1812
1813#[cfg(any(all(test, unix), target_os = "linux"))]
1816fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1817 let occupied = match std::fs::read_dir(path) {
1818 Ok(mut entries) => entries.next().is_some(),
1819 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1820 Err(error) => return Err(error),
1821 };
1822 if occupied {
1823 let name = path.file_name().map_or_else(
1824 || std::borrow::Cow::Borrowed("directory"),
1825 |name| name.to_string_lossy(),
1826 );
1827 std::fs::rename(
1828 path,
1829 path.with_file_name(format!("{name}.before-move-{stamp}")),
1830 )?;
1831 }
1832 Ok(())
1833}
1834
1835#[cfg(any(all(test, unix), target_os = "linux"))]
1839fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
1840 use std::os::unix::fs::DirBuilderExt as _;
1841
1842 std::fs::DirBuilder::new()
1843 .recursive(true)
1844 .mode(0o700)
1845 .create(to)?;
1846 for entry in std::fs::read_dir(from)? {
1847 let entry = entry?;
1848 if skip.is_some_and(|skip| entry.file_name() == skip) {
1849 continue;
1850 }
1851 let source = entry.path();
1852 let target = to.join(entry.file_name());
1853 let kind = entry.file_type()?;
1854 if kind.is_symlink() {
1855 std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
1856 } else if kind.is_dir() {
1857 copy_tree(&source, &target, None)?;
1858 } else {
1859 std::fs::copy(&source, &target)?;
1860 }
1861 }
1862 Ok(())
1863}
1864
1865#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1872fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
1873 if let Some(root) = SYSTEMD_HOME_ROOTS
1874 .iter()
1875 .find(|root| path.starts_with(root))
1876 {
1877 return Some(format!(
1878 "{} is under {root}, which a boot service cannot see",
1879 path.display()
1880 ));
1881 }
1882 match owner {
1883 Some(0) => None,
1884 Some(uid) => Some(format!(
1885 "{} belongs to uid {uid}, and a boot service runs as root with no capability \
1886 to open another account's directory",
1887 path.display()
1888 )),
1889 None => Some(format!("{} cannot be inspected", path.display())),
1890 }
1891}
1892
1893pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1917
1918#[must_use]
1925pub fn launchd_plist(plan: &InstallPlan) -> String {
1926 let identity = plan.identity();
1927 let directories = plan.directories();
1928 let mut out = String::new();
1929 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1930 out.push_str(
1931 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1932 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1933 );
1934 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1935 out.push_str(&plist_string("Label", &identity.launchd_label()));
1936
1937 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1938 out.push_str(&format!(
1939 " <string>{}</string>\n",
1940 xml_escape(&plan.binary().to_string_lossy())
1941 ));
1942 for argument in plan.arguments() {
1943 out.push_str(&format!(
1944 " <string>{}</string>\n",
1945 xml_escape(&argument.to_string_lossy())
1946 ));
1947 }
1948 out.push_str(" </array>\n");
1949
1950 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1951 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1952 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1953 out.push_str(" </dict>\n");
1954 out.push_str(&format!(
1955 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1956 plan.restart().delay().as_secs()
1957 ));
1958 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1959 out.push_str(&plist_string(
1960 "WorkingDirectory",
1961 &directories.state.to_string_lossy(),
1962 ));
1963 out.push_str(&plist_string(
1964 "StandardOutPath",
1965 &directories
1966 .logs
1967 .join("runner-manager.launchd.out.log")
1968 .to_string_lossy(),
1969 ));
1970 out.push_str(&plist_string(
1971 "StandardErrorPath",
1972 &directories
1973 .logs
1974 .join("runner-manager.launchd.err.log")
1975 .to_string_lossy(),
1976 ));
1977
1978 match plan.start_mode() {
1979 StartMode::Boot => {
1980 out.push_str(&plist_string(
1983 "UserName",
1984 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1985 .as_str(),
1986 ));
1987 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1989 }
1990 StartMode::Login => {
1991 }
1995 }
1996
1997 out.push_str("</dict>\n</plist>\n");
1998 out
1999}
2000
2001fn plist_string(key: &str, value: &str) -> String {
2003 format!(
2004 " <key>{}</key>\n <string>{}</string>\n",
2005 xml_escape(key),
2006 xml_escape(value)
2007 )
2008}
2009
2010#[derive(Debug, Clone, PartialEq, Eq)]
2018pub struct TaskPrincipal {
2019 user_id: String,
2020}
2021
2022impl TaskPrincipal {
2023 pub fn current() -> Result<Self, ServiceError> {
2031 let user = std::env::var("USERNAME")
2032 .ok()
2033 .filter(|value| !value.trim().is_empty());
2034 let Some(user) = user else {
2035 return Err(ServiceError::Control {
2036 operation: "identify the account for",
2037 name: SERVICE_NAME.to_string(),
2038 manager: "Windows Task Scheduler",
2039 detail: "this session reports no %USERNAME%, so there is no principal to \
2040 register a logon-triggered task for"
2041 .to_string(),
2042 });
2043 };
2044 let domain = std::env::var("USERDOMAIN")
2045 .ok()
2046 .filter(|value| !value.trim().is_empty());
2047 Ok(Self {
2048 user_id: match domain {
2049 Some(domain) => format!("{domain}\\{user}"),
2050 None => user,
2051 },
2052 })
2053 }
2054
2055 #[must_use]
2057 pub fn named(user_id: impl Into<String>) -> Self {
2058 Self {
2059 user_id: user_id.into(),
2060 }
2061 }
2062
2063 #[must_use]
2065 pub fn user_id(&self) -> &str {
2066 &self.user_id
2067 }
2068}
2069
2070#[must_use]
2077pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2078 let identity = plan.identity();
2079 let user = xml_escape(principal.user_id());
2080 let arguments = plan
2081 .arguments()
2082 .iter()
2083 .map(|argument| quote_argument(&argument.to_string_lossy()))
2084 .collect::<Vec<_>>()
2085 .join(" ");
2086 let mut out = String::new();
2087 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2088 out.push_str(
2089 "<Task version=\"1.4\" \
2090 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2091 );
2092 out.push_str(" <RegistrationInfo>\n");
2093 out.push_str(&format!(
2094 " <Description>{}</Description>\n",
2095 xml_escape(identity.description())
2096 ));
2097 out.push_str(&format!(
2098 " <URI>\\{}</URI>\n",
2099 xml_escape(identity.name())
2100 ));
2101 out.push_str(" </RegistrationInfo>\n");
2102
2103 out.push_str(" <Triggers>\n <LogonTrigger>\n");
2104 out.push_str(" <Enabled>true</Enabled>\n");
2105 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2106 out.push_str(" </LogonTrigger>\n </Triggers>\n");
2107
2108 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
2109 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2110 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
2111 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
2112 out.push_str(" </Principal>\n </Principals>\n");
2113
2114 out.push_str(" <Settings>\n");
2115 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2119 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2120 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2121 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
2122 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
2123 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2124 out.push_str(" <IdleSettings>\n");
2125 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2126 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
2127 out.push_str(" </IdleSettings>\n");
2128 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2129 out.push_str(" <Enabled>true</Enabled>\n");
2130 out.push_str(" <Hidden>false</Hidden>\n");
2131 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2132 out.push_str(" <WakeToRun>false</WakeToRun>\n");
2133 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2135 out.push_str(" <Priority>7</Priority>\n");
2136 out.push_str(" <RestartOnFailure>\n");
2137 out.push_str(&format!(
2138 " <Interval>{}</Interval>\n",
2139 iso8601_minutes(
2140 plan.restart()
2141 .effective_delay(DefinitionKind::WindowsScheduledTask)
2142 )
2143 ));
2144 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
2145 out.push_str(" </RestartOnFailure>\n");
2146 out.push_str(" </Settings>\n");
2147
2148 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
2149 out.push_str(&format!(
2150 " <Command>{}</Command>\n",
2151 xml_escape(&plan.binary().to_string_lossy())
2152 ));
2153 if !arguments.is_empty() {
2154 out.push_str(&format!(
2155 " <Arguments>{}</Arguments>\n",
2156 xml_escape(&arguments)
2157 ));
2158 }
2159 out.push_str(&format!(
2160 " <WorkingDirectory>{}</WorkingDirectory>\n",
2161 xml_escape(&plan.directories().state.to_string_lossy())
2162 ));
2163 out.push_str(" </Exec>\n </Actions>\n");
2164 out.push_str("</Task>\n");
2165 out
2166}
2167
2168fn iso8601_minutes(duration: Duration) -> String {
2171 format!("PT{}M", duration.as_secs() / 60)
2172}
2173
2174pub(crate) fn xml_escape(value: &str) -> String {
2182 let mut out = String::with_capacity(value.len());
2183 for c in value.chars() {
2184 match c {
2185 '&' => out.push_str("&"),
2186 '<' => out.push_str("<"),
2187 '>' => out.push_str(">"),
2188 '"' => out.push_str("""),
2189 '\'' => out.push_str("'"),
2190 other => out.push(other),
2191 }
2192 }
2193 out
2194}
2195
2196fn xml_unescape(value: &str) -> String {
2202 value
2203 .replace("<", "<")
2204 .replace(">", ">")
2205 .replace(""", "\"")
2206 .replace("'", "'")
2207 .replace("&", "&")
2208}
2209
2210#[derive(Debug, Clone, PartialEq, Eq)]
2219pub struct WindowsServiceSpec {
2220 pub name: String,
2222 pub display_name: String,
2224 pub description: String,
2226 pub automatic_start: bool,
2228 pub account: Option<String>,
2231 pub command_line: String,
2233 pub restart: RestartPolicy,
2235}
2236
2237#[must_use]
2239pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2240 WindowsServiceSpec {
2241 name: plan.identity().name().to_string(),
2242 display_name: plan.identity().display_name().to_string(),
2243 description: plan.identity().description().to_string(),
2244 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2249 account: match ServiceAccount::for_definition(
2250 DefinitionKind::WindowsService,
2251 plan.start_mode(),
2252 ) {
2253 ServiceAccount::LocalSystem => None,
2256 other => Some(other.as_str().to_string()),
2257 },
2258 command_line: plan.command_line(),
2259 restart: plan.restart(),
2260 }
2261}
2262
2263#[must_use]
2266fn windows_service_descriptor(plan: &InstallPlan) -> String {
2267 let spec = windows_service_spec(plan);
2268 let mut out = String::new();
2269 out.push_str("[windows-service]\n");
2270 out.push_str(&format!("Name={}\n", spec.name));
2271 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2272 out.push_str(&format!("Description={}\n", spec.description));
2273 out.push_str("ServiceType=OWN_PROCESS\n");
2277 out.push_str(&format!(
2278 "StartType={}\n",
2279 if spec.automatic_start {
2280 "AutoStart"
2281 } else {
2282 "OnDemand"
2283 }
2284 ));
2285 out.push_str("ErrorControl=Normal\n");
2286 out.push_str(&format!(
2287 "Account={}\n",
2288 spec.account
2289 .as_deref()
2290 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2291 ));
2292 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2293 out.push_str(&format!(
2294 "FailureActionRestartDelaySecs={}\n",
2295 spec.restart.delay().as_secs()
2296 ));
2297 out.push_str(&format!(
2298 "FailureActionsResetPeriodSecs={}\n",
2299 spec.restart.reset_after().as_secs()
2300 ));
2301 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2305 out.push_str(&format!(
2306 "ReadWritePaths={}\n",
2307 plan.directories()
2308 .all()
2309 .iter()
2310 .map(|path| quote_argument(&path.to_string_lossy()))
2311 .collect::<Vec<_>>()
2312 .join(" ")
2313 ));
2314 out
2315}
2316
2317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2323pub enum FindingKind {
2324 Excess,
2327 Shortfall,
2330}
2331
2332impl fmt::Display for FindingKind {
2333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2334 f.write_str(match self {
2335 Self::Excess => "excess",
2336 Self::Shortfall => "shortfall",
2337 })
2338 }
2339}
2340
2341#[derive(Debug, Clone, PartialEq, Eq)]
2343pub struct PrivilegeFinding {
2344 pub kind: FindingKind,
2346 pub subject: String,
2348 pub detail: String,
2350}
2351
2352impl fmt::Display for PrivilegeFinding {
2353 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2354 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2355 }
2356}
2357
2358#[derive(Debug, Clone, PartialEq, Eq)]
2372pub struct PrivilegeReview {
2373 kind: DefinitionKind,
2374 account: ServiceAccount,
2375 controls: Vec<String>,
2376 findings: Vec<PrivilegeFinding>,
2377}
2378
2379impl PrivilegeReview {
2380 #[must_use]
2382 pub fn is_least_privilege(&self) -> bool {
2383 !self
2384 .findings
2385 .iter()
2386 .any(|finding| finding.kind == FindingKind::Excess)
2387 }
2388
2389 #[must_use]
2391 pub fn findings(&self) -> &[PrivilegeFinding] {
2392 &self.findings
2393 }
2394
2395 #[must_use]
2398 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2399 self.findings
2400 .iter()
2401 .filter(|finding| finding.kind == FindingKind::Excess)
2402 .collect()
2403 }
2404
2405 #[must_use]
2411 pub fn controls(&self) -> &[String] {
2412 &self.controls
2413 }
2414
2415 #[must_use]
2417 pub const fn account(&self) -> &ServiceAccount {
2418 &self.account
2419 }
2420
2421 #[must_use]
2423 pub const fn kind(&self) -> DefinitionKind {
2424 self.kind
2425 }
2426}
2427
2428impl fmt::Display for PrivilegeReview {
2429 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2430 writeln!(
2431 f,
2432 "{} runs as {} ({})",
2433 self.kind,
2434 self.account,
2435 self.account.justification()
2436 )?;
2437 for control in &self.controls {
2438 writeln!(f, " confirmed {control}")?;
2439 }
2440 for finding in &self.findings {
2441 writeln!(f, " {finding}")?;
2442 }
2443 if self.is_least_privilege() {
2444 write!(f, " verdict least privilege")
2445 } else {
2446 write!(
2447 f,
2448 " verdict NOT least privilege: {} excess(es)",
2449 self.excesses().len()
2450 )
2451 }
2452 }
2453}
2454
2455#[must_use]
2461pub fn review_least_privilege(
2462 definition: &ServiceDefinition,
2463 plan: &InstallPlan,
2464) -> PrivilegeReview {
2465 let mut controls = Vec::new();
2466 let mut findings = Vec::new();
2467 match definition.kind() {
2468 DefinitionKind::SystemdUnit => {
2469 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2470 }
2471 DefinitionKind::LaunchdPlist => {
2472 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2473 }
2474 DefinitionKind::WindowsScheduledTask => {
2475 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2476 }
2477 DefinitionKind::WindowsService => {
2478 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2479 }
2480 }
2481 PrivilegeReview {
2482 kind: definition.kind(),
2483 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2488 controls,
2489 findings,
2490 }
2491}
2492
2493fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2495 let mut permitted = plan
2496 .directories()
2497 .all()
2498 .iter()
2499 .map(|path| path.to_string_lossy().into_owned())
2500 .collect::<Vec<_>>();
2501 if kind == DefinitionKind::SystemdUnit
2502 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2503 {
2504 permitted.push(secret_directory.to_string_lossy().into_owned());
2505 }
2506 permitted
2507}
2508
2509fn same_path_text(left: &str, right: &str) -> bool {
2516 if cfg!(windows) {
2517 left.eq_ignore_ascii_case(right)
2518 } else {
2519 left == right
2520 }
2521}
2522
2523fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2526 match kind {
2527 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2528 left.eq_ignore_ascii_case(right)
2529 }
2530 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2531 }
2532}
2533
2534fn review_writable_paths(
2536 kind: DefinitionKind,
2537 subject: &str,
2538 listed: &[String],
2539 plan: &InstallPlan,
2540 controls: &mut Vec<String>,
2541 findings: &mut Vec<PrivilegeFinding>,
2542) {
2543 let permitted = permitted_paths(kind, plan);
2544 for entry in listed {
2545 if !permitted
2546 .iter()
2547 .any(|allowed| same_path_for(kind, allowed, entry))
2548 {
2549 findings.push(PrivilegeFinding {
2550 kind: FindingKind::Excess,
2551 subject: subject.to_string(),
2552 detail: format!(
2553 "{entry} is writable but is not one of this registration's required paths"
2554 ),
2555 });
2556 }
2557 }
2558 for allowed in &permitted {
2559 if !listed
2560 .iter()
2561 .any(|entry| same_path_for(kind, allowed, entry))
2562 {
2563 findings.push(PrivilegeFinding {
2564 kind: FindingKind::Shortfall,
2565 subject: subject.to_string(),
2566 detail: format!(
2567 "{allowed} is one of this registration's directories but is not writable, \
2568 so the daemon cannot use it"
2569 ),
2570 });
2571 }
2572 }
2573 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2574 controls.push(format!(
2575 "{subject} names exactly the required application-data and credential paths"
2576 ));
2577 }
2578}
2579
2580fn review_capabilities(
2583 expected: &str,
2584 wanted: &str,
2585 actual: &str,
2586 controls: &mut Vec<String>,
2587 findings: &mut Vec<PrivilegeFinding>,
2588) {
2589 let subject = "CapabilityBoundingSet";
2590 if actual.trim_start().starts_with('~') {
2592 findings.push(PrivilegeFinding {
2593 kind: FindingKind::Excess,
2594 subject: subject.to_string(),
2595 detail: format!("is `{actual}`, which keeps every capability it does not name"),
2596 });
2597 return;
2598 }
2599 let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2600 let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2601 let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2602 let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2603 if !extra.is_empty() {
2604 findings.push(PrivilegeFinding {
2605 kind: FindingKind::Excess,
2606 subject: subject.to_string(),
2607 detail: format!(
2608 "keeps {}, which the requirement does not ask for",
2609 extra.join(" ")
2610 ),
2611 });
2612 }
2613 if !missing.is_empty() {
2614 findings.push(PrivilegeFinding {
2615 kind: FindingKind::Shortfall,
2616 subject: subject.to_string(),
2617 detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2618 });
2619 }
2620 if extra.is_empty() && missing.is_empty() {
2621 controls.push(expected.to_string());
2622 }
2623}
2624
2625fn review_inbound_surface(
2633 text: &str,
2634 markers: &[(&str, &str)],
2635 controls: &mut Vec<String>,
2636 findings: &mut Vec<PrivilegeFinding>,
2637) {
2638 let mut clean = true;
2639 for (marker, detail) in markers {
2640 if text.contains(marker) {
2641 clean = false;
2642 findings.push(PrivilegeFinding {
2643 kind: FindingKind::Excess,
2644 subject: (*marker).to_string(),
2645 detail: (*detail).to_string(),
2646 });
2647 }
2648 }
2649 if clean {
2650 controls.push(
2651 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2652 );
2653 }
2654}
2655
2656fn review_systemd(
2657 text: &str,
2658 plan: &InstallPlan,
2659 controls: &mut Vec<String>,
2660 findings: &mut Vec<PrivilegeFinding>,
2661) {
2662 let directives = ini_directives(text, "Service");
2663 for expected in systemd_hardening(plan.start_mode()) {
2664 let (key, value) = expected
2665 .split_once('=')
2666 .expect("every hardening directive is written as key=value");
2667 match directives.get(key) {
2668 Some(actual) if key == "CapabilityBoundingSet" => {
2669 review_capabilities(expected, value, actual, controls, findings);
2670 }
2671 Some(actual) if actual == value => controls.push((*expected).to_string()),
2672 Some(actual) => findings.push(PrivilegeFinding {
2673 kind: FindingKind::Excess,
2674 subject: key.to_string(),
2675 detail: format!(
2676 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2677 requirement does not ask for"
2678 ),
2679 }),
2680 None => findings.push(PrivilegeFinding {
2681 kind: FindingKind::Excess,
2682 subject: key.to_string(),
2683 detail: format!(
2684 "is absent, so the unit inherits systemd's default rather than `{value}`"
2685 ),
2686 }),
2687 }
2688 }
2689
2690 match directives.get("ReadWritePaths") {
2691 Some(value) => {
2692 let listed = split_quoted(value);
2693 review_writable_paths(
2694 DefinitionKind::SystemdUnit,
2695 "ReadWritePaths",
2696 &listed,
2697 plan,
2698 controls,
2699 findings,
2700 );
2701 }
2702 None => findings.push(PrivilegeFinding {
2703 kind: FindingKind::Shortfall,
2704 subject: "ReadWritePaths".to_string(),
2705 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2706 .to_string(),
2707 }),
2708 }
2709
2710 if directives.contains_key("PrivateUsers")
2713 && directives.get("PrivateUsers") == Some(&"no".to_string())
2714 {
2715 findings.push(PrivilegeFinding {
2716 kind: FindingKind::Excess,
2717 subject: "PrivateUsers".to_string(),
2718 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2719 .to_string(),
2720 });
2721 }
2722
2723 review_inbound_surface(
2724 text,
2725 &[
2726 (
2727 "ListenStream=",
2728 "asks systemd to open a listening socket for this service, which \
2729 07-security.md rule 2 forbids the product to have",
2730 ),
2731 (
2732 "ListenDatagram=",
2733 "asks systemd to open a listening socket for this service, which \
2734 07-security.md rule 2 forbids the product to have",
2735 ),
2736 ],
2737 controls,
2738 findings,
2739 );
2740}
2741
2742fn review_launchd(
2743 text: &str,
2744 plan: &InstallPlan,
2745 controls: &mut Vec<String>,
2746 findings: &mut Vec<PrivilegeFinding>,
2747) {
2748 match plist_string_value(text, "ProcessType") {
2752 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2753 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2754 }
2755 found => findings.push(PrivilegeFinding {
2756 kind: FindingKind::Shortfall,
2757 subject: "ProcessType".to_string(),
2758 detail: format!(
2759 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2760 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2761 when it next starts with no runner; `service status` says how to restart it",
2762 found.map_or_else(
2763 || "absent (launchd's `Standard`)".to_string(),
2764 |value| format!("`{value}`")
2765 )
2766 ),
2767 }),
2768 }
2769
2770 match plan.start_mode() {
2771 StartMode::Boot => {
2772 if plist_bool_value(text, "SessionCreate") == Some(true) {
2773 findings.push(PrivilegeFinding {
2774 kind: FindingKind::Excess,
2775 subject: "SessionCreate".to_string(),
2776 detail: "asks launchd to create a security session for a job that runs \
2777 outside every login session and has no use for one"
2778 .to_string(),
2779 });
2780 } else {
2781 controls.push("SessionCreate is not requested".to_string());
2782 }
2783 match plist_string_value(text, "UserName").as_deref() {
2784 Some("root") => {
2785 controls.push("UserName=root, stated rather than inherited".to_string())
2786 }
2787 Some(other) => findings.push(PrivilegeFinding {
2788 kind: FindingKind::Shortfall,
2789 subject: "UserName".to_string(),
2790 detail: format!(
2791 "is `{other}`, which cannot unlock the System Keychain: \
2792 /var/db/SystemKey is root-only, so the daemon would start and then \
2793 find no credential"
2794 ),
2795 }),
2796 None => findings.push(PrivilegeFinding {
2797 kind: FindingKind::Shortfall,
2798 subject: "UserName".to_string(),
2799 detail: "is absent, so the account is launchd's implicit default and this \
2800 review cannot confirm it"
2801 .to_string(),
2802 }),
2803 }
2804 }
2805 StartMode::Login => {
2806 if let Some(named) = plist_string_value(text, "UserName") {
2807 findings.push(PrivilegeFinding {
2808 kind: FindingKind::Excess,
2809 subject: "UserName".to_string(),
2810 detail: format!(
2811 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2812 naming an account here asks launchd for a switch a login-mode \
2813 registration has no reason to want"
2814 ),
2815 });
2816 } else {
2817 controls
2818 .push("no UserName: the agent runs as the operator and no other".to_string());
2819 }
2820 }
2821 }
2822
2823 review_inbound_surface(
2824 text,
2825 &[
2826 (
2827 "<key>Sockets</key>",
2828 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2829 forbids the product to have",
2830 ),
2831 (
2832 "<key>MachServices</key>",
2833 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2834 forbids the product to have",
2835 ),
2836 ],
2837 controls,
2838 findings,
2839 );
2840}
2841
2842fn review_scheduled_task(
2843 text: &str,
2844 controls: &mut Vec<String>,
2845 findings: &mut Vec<PrivilegeFinding>,
2846) {
2847 match xml_value(text, "RunLevel").as_deref() {
2848 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2849 Some(other) => findings.push(PrivilegeFinding {
2850 kind: FindingKind::Excess,
2851 subject: "RunLevel".to_string(),
2852 detail: format!(
2853 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2854 an administrator"
2855 ),
2856 }),
2857 None => findings.push(PrivilegeFinding {
2858 kind: FindingKind::Excess,
2859 subject: "RunLevel".to_string(),
2860 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2861 .to_string(),
2862 }),
2863 }
2864
2865 match xml_value(text, "LogonType").as_deref() {
2866 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2867 Some(other) => findings.push(PrivilegeFinding {
2868 kind: FindingKind::Excess,
2869 subject: "LogonType".to_string(),
2870 detail: format!(
2871 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2872 task; an interactive token needs neither"
2873 ),
2874 }),
2875 None => findings.push(PrivilegeFinding {
2876 kind: FindingKind::Shortfall,
2877 subject: "LogonType".to_string(),
2878 detail: "is absent, so this review cannot confirm that no credential is stored"
2879 .to_string(),
2880 }),
2881 }
2882}
2883
2884fn review_windows_service(
2885 text: &str,
2886 plan: &InstallPlan,
2887 controls: &mut Vec<String>,
2888 findings: &mut Vec<PrivilegeFinding>,
2889) {
2890 let directives = ini_directives(text, "windows-service");
2891
2892 match directives.get("ServiceType").map(String::as_str) {
2893 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2894 Some(other) => findings.push(PrivilegeFinding {
2895 kind: FindingKind::Excess,
2896 subject: "ServiceType".to_string(),
2897 detail: format!(
2898 "is `{other}`; an interactive or shared-process service reaches further than a \
2899 daemon that only talks to GitHub over HTTPS"
2900 ),
2901 }),
2902 None => findings.push(PrivilegeFinding {
2903 kind: FindingKind::Shortfall,
2904 subject: "ServiceType".to_string(),
2905 detail: "is absent, so this review cannot confirm the service is not interactive"
2906 .to_string(),
2907 }),
2908 }
2909
2910 match directives.get("Account").map(String::as_str) {
2914 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2915 controls.push(format!(
2916 "Account={account}: the only stock account the machine-scoped store's DACL \
2917 (SY, BA, OW) admits"
2918 ));
2919 }
2920 Some(other) => findings.push(PrivilegeFinding {
2921 kind: FindingKind::Shortfall,
2922 subject: "Account".to_string(),
2923 detail: format!(
2924 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2925 daemon would start and then find no credential. Widening that DACL is not this \
2926 registration's to do: an ACE reaching `{other}` would also reach every other \
2927 service running under it"
2928 ),
2929 }),
2930 None => findings.push(PrivilegeFinding {
2931 kind: FindingKind::Shortfall,
2932 subject: "Account".to_string(),
2933 detail: "is absent, so this review cannot confirm which account was registered"
2934 .to_string(),
2935 }),
2936 }
2937
2938 match directives.get("ReadWritePaths") {
2939 Some(value) => {
2940 let listed = split_quoted(value);
2941 review_writable_paths(
2942 DefinitionKind::WindowsService,
2943 "ReadWritePaths",
2944 &listed,
2945 plan,
2946 controls,
2947 findings,
2948 );
2949 }
2950 None => findings.push(PrivilegeFinding {
2951 kind: FindingKind::Shortfall,
2952 subject: "ReadWritePaths".to_string(),
2953 detail: "is absent, so the directories the service was installed against are not \
2954 recorded"
2955 .to_string(),
2956 }),
2957 }
2958}
2959
2960fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2968 let mut out = BTreeMap::new();
2969 let mut inside = false;
2970 for line in text.lines() {
2971 let line = line.trim();
2972 if line.starts_with('[') && line.ends_with(']') {
2973 inside = &line[1..line.len() - 1] == section;
2974 continue;
2975 }
2976 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2977 continue;
2978 }
2979 if let Some((key, value)) = line.split_once('=') {
2980 out.insert(key.trim().to_string(), value.trim().to_string());
2981 }
2982 }
2983 out
2984}
2985
2986#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2993fn systemd_exec_command_line(value: &str) -> String {
2994 let value = value.trim();
2995 let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
2996 if !value[..value.len() - command.len()].contains('@') {
2997 return command.to_string();
2998 }
2999 let mut words = split_command_line(command);
3000 if words.len() > 1 {
3001 words.remove(1);
3002 }
3003 words
3004 .iter()
3005 .map(|word| quote_argument(word))
3006 .collect::<Vec<_>>()
3007 .join(" ")
3008}
3009
3010#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3014fn systemd_timespan(value: &str) -> Option<Duration> {
3015 let mut rest = value.trim();
3016 if rest.is_empty() || rest == "infinity" {
3017 return None;
3018 }
3019 let mut seconds = 0f64;
3020 while !rest.is_empty() {
3021 let digits = rest
3022 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3023 .unwrap_or(rest.len());
3024 if digits == 0 {
3025 return None;
3026 }
3027 let number: f64 = rest[..digits].parse().ok()?;
3028 rest = rest[digits..].trim_start();
3029 let letters = rest
3030 .find(|c: char| !c.is_alphabetic())
3031 .unwrap_or(rest.len());
3032 let scale = match &rest[..letters] {
3033 "" | "s" | "sec" | "second" | "seconds" => 1.0,
3034 "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3035 "ms" | "msec" => 1e-3,
3036 "m" | "min" | "minute" | "minutes" => 60.0,
3037 "h" | "hr" | "hour" | "hours" => 3_600.0,
3038 "d" | "day" | "days" => 86_400.0,
3039 "w" | "week" | "weeks" => 604_800.0,
3040 "M" | "month" | "months" => 2_629_800.0,
3041 "y" | "year" | "years" => 31_557_600.0,
3042 _ => return None,
3043 };
3044 seconds += number * scale;
3045 rest = rest[letters..].trim_start();
3046 }
3047 Duration::try_from_secs_f64(seconds).ok()
3048}
3049
3050fn split_quoted(value: &str) -> Vec<String> {
3053 let mut out = Vec::new();
3054 let mut rest = value.trim();
3055 while !rest.is_empty() {
3056 if rest.starts_with('"') {
3057 if let Some(parsed) = executable_from_command_line(rest) {
3060 out.push(parsed.to_string_lossy().into_owned());
3061 }
3062 let mut depth = 0usize;
3064 let mut end = rest.len();
3065 for (index, c) in rest.char_indices() {
3066 match c {
3067 '\\' => depth += 1,
3068 '"' => {
3069 if depth.is_multiple_of(2) && index > 0 {
3070 end = index + 1;
3071 break;
3072 }
3073 depth = 0;
3074 }
3075 _ => depth = 0,
3076 }
3077 }
3078 rest = rest[end..].trim_start();
3079 } else {
3080 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3081 out.push(rest[..end].to_string());
3082 rest = rest[end..].trim_start();
3083 }
3084 }
3085 out
3086}
3087
3088pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3100 let open = format!("<{tag}>");
3101 let close = format!("</{tag}>");
3102 let start = text.find(&open)? + open.len();
3103 let end = text[start..].find(&close)? + start;
3104 Some(xml_unescape(text[start..end].trim()))
3105}
3106
3107#[cfg(any(windows, test))]
3113fn windows_login_task_starts_automatically(document: &str) -> bool {
3114 document.contains("<LogonTrigger>")
3115 && xml_value(document, "Enabled").as_deref() != Some("false")
3116}
3117
3118fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3120 let marker = format!("<key>{key}</key>");
3121 let start = text.find(&marker)? + marker.len();
3122 Some(text[start..].trim_start())
3123}
3124
3125pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3126 let rest = plist_value_after_key(text, key)?;
3127 if !rest.starts_with("<string>") {
3128 return None;
3129 }
3130 xml_value(rest, "string")
3131}
3132
3133fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3134 let rest = plist_value_after_key(text, key)?;
3135 if rest.starts_with("<true/>") {
3136 Some(true)
3137 } else if rest.starts_with("<false/>") {
3138 Some(false)
3139 } else {
3140 None
3141 }
3142}
3143
3144pub const RECORD_SCHEMA_VERSION: u32 = 1;
3155
3156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3168pub struct InstallRecord {
3169 pub schema_version: u32,
3171 pub service_name: String,
3173 pub manager: String,
3175 pub start_mode: StartMode,
3177 pub account: ServiceAccount,
3179 pub binary: PathBuf,
3181 #[serde(default)]
3189 pub source_binary: Option<PathBuf>,
3190 pub arguments: Vec<String>,
3192 pub restart_delay_secs: u64,
3194 pub restart_reset_secs: u64,
3196 pub log_file: PathBuf,
3198 #[serde(default)]
3206 pub starts_on_demand: bool,
3207 pub definition_path: Option<PathBuf>,
3209 pub installed_at: DateTime<Utc>,
3211 pub installed_by_version: String,
3213 pub directories: ServiceDirectories,
3218}
3219
3220impl InstallRecord {
3221 #[must_use]
3223 pub fn path(paths: &AppPaths) -> PathBuf {
3224 paths.config_dir().join(RECORD_FILE)
3225 }
3226
3227 #[must_use]
3229 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3230 Self {
3231 schema_version: RECORD_SCHEMA_VERSION,
3232 service_name: plan.identity().name().to_string(),
3233 manager: definition.kind().manager().to_string(),
3234 start_mode: plan.start_mode(),
3235 account: plan.account().clone(),
3236 binary: plan.binary().to_path_buf(),
3237 arguments: plan
3238 .arguments()
3239 .iter()
3240 .map(|argument| argument.to_string_lossy().into_owned())
3241 .collect(),
3242 restart_delay_secs: plan.restart().delay().as_secs(),
3243 restart_reset_secs: plan.restart().reset_after().as_secs(),
3244 starts_on_demand: plan.is_on_demand(),
3245 source_binary: plan.source_binary().map(Path::to_path_buf),
3246 log_file: plan.directories().log_file(),
3247 definition_path: definition.install_path().map(Path::to_path_buf),
3248 installed_at: at,
3249 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3250 directories: plan.directories().clone(),
3251 }
3252 }
3253
3254 #[must_use]
3262 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3263 let plan = InstallPlan::unchecked(
3264 identity,
3265 self.start_mode,
3266 self.binary.clone(),
3267 self.directories.clone(),
3268 )
3269 .with_arguments(self.arguments.clone())
3270 .with_restart(self.restart());
3271 let plan = if self.starts_on_demand {
3272 plan.started_on_demand()
3273 } else {
3274 plan
3275 };
3276 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3277 Ok(store) => plan.with_secret_guard(store.guard()),
3278 Err(_) => plan,
3279 }
3280 }
3281
3282 #[must_use]
3286 pub fn restart(&self) -> RestartPolicy {
3287 RestartPolicy::new(
3288 Duration::from_secs(self.restart_delay_secs),
3289 Duration::from_secs(self.restart_reset_secs),
3290 )
3291 .unwrap_or_default()
3292 }
3293
3294 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3305 let path = Self::path(paths);
3306 let text = match std::fs::read_to_string(&path) {
3307 Ok(text) => text,
3308 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3309 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3312 return Err(ServiceError::RecordNotPermitted {
3313 path,
3314 detail: error.to_string(),
3315 });
3316 }
3317 Err(error) => {
3318 return Err(ServiceError::Record {
3319 operation: "read",
3320 path,
3321 detail: error.to_string(),
3322 });
3323 }
3324 };
3325 let record: Self =
3326 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3327 path: path.clone(),
3328 detail: error.to_string(),
3329 })?;
3330 if record.schema_version != RECORD_SCHEMA_VERSION {
3331 return Err(ServiceError::RecordUnreadable {
3332 path,
3333 detail: format!(
3334 "it declares schema version {} and this build reads version {}",
3335 record.schema_version, RECORD_SCHEMA_VERSION
3336 ),
3337 });
3338 }
3339 Ok(Some(record))
3340 }
3341
3342 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3348 use std::io::Write as _;
3349
3350 let path = Self::path(paths);
3351 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3352 operation: "encode",
3353 path: path.clone(),
3354 detail: error.to_string(),
3355 })?;
3356 if let Some(parent) = path.parent() {
3357 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3358 operation: "write",
3359 path: path.clone(),
3360 detail: error.to_string(),
3361 })?;
3362 }
3363 let parent = path.parent().ok_or_else(|| ServiceError::Record {
3364 operation: "write",
3365 path: path.clone(),
3366 detail: "the record path has no parent directory".to_string(),
3367 })?;
3368 let mut temporary =
3369 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3370 operation: "write",
3371 path: path.clone(),
3372 detail: error.to_string(),
3373 })?;
3374 temporary
3375 .write_all(text.as_bytes())
3376 .and_then(|()| temporary.as_file().sync_all())
3377 .map_err(|error| ServiceError::Record {
3378 operation: "write",
3379 path: path.clone(),
3380 detail: error.to_string(),
3381 })?;
3382 #[cfg(unix)]
3398 {
3399 use std::os::unix::fs::PermissionsExt as _;
3400
3401 temporary
3402 .as_file()
3403 .set_permissions(std::fs::Permissions::from_mode(0o644))
3404 .map_err(|error| ServiceError::Record {
3405 operation: "write",
3406 path: path.clone(),
3407 detail: error.to_string(),
3408 })?;
3409 }
3410 temporary
3411 .persist(&path)
3412 .map(|_| ())
3413 .map_err(|error| ServiceError::Record {
3414 operation: "write",
3415 path,
3416 detail: error.error.to_string(),
3417 })
3418 }
3419
3420 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3426 let path = Self::path(paths);
3427 match std::fs::remove_file(&path) {
3428 Ok(()) => Ok(true),
3429 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3430 Err(error) => Err(ServiceError::Record {
3431 operation: "remove",
3432 path,
3433 detail: error.to_string(),
3434 }),
3435 }
3436 }
3437}
3438
3439const CONTACT_SCHEMA_VERSION: u32 = 1;
3445
3446#[derive(Debug, Clone, Serialize, Deserialize)]
3447struct ContactRecord {
3448 schema_version: u32,
3449 last_success: DateTime<Utc>,
3450}
3451
3452pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3473 write_state_record(
3474 &contact_path(paths),
3475 &ContactRecord {
3476 schema_version: CONTACT_SCHEMA_VERSION,
3477 last_success: at,
3478 },
3479 )
3480}
3481
3482pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3494 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3495}
3496
3497#[must_use]
3499pub fn contact_path(paths: &AppPaths) -> PathBuf {
3500 paths.state_dir().join(CONTACT_FILE)
3501}
3502
3503pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3506 let failed = |detail: String| ServiceError::Record {
3507 operation: "write",
3508 path: path.to_path_buf(),
3509 detail,
3510 };
3511 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3512 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3513 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3514 let temporary = path.with_extension("toml.new");
3515 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3516 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3517}
3518
3519pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3522 path: &Path,
3523) -> Result<Option<T>, ServiceError> {
3524 let failed = |detail: String| ServiceError::Record {
3525 operation: "read",
3526 path: path.to_path_buf(),
3527 detail,
3528 };
3529 let text = match std::fs::read_to_string(path) {
3530 Ok(text) => text,
3531 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3532 Err(error) => return Err(failed(error.to_string())),
3533 };
3534 toml::from_str(&text)
3535 .map(Some)
3536 .map_err(|error| failed(error.to_string()))
3537}
3538
3539pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3541 match std::fs::remove_file(path) {
3542 Ok(()) => Ok(()),
3543 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3544 Err(error) => Err(ServiceError::Record {
3545 operation: "remove",
3546 path: path.to_path_buf(),
3547 detail: error.to_string(),
3548 }),
3549 }
3550}
3551
3552#[derive(Debug, Serialize, Deserialize)]
3557#[serde(deny_unknown_fields)]
3558struct CredentialRejectionRecord {
3559 schema_version: u32,
3560 rejected_since: DateTime<Utc>,
3561}
3562
3563pub fn record_github_credential_rejected(
3579 paths: &AppPaths,
3580 at: DateTime<Utc>,
3581) -> Result<(), ServiceError> {
3582 if github_credential_rejected_since(paths)
3583 .ok()
3584 .flatten()
3585 .is_some()
3586 {
3587 return Ok(());
3588 }
3589 write_state_record(
3590 &credential_rejection_path(paths),
3591 &CredentialRejectionRecord {
3592 schema_version: CONTACT_SCHEMA_VERSION,
3593 rejected_since: at,
3594 },
3595 )
3596}
3597
3598pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3604 remove_state_record(&credential_rejection_path(paths))
3605}
3606
3607pub fn github_credential_rejected_since(
3613 paths: &AppPaths,
3614) -> Result<Option<DateTime<Utc>>, ServiceError> {
3615 Ok(
3616 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3617 .map(|record| record.rejected_since),
3618 )
3619}
3620
3621#[must_use]
3623pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3624 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3625}
3626
3627#[derive(Debug, Serialize, Deserialize)]
3628#[serde(deny_unknown_fields)]
3629struct CredentialUnreadableRecord {
3630 schema_version: u32,
3631 unreadable_since: DateTime<Utc>,
3632}
3633
3634pub fn record_credential_unreadable(
3653 paths: &AppPaths,
3654 at: DateTime<Utc>,
3655) -> Result<(), ServiceError> {
3656 if credential_unreadable_since(paths).ok().flatten().is_some() {
3657 return Ok(());
3658 }
3659 write_state_record(
3660 &credential_unreadable_path(paths),
3661 &CredentialUnreadableRecord {
3662 schema_version: CONTACT_SCHEMA_VERSION,
3663 unreadable_since: at,
3664 },
3665 )
3666}
3667
3668pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3674 remove_state_record(&credential_unreadable_path(paths))
3675}
3676
3677pub fn credential_unreadable_since(
3684 paths: &AppPaths,
3685) -> Result<Option<DateTime<Utc>>, ServiceError> {
3686 Ok(
3687 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3688 .map(|record| record.unreadable_since),
3689 )
3690}
3691
3692#[must_use]
3694pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3695 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3696}
3697
3698#[must_use]
3706pub fn sign_in_instruction(mode: StartMode) -> String {
3707 let (command, place) = match mode {
3708 StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3709 StartMode::Boot => (
3710 "runner-manager auth login --start-at boot".to_owned(),
3711 " from an elevated terminal",
3712 ),
3713 StartMode::Login => (
3714 "runner-manager auth login --start-at login".to_owned(),
3715 MACOS_LOGIN_PLACE,
3716 ),
3717 };
3718 format!("`{command}`{place}")
3719}
3720
3721#[must_use]
3730pub fn sudo_command(arguments: &str) -> String {
3731 let executable = if cfg!(test) {
3734 None
3735 } else {
3736 std::env::current_exe().ok()
3737 };
3738 format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3739}
3740
3741fn sudo_program(executable: Option<&Path>) -> String {
3743 const SECURE_PATH: [&str; 7] = [
3746 "/usr/local/sbin",
3747 "/usr/local/bin",
3748 "/usr/sbin",
3749 "/usr/bin",
3750 "/sbin",
3751 "/bin",
3752 "/snap/bin",
3753 ];
3754 let Some(executable) = executable else {
3755 return "runner-manager".to_owned();
3756 };
3757 if executable
3758 .parent()
3759 .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3760 {
3761 return "runner-manager".to_owned();
3762 }
3763 let text = executable.to_string_lossy();
3764 if text
3765 .chars()
3766 .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3767 {
3768 text.into_owned()
3769 } else {
3770 format!("'{}'", text.replace('\'', "'\\''"))
3771 }
3772}
3773
3774pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3777 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3778 keychain)"
3779} else {
3780 ""
3781};
3782
3783#[derive(Debug, Clone, PartialEq, Eq)]
3800pub struct DefinitionDrift {
3801 pub path: PathBuf,
3803 pub installed_by_version: String,
3805 pub rendered: String,
3807 pub start_mode: StartMode,
3809 pub manager: DefinitionKind,
3811}
3812
3813fn definition_drift(
3821 controls: &dyn ControlFactory,
3822 identity: &ServiceIdentity,
3823 record: &InstallRecord,
3824) -> Option<DefinitionDrift> {
3825 let compare = match host_definition_kind(record.start_mode) {
3826 DefinitionKind::SystemdUnit => systemd_definition_drift,
3827 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3828 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3829 };
3830 let path = record.definition_path.clone()?;
3831 let installed = controls.installed_definition(&path)?;
3832 compare(identity, record, path, &installed)
3833}
3834
3835fn launchd_definition_drift(
3844 identity: &ServiceIdentity,
3845 record: &InstallRecord,
3846 path: PathBuf,
3847 installed: &str,
3848) -> Option<DefinitionDrift> {
3849 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3850 || DefinitionDrift {
3851 path,
3852 installed_by_version: record.installed_by_version.clone(),
3853 rendered: launchd_plist(&record.plan(identity.clone())),
3854 start_mode: record.start_mode,
3855 manager: DefinitionKind::LaunchdPlist,
3856 },
3857 )
3858}
3859
3860fn systemd_definition_drift(
3861 identity: &ServiceIdentity,
3862 record: &InstallRecord,
3863 path: PathBuf,
3864 installed: &str,
3865) -> Option<DefinitionDrift> {
3866 let rendered = systemd_unit(&record.plan(identity.clone()));
3867 (installed != rendered).then(|| DefinitionDrift {
3868 path,
3869 installed_by_version: record.installed_by_version.clone(),
3870 rendered,
3871 start_mode: record.start_mode,
3872 manager: DefinitionKind::SystemdUnit,
3873 })
3874}
3875
3876const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3881
3882#[derive(Debug, Clone, Serialize, Deserialize)]
3883struct RootRefusalFile {
3884 schema_version: u32,
3885 #[serde(default)]
3887 refusals: BTreeMap<String, RootRefusalEntry>,
3888}
3889
3890#[derive(Debug, Clone, Serialize, Deserialize)]
3891struct RootRefusalEntry {
3892 at: DateTime<Utc>,
3893 kind: String,
3894 root: String,
3895 detail: String,
3896}
3897
3898#[derive(Debug, Clone, PartialEq, Eq)]
3900pub struct RunnerRootRefusal {
3901 pub policy: String,
3903 pub at: DateTime<Utc>,
3905 pub kind: String,
3907 pub root: String,
3909 pub detail: String,
3912}
3913
3914pub fn record_runner_root_refusal(
3948 paths: &AppPaths,
3949 policy: &str,
3950 at: DateTime<Utc>,
3951 kind: &str,
3952 root: &str,
3953 detail: &str,
3954) -> Result<(), ServiceError> {
3955 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3956 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3957 refusals: BTreeMap::new(),
3958 });
3959 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3960 file.refusals.insert(
3961 policy.to_owned(),
3962 RootRefusalEntry {
3963 at,
3964 kind: kind.to_owned(),
3965 root: root.to_owned(),
3966 detail: detail.to_owned(),
3967 },
3968 );
3969 write_refusal_file(paths, &file)
3970}
3971
3972pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3983 let Some(mut file) = read_refusal_file(paths)? else {
3984 return Ok(());
3985 };
3986 if file.refusals.remove(policy).is_none() {
3987 return Ok(());
3988 }
3989 if file.refusals.is_empty() {
3990 let path = root_refusal_path(paths);
3991 return match std::fs::remove_file(&path) {
3992 Ok(()) => Ok(()),
3993 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3994 Err(error) => Err(ServiceError::Record {
3995 operation: "remove",
3996 path,
3997 detail: error.to_string(),
3998 }),
3999 };
4000 }
4001 write_refusal_file(paths, &file)
4002}
4003
4004pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4016 Ok(read_refusal_file(paths)?
4017 .map(|file| {
4018 file.refusals
4019 .into_iter()
4020 .map(|(policy, entry)| RunnerRootRefusal {
4021 policy,
4022 at: entry.at,
4023 kind: entry.kind,
4024 root: entry.root,
4025 detail: entry.detail,
4026 })
4027 .collect()
4028 })
4029 .unwrap_or_default())
4030}
4031
4032fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4033 read_state_record(&root_refusal_path(paths))
4034}
4035
4036fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4037 write_state_record(&root_refusal_path(paths), file)
4038}
4039
4040#[must_use]
4042pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4043 paths.state_dir().join(ROOT_REFUSAL_FILE)
4044}
4045
4046#[derive(Debug, Clone, PartialEq, Eq)]
4057pub enum BinaryPath {
4058 Current {
4060 path: PathBuf,
4062 },
4063 Missing {
4070 recorded: PathBuf,
4072 },
4073 NotExecutable {
4076 recorded: PathBuf,
4078 detail: String,
4080 },
4081 Diverged {
4083 recorded: PathBuf,
4085 registered: PathBuf,
4087 },
4088}
4089
4090impl BinaryPath {
4091 #[must_use]
4093 pub const fn is_error(&self) -> bool {
4094 !matches!(self, Self::Current { .. })
4095 }
4096
4097 #[must_use]
4099 pub fn recorded(&self) -> &Path {
4100 match self {
4101 Self::Current { path } => path,
4102 Self::Missing { recorded }
4103 | Self::NotExecutable { recorded, .. }
4104 | Self::Diverged { recorded, .. } => recorded,
4105 }
4106 }
4107}
4108
4109impl fmt::Display for BinaryPath {
4110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4111 match self {
4112 Self::Current { path } => write!(f, "{}", path.display()),
4113 Self::Missing { recorded } => write!(
4114 f,
4115 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4116 package manager that moved the binary is the usual cause; an `npm i -g` \
4117 installation moves with the active Node version. Run `service install` again \
4118 from the binary that is now installed.",
4119 recorded.display()
4120 ),
4121 Self::NotExecutable { recorded, detail } => write!(
4122 f,
4123 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4124 from the installed binary.",
4125 recorded.display()
4126 ),
4127 Self::Diverged {
4128 recorded,
4129 registered,
4130 } => write!(
4131 f,
4132 "{} -- STALE: the service manager is registered to start {} instead. Something \
4133 has edited the registration since it was installed. Run `service uninstall` and \
4134 `service install`; neither touches configuration, secrets, or the cache.",
4135 recorded.display(),
4136 registered.display()
4137 ),
4138 }
4139 }
4140}
4141
4142#[must_use]
4150pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4151 match std::fs::metadata(recorded) {
4152 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4153 return BinaryPath::Missing {
4154 recorded: recorded.to_path_buf(),
4155 };
4156 }
4157 Err(error) => {
4158 return BinaryPath::NotExecutable {
4159 recorded: recorded.to_path_buf(),
4160 detail: format!("it cannot be inspected ({error})"),
4161 };
4162 }
4163 Ok(metadata) if !metadata.is_file() => {
4164 return BinaryPath::NotExecutable {
4165 recorded: recorded.to_path_buf(),
4166 detail: "what is there is not a file".to_string(),
4167 };
4168 }
4169 Ok(_) => {}
4170 }
4171 if let Some(registered) = registered
4172 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
4173 {
4174 return BinaryPath::Diverged {
4175 recorded: recorded.to_path_buf(),
4176 registered: registered.to_path_buf(),
4177 };
4178 }
4179 BinaryPath::Current {
4180 path: recorded.to_path_buf(),
4181 }
4182}
4183
4184#[derive(Debug, Clone, PartialEq, Eq)]
4190pub struct Registration {
4191 pub manager: DefinitionKind,
4196 pub start_mode: StartMode,
4198 pub command_line: String,
4200 pub account: Option<String>,
4207 pub running: bool,
4209 pub starts_automatically: bool,
4216 pub restart_delay: Option<Duration>,
4225}
4226
4227impl Registration {
4228 #[must_use]
4230 pub fn binary(&self) -> Option<PathBuf> {
4231 executable_from_command_line(&self.command_line)
4232 }
4233}
4234
4235pub trait ServiceControl: fmt::Debug {
4241 fn manager(&self) -> DefinitionKind;
4243
4244 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4250
4251 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4262
4263 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4269
4270 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4277
4278 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4284}
4285
4286pub trait ControlFactory: fmt::Debug + Send + Sync {
4292 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4298
4299 fn installed_definition(&self, _path: &Path) -> Option<String> {
4303 None
4304 }
4305
4306 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4311 None
4312 }
4313}
4314
4315#[derive(Debug, Clone, Copy, Default)]
4317pub struct HostControls;
4318
4319#[derive(Debug, Clone)]
4325pub struct Installed {
4326 pub plan: InstallPlan,
4328 pub definition: ServiceDefinition,
4330 pub record: InstallRecord,
4332 pub review: PrivilegeReview,
4334 pub runner_root: RootAccessSummary,
4339 pub replaced_existing: bool,
4347}
4348
4349#[derive(Debug, Clone, PartialEq, Eq)]
4351pub struct Uninstalled {
4352 pub removed_registration: bool,
4354 pub removed_record: bool,
4356 pub removed_definition: Option<PathBuf>,
4358 pub preserved: Vec<PathBuf>,
4365}
4366
4367impl fmt::Display for Uninstalled {
4368 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4369 if self.removed_registration {
4370 writeln!(f, "The service registration was removed.")?;
4371 } else {
4372 writeln!(f, "There was no service registration to remove.")?;
4373 }
4374 writeln!(f, "Nothing else was deleted. These are untouched:")?;
4375 for path in &self.preserved {
4376 writeln!(f, " {}", path.display())?;
4377 }
4378 write!(
4379 f,
4380 "The stored GitHub token is untouched too; `auth logout` is what purges it."
4381 )
4382 }
4383}
4384
4385#[derive(Debug, Clone, PartialEq, Eq)]
4387pub struct StartModeChange {
4388 pub from: StartMode,
4390 pub to: StartMode,
4392 pub changed: bool,
4394 pub store_scope: crate::secrets::SecretScope,
4397 pub runner_root: RootAccessSummary,
4403}
4404
4405impl fmt::Display for StartModeChange {
4406 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4407 if !self.changed {
4408 return write!(f, "The service already starts at {}.", self.to);
4409 }
4410 write!(
4411 f,
4412 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4413 if the token was stored under the other scope, run `auth login` again. {}",
4414 self.to, self.from, self.store_scope, self.runner_root
4415 )
4416 }
4417}
4418
4419#[derive(Debug, Clone)]
4427pub struct ServiceOperations {
4428 paths: AppPaths,
4429 identity: ServiceIdentity,
4430 controls: std::sync::Arc<dyn ControlFactory>,
4431 runner_root: Option<LocalAbsolutePath>,
4432}
4433
4434impl ServiceOperations {
4435 #[must_use]
4437 pub fn on_this_host(paths: AppPaths) -> Self {
4438 Self::with_controls(
4439 paths,
4440 ServiceIdentity::product(),
4441 std::sync::Arc::new(HostControls),
4442 )
4443 }
4444
4445 #[must_use]
4453 pub fn with_controls(
4454 paths: AppPaths,
4455 identity: ServiceIdentity,
4456 controls: std::sync::Arc<dyn ControlFactory>,
4457 ) -> Self {
4458 Self {
4459 paths,
4460 identity,
4461 controls,
4462 runner_root: None,
4463 }
4464 }
4465
4466 #[must_use]
4485 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4486 self.runner_root = Some(root);
4487 self
4488 }
4489
4490 #[must_use]
4492 pub const fn paths(&self) -> &AppPaths {
4493 &self.paths
4494 }
4495
4496 #[must_use]
4498 pub const fn identity(&self) -> &ServiceIdentity {
4499 &self.identity
4500 }
4501
4502 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4522 self.paths
4523 .create_all()
4524 .map_err(|source| ServiceError::Paths {
4525 source: Box::new(source),
4526 })?;
4527
4528 let _guard = self.refuse_while_an_agent_runs()?;
4531 let adopted = self.adopt_boot_installation(request.start_mode())?;
4532 if !adopted {
4536 self.refuse_foreign("replace")?;
4537 }
4538
4539 let (replacing, previous_was_running) = match self.find_registration()? {
4568 Some((existing, registration)) if existing == request.start_mode() => {
4569 (true, registration.running)
4570 }
4571 Some((existing, _)) => {
4572 return Err(ServiceError::AlreadyInstalled {
4573 name: self.identity.name().to_string(),
4574 existing,
4575 requested: request.start_mode(),
4576 });
4577 }
4578 None => (false, false),
4579 };
4580
4581 let plan = InstallPlan::resolve(
4582 self.identity.clone(),
4583 request,
4584 ServiceDirectories::of(&self.paths),
4585 )?;
4586
4587 let control = self.controls.control(plan.start_mode())?;
4592
4593 let root = self.prepare_runner_root(plan.start_mode())?;
4599
4600 let previous = if replacing {
4605 InstallRecord::read(&self.paths).ok().flatten()
4606 } else {
4607 None
4608 };
4609 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4610 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4611 }
4612
4613 let definition = match control.install(&plan) {
4614 Ok(definition) => definition,
4615 Err(cause) => {
4616 let restored =
4619 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4620 return Err(rolled_back(
4621 retained_runner_root(&root),
4622 restored,
4623 "install",
4624 &self.identity,
4625 cause,
4626 ));
4627 }
4628 };
4629 let review = review_least_privilege(&definition, &plan);
4630 let record = InstallRecord::of(&plan, &definition, Utc::now());
4631 if let Err(cause) = record.write(&self.paths) {
4632 let rollback = control
4633 .uninstall(&self.identity)
4634 .map(|_| ())
4635 .and_then(|()| {
4636 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4637 });
4638 return Err(rolled_back(
4639 retained_runner_root(&root),
4640 rollback,
4641 "install",
4642 &self.identity,
4643 cause,
4644 ));
4645 }
4646
4647 if let Err(cause) = control.start(&self.identity) {
4654 let remove_new = control.uninstall(&self.identity).map(|_| ());
4655 let restore_registration = remove_new.and_then(|()| {
4656 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4657 });
4658 let restore_record = restore_registration.and_then(|()| match &previous {
4659 Some(previous) => previous.write(&self.paths),
4660 None => InstallRecord::remove(&self.paths).map(|_| ()),
4661 });
4662 return Err(rolled_back(
4663 retained_runner_root(&root),
4664 restore_record,
4665 "install and start",
4666 &self.identity,
4667 cause,
4668 ));
4669 }
4670 Ok(Installed {
4671 plan,
4672 definition,
4673 record,
4674 review,
4675 runner_root: root.summary().clone(),
4676 replaced_existing: replacing,
4677 })
4678 }
4679
4680 fn reinstate(
4691 &self,
4692 control: &dyn ServiceControl,
4693 previous: Option<&InstallRecord>,
4694 was_running: bool,
4695 ) -> Result<(), ServiceError> {
4696 let Some(record) = previous else {
4697 return Ok(());
4698 };
4699 control.install(&record.plan(self.identity.clone()))?;
4700 if was_running {
4701 control.start(&self.identity)?;
4702 }
4703 Ok(())
4704 }
4705
4706 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4715 let record = InstallRecord::read(&self.paths).ok().flatten();
4716 let removed_definition = record
4717 .as_ref()
4718 .and_then(|record| record.definition_path.clone());
4719
4720 let mut removed_registration = false;
4721 let mut foreign = None;
4728 for mode in [StartMode::Boot, StartMode::Login] {
4729 let control = self.controls.control(mode)?;
4730 if let Some(directories) = control
4731 .query(&self.identity)
4732 .ok()
4733 .flatten()
4734 .and_then(|registration| self.foreign_directories(®istration))
4735 {
4736 foreign = Some(directories);
4737 continue;
4738 }
4739 if control.uninstall(&self.identity)? {
4740 removed_registration = true;
4741 }
4742 }
4743 if !removed_registration && let Some(directories) = foreign {
4744 return Err(self.foreign_refusal("uninstall", directories));
4745 }
4746 let removed_record = InstallRecord::remove(&self.paths)?;
4747 Ok(Uninstalled {
4748 removed_registration,
4749 removed_record,
4750 removed_definition: removed_definition.filter(|_| removed_registration),
4751 preserved: self
4752 .paths
4753 .all()
4754 .iter()
4755 .map(|(_, path)| (*path).to_path_buf())
4756 .collect(),
4757 })
4758 }
4759
4760 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4782 let Some(record) = InstallRecord::read(&self.paths)? else {
4783 return Err(ServiceError::NotInstalled {
4784 name: self.identity.name().to_string(),
4785 operation: "switch the start mode of",
4786 });
4787 };
4788 let from = record.start_mode;
4789 if from == to {
4790 return Ok(StartModeChange {
4794 from,
4795 to,
4796 changed: false,
4797 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4798 runner_root: RootAccessSummary::NotApplicable,
4799 });
4800 }
4801
4802 self.refuse_foreign("switch the start mode of")?;
4805
4806 #[cfg(windows)]
4807 let arguments = {
4808 let mut arguments = record.arguments.clone();
4809 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4810 if to == StartMode::Boot {
4811 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4812 }
4813 arguments
4814 };
4815 #[cfg(not(windows))]
4816 let arguments = record.arguments.clone();
4817 let plan = InstallPlan::unchecked(
4818 self.identity.clone(),
4819 to,
4820 record.binary.clone(),
4821 record.directories.clone(),
4822 )
4823 .with_arguments(arguments)
4824 .with_restart(record.restart());
4825 let plan = if record.starts_on_demand {
4826 plan.started_on_demand()
4827 } else {
4828 plan
4829 };
4830 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4831 Ok(store) => plan.with_secret_guard(store.guard()),
4832 Err(_) => plan,
4833 };
4834
4835 let target = self.controls.control(to)?;
4842 let previous = self.controls.control(from)?;
4847
4848 let root = self.prepare_runner_root(to)?;
4856
4857 let definition = match target.install(&plan) {
4858 Ok(definition) => definition,
4859 Err(cause) => {
4860 return Err(undo_runner_root(
4861 &root,
4862 "switch start mode",
4863 &self.identity,
4864 cause,
4865 ));
4866 }
4867 };
4868 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4869 if let Err(cause) = next_record.write(&self.paths) {
4870 return Err(rolled_back(
4871 retained_runner_root(&root),
4872 target.uninstall(&self.identity),
4873 "switch start mode",
4874 &self.identity,
4875 cause,
4876 ));
4877 }
4878
4879 if let Err(cause) = previous.uninstall(&self.identity) {
4883 let target_rollback = target.uninstall(&self.identity);
4884 let record_rollback = record.write(&self.paths);
4885 return Err(rolled_back(
4886 retained_runner_root(&root),
4887 target_rollback.and(record_rollback),
4888 "switch start mode",
4889 &self.identity,
4890 cause,
4891 ));
4892 }
4893 Ok(StartModeChange {
4894 from,
4895 to,
4896 changed: true,
4897 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4898 runner_root: root.summary().clone(),
4899 })
4900 }
4901
4902 pub fn start(&self) -> Result<(), ServiceError> {
4908 let mode = self.own_registration_mode("start")?;
4909 self.controls.control(mode)?.start(&self.identity)
4910 }
4911
4912 fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
4920 match self.registration_for_status()? {
4921 None => Err(ServiceError::NotInstalled {
4922 name: self.identity.name().to_string(),
4923 operation,
4924 }),
4925 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4926 Some((registration, None)) => Ok(registration.start_mode),
4927 }
4928 }
4929
4930 pub fn stop(&self) -> Result<bool, ServiceError> {
4936 let mode = self.own_registration_mode("stop")?;
4937 self.controls.control(mode)?.stop(&self.identity)
4938 }
4939
4940 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4971 match self.registration_for_status()? {
4972 Some((registration, Some(directories))) => {
4973 Ok(self.foreign_status(registration, directories))
4974 }
4975 found => {
4976 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4977 Ok(record) => (record, None),
4978 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4979 Err(error) => return Err(error),
4980 };
4981 self.status_at(
4982 &self.paths,
4983 record,
4984 record_refused.as_ref(),
4985 found.map(|(registration, _)| registration),
4986 )
4987 }
4988 }
4989 }
4990
4991 fn status_at(
4994 &self,
4995 paths: &AppPaths,
4996 record: Option<InstallRecord>,
4997 record_refused: Option<&ServiceError>,
4998 found: Option<Registration>,
4999 ) -> Result<ServiceStatus, ServiceError> {
5000 let last_github_contact = last_github_contact(paths)?;
5001 let drift = record
5002 .as_ref()
5003 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5004 let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5005 let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5006 let probed = self.controls.unattended_login();
5007 let unattended = record.as_ref().and_then(|record| {
5008 let account = probed.as_ref().map_or_else(String::new, |_| {
5011 found
5012 .as_ref()
5013 .and_then(|registration| registration.account.clone())
5014 .unwrap_or_else(|| service_account_name(&record.account))
5015 });
5016 unattended_gap(record.start_mode, &account, probed.as_ref())
5017 });
5018 let now = Utc::now();
5019 let installed = record.is_some() || found.is_some();
5020 let status = ServiceStatus::compose(
5021 self.identity.clone(),
5022 record,
5023 record_refused,
5024 found,
5025 last_github_contact,
5026 paths,
5027 )
5028 .with_definition_drift(drift)
5029 .with_credential_rejection(credential_rejected_since)
5030 .with_credential_unreadable(credential_unreadable_since)
5031 .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5032 .with_unattended_gap(unattended)
5033 .with_host_unfit(
5034 crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5035 );
5036 let restart = status.restart_command();
5037 Ok(status.with_stall(
5038 crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5039 ))
5040 }
5041
5042 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5049 Ok(InstallRecord::read(&self.paths)?
5050 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5051 }
5052
5053 #[cfg(target_os = "macos")]
5075 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5076 let root = unsafe { libc::getuid() } == 0;
5082 if root != (drift.start_mode == StartMode::Boot) {
5083 return Err(ServiceError::Control {
5084 operation: "rewrite",
5085 name: self.identity.name().to_string(),
5086 manager: "launchd",
5087 detail: format!(
5088 "the install record says --start-at {}, but this daemon runs as {}; the plist \
5089 is left as it is",
5090 drift.start_mode,
5091 if root { "root" } else { "the operator" }
5092 ),
5093 });
5094 }
5095 replace_definition(self.identity.name(), drift)?;
5096 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5100 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5101 let _ = record.write(&self.paths);
5102 }
5103 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5104 }
5105
5106 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5116 #[cfg(not(windows))]
5117 {
5118 let _ = (mode, &self.runner_root);
5121 Ok(RootAccessChange::not_applicable())
5122 }
5123 #[cfg(windows)]
5124 {
5125 let wrap = |source| ServiceError::RunnerRoot {
5126 source: Box::new(source),
5127 };
5128 if let Some(root) = self
5146 .runner_root
5147 .as_ref()
5148 .filter(|_| self.identity.is_fixture() || cfg!(test))
5149 {
5150 let admission = RootAdmission::of_this_account().map_err(wrap)?;
5151 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5152 .map_err(wrap);
5153 }
5154
5155 let admission = match ServiceAccount::for_start_mode(mode) {
5156 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5159 ServiceAccount::InvokingUser | ServiceAccount::Root => {
5163 RootAdmission::of_this_account().map_err(wrap)?
5164 }
5165 };
5166 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5167 }
5168 }
5169
5170 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5172 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5173 |source| match source {
5174 held @ LockError::Held { .. } => ServiceError::LockHeld {
5175 source: Box::new(held),
5176 },
5177 other => ServiceError::LockUnreadable {
5178 source: Box::new(other),
5179 },
5180 },
5181 )
5182 }
5183
5184 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5186 for mode in [StartMode::Boot, StartMode::Login] {
5187 let control = self.controls.control(mode)?;
5188 if let Some(registration) = control.query(&self.identity)? {
5189 return Ok(Some((registration.start_mode, registration)));
5190 }
5191 }
5192 Ok(None)
5193 }
5194
5195 fn registration_for_status(
5204 &self,
5205 ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5206 let Some((mode, found)) = self.find_registration()? else {
5207 return Ok(None);
5208 };
5209 let Some(directories) = self.foreign_directories(&found) else {
5210 return Ok(Some((found, None)));
5211 };
5212 let other = match mode {
5213 StartMode::Boot => StartMode::Login,
5214 StartMode::Login => StartMode::Boot,
5215 };
5216 let own = self
5217 .controls
5218 .control(other)
5219 .ok()
5220 .and_then(|control| control.query(&self.identity).ok().flatten())
5221 .filter(|registration| self.foreign_directories(registration).is_none());
5222 Ok(Some(match own {
5223 Some(own) => (own, None),
5224 None => (found, Some(directories)),
5225 }))
5226 }
5227
5228 pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5246 #[cfg(target_os = "linux")]
5247 return self.adopt_boot_installation_on_linux(mode);
5248 #[cfg(not(target_os = "linux"))]
5249 {
5250 let _ = mode;
5251 Ok(false)
5252 }
5253 }
5254
5255 #[cfg(target_os = "linux")]
5256 fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5257 if mode != StartMode::Boot {
5258 return Ok(false);
5259 }
5260 self.refuse_overridden_account()?;
5261 if !crate::host_fitness::is_elevated() {
5262 return Ok(false);
5263 }
5264 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5265 return Ok(false);
5266 };
5267 let Some(old) = self.foreign_directories(&found) else {
5268 return Ok(false);
5269 };
5270 self.controls
5271 .control(StartMode::Boot)?
5272 .stop(&self.identity)?;
5273 if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5278 let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5279 for (from, to, skip) in [
5280 (&old.config, self.paths.config_dir(), None),
5281 (&old.state, self.paths.state_dir(), Some("bin")),
5283 ] {
5284 set_aside(to, &stamp)
5285 .and_then(|()| copy_tree(from, to, skip))
5286 .map_err(|error| ServiceError::Control {
5287 operation: "move the data of",
5288 name: self.identity.name().to_string(),
5289 manager: "systemd",
5290 detail: format!(
5291 "cannot copy {} to {}: {error}",
5292 from.display(),
5293 to.display()
5294 ),
5295 })?;
5296 }
5297 }
5298 Ok(true)
5299 }
5300
5301 #[cfg(target_os = "linux")]
5308 fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5309 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5310 return Ok(());
5311 };
5312 match found.account.as_deref() {
5313 Some(account) if account != ServiceAccount::Root.as_str() => {
5314 Err(ServiceError::Control {
5315 operation: "install",
5316 name: self.identity.name().to_string(),
5317 manager: "systemd",
5318 detail: format!(
5319 "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5320 a boot service must run as root to use {}. Remove `User=` and `Group=` \
5321 from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5322 self.identity.systemd_unit(),
5323 crate::paths::SYSTEM_DATA_ROOT,
5324 ),
5325 })
5326 }
5327 _ => Ok(()),
5328 }
5329 }
5330
5331 fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5334 match self.registration_for_status()? {
5335 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5336 _ => Ok(()),
5337 }
5338 }
5339
5340 fn foreign_refusal(
5343 &self,
5344 operation: &'static str,
5345 directories: ServiceDirectories,
5346 ) -> ServiceError {
5347 let foreign = self.foreign_service_at(directories);
5348 ServiceError::ForeignInstallation {
5349 name: self.identity.name().to_string(),
5350 operation,
5351 installed_by: foreign.installed_by(),
5352 act_as: foreign.act_as(),
5353 }
5354 }
5355
5356 fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5359 ServiceDirectories::from_command_line(®istration.command_line)
5360 .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5361 }
5362
5363 #[must_use]
5368 pub fn foreign_service(&self) -> Option<ForeignService> {
5369 let (_, directories) = self.registration_for_status().ok()??;
5370 Some(self.foreign_service_at(directories?))
5371 }
5372
5373 fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5376 let root = directories.root();
5377 let home = host_home();
5378 let this_account = home
5381 .as_deref()
5382 .filter(|home| contains_directory(home, &root))
5383 .and_then(|_| {
5384 if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5385 Some(format!(
5386 "with `--data-dir {}`",
5387 quote_argument(&root.to_string_lossy())
5388 ))
5389 } else {
5390 AppPaths::discover()
5391 .ok()
5392 .filter(|standard| directories == ServiceDirectories::of(standard))
5393 .map(|_| "without `--data-dir`".to_owned())
5394 }
5395 });
5396 ForeignService {
5397 owner: data_root_owner(&root, home.as_deref()),
5398 this_account,
5399 root,
5400 directories,
5401 local_root: ServiceDirectories::of(&self.paths).root(),
5402 local_record: InstallRecord::path(&self.paths),
5403 unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5404 unreadable: None,
5405 }
5406 }
5407
5408 fn foreign_status(
5415 &self,
5416 registration: Registration,
5417 directories: ServiceDirectories,
5418 ) -> ServiceStatus {
5419 let service_paths = directories.paths();
5420 let mut foreign = self.foreign_service_at(directories);
5421 let unreadable = match InstallRecord::read(&service_paths) {
5422 Ok(Some(record)) => {
5423 match self.status_at(
5424 &service_paths,
5425 Some(record),
5426 None,
5427 Some(registration.clone()),
5428 ) {
5429 Ok(status) => return status.with_foreign(foreign),
5430 Err(error) => error.to_string(),
5431 }
5432 }
5433 Ok(None) => format!(
5434 "there is no install record at {}",
5435 InstallRecord::path(&service_paths).display()
5436 ),
5437 Err(
5442 ServiceError::RecordNotPermitted { path, detail }
5443 | ServiceError::RecordUnreadable { path, detail }
5444 | ServiceError::Record { path, detail, .. },
5445 ) => format!("this account cannot read {} ({detail})", path.display()),
5446 Err(error) => error.to_string(),
5447 };
5448 foreign.unreadable = Some(unreadable);
5449 ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5450 }
5451
5452 pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5476 let config = self.paths.config_dir();
5477 let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5478 let mut service_root = None;
5479 for mode in [StartMode::Boot, StartMode::Login] {
5480 let registration = self
5481 .controls
5482 .control(mode)
5483 .and_then(|control| control.query(&self.identity))
5484 .map_err(|error| {
5485 in_use(format!(
5486 "whether a {mode} registration runs against it could not be read ({error})"
5487 ))
5488 })?;
5489 let Some(registration) = registration else {
5490 continue;
5491 };
5492 let Some(directories) = self.foreign_directories(®istration) else {
5493 return Err(in_use(format!(
5494 "{} holds a registration that runs against {}. `service status` describes it",
5495 registration.manager,
5496 config.display()
5497 )));
5498 };
5499 if let Some(overlap) = directories.all().into_iter().find(|directory| {
5500 contains_directory(directory, config) || contains_directory(config, directory)
5501 }) {
5502 return Err(in_use(format!(
5503 "the machine's service runs against {}, which overlaps {}",
5504 overlap.display(),
5505 config.display()
5506 )));
5507 }
5508 service_root.get_or_insert_with(|| directories.root());
5509 }
5510 let _lock =
5511 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5512 in_use(format!(
5513 "an agent may be running against these directories: {source}"
5514 ))
5515 })?;
5516 let failed = |operation: &'static str, path: &Path| {
5517 let path = path.to_path_buf();
5518 move |error: std::io::Error| ServiceError::Record {
5519 operation,
5520 path,
5521 detail: error.to_string(),
5522 }
5523 };
5524 let kept = [
5526 self.paths.state_dir(),
5527 self.paths.runtime_dir(),
5528 self.paths.logs_dir(),
5529 ];
5530 let mut entries = Vec::new();
5531 for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5532 let entry = entry.map_err(failed("list", config))?.path();
5533 let archived = entry
5534 .file_name()
5535 .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5536 if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5537 entries.push(entry);
5538 }
5539 }
5540 if entries.is_empty() {
5541 return Ok(ForgottenLocal {
5542 archive: None,
5543 moved: entries,
5544 service_root,
5545 });
5546 }
5547 entries.sort();
5548 let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5549 std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5550 let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5551 for (index, entry) in entries.iter().enumerate() {
5552 if let Err(error) = std::fs::rename(entry, inside(entry)) {
5553 let stranded: Vec<String> = entries[..index]
5554 .iter()
5555 .rev()
5556 .filter(|done| std::fs::rename(inside(done), done).is_err())
5557 .map(|done| inside(done).display().to_string())
5558 .collect();
5559 let _ = std::fs::remove_dir(&archive);
5560 let mut detail = error.to_string();
5561 if !stranded.is_empty() {
5562 detail.push_str(&format!(
5563 "; these could not be put back and are still in the archive: {}",
5564 stranded.join(", ")
5565 ));
5566 }
5567 return Err(ServiceError::Record {
5568 operation: "archive",
5569 path: entry.clone(),
5570 detail,
5571 });
5572 }
5573 }
5574 Ok(ForgottenLocal {
5575 archive: Some(archive),
5576 moved: entries,
5577 service_root,
5578 })
5579 }
5580}
5581
5582pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5585
5586#[derive(Debug, Clone, PartialEq, Eq)]
5588pub struct ForgottenLocal {
5589 pub archive: Option<PathBuf>,
5591 pub moved: Vec<PathBuf>,
5593 pub service_root: Option<PathBuf>,
5596}
5597
5598impl fmt::Display for ForgottenLocal {
5599 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5600 let Some(archive) = &self.archive else {
5601 return write!(
5602 f,
5603 "Nothing to archive: this account's configuration directory is empty."
5604 );
5605 };
5606 writeln!(f, "Archived this account's unused local configuration")?;
5607 writeln!(f, " archive {}", archive.display())?;
5608 for path in &self.moved {
5609 writeln!(f, " moved {}", path.display())?;
5610 }
5611 if let Some(root) = &self.service_root {
5612 writeln!(
5613 f,
5614 " service data root {} (untouched)",
5615 root.display()
5616 )?;
5617 }
5618 write!(
5619 f,
5620 " restore move everything in the archive back into its parent \
5621 directory"
5622 )
5623 }
5624}
5625
5626fn same_directory(left: &Path, right: &Path) -> bool {
5629 let text = |path: &Path| {
5630 path.to_string_lossy()
5631 .trim_end_matches(['/', '\\'])
5632 .to_owned()
5633 };
5634 same_path_text(&text(left), &text(right))
5635 || matches!(
5636 (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5637 (Ok(left), Ok(right)) if left == right
5638 )
5639}
5640
5641const REMEDY_OPENINGS: [&str; 8] = [
5644 ". Run ",
5645 ". If nothing is waiting",
5646 ". Re-register",
5647 ". Switch the start mode",
5648 " Sign in again",
5649 ". Fix: ",
5650 "; to do it now",
5651 "; if the service manager denies",
5652];
5653
5654fn diagnosis_only(detail: &str) -> String {
5663 let cut = REMEDY_OPENINGS
5664 .iter()
5665 .filter_map(|opening| detail.find(opening))
5666 .min()
5667 .unwrap_or(detail.len());
5668 let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5669 let names_a_change = |text: &str| {
5670 [
5671 "service install",
5672 "service uninstall",
5673 "auth login",
5674 "launchctl",
5675 "systemctl",
5676 ]
5677 .iter()
5678 .any(|command| text.contains(command))
5679 };
5680 if !names_a_change(diagnosis) {
5681 return diagnosis.to_owned();
5682 }
5683 let end = [", ", "; ", ". "]
5685 .iter()
5686 .filter_map(|separator| diagnosis.find(separator))
5687 .min()
5688 .unwrap_or(diagnosis.len());
5689 let first = &diagnosis[..end];
5690 if names_a_change(first) {
5691 "the installation's own records report this".to_owned()
5692 } else {
5693 first.to_owned()
5694 }
5695}
5696
5697fn path_key(path: &Path) -> Vec<String> {
5700 path.components()
5701 .map(|component| {
5702 let text = component.as_os_str().to_string_lossy();
5703 if cfg!(windows) {
5704 text.to_lowercase()
5705 } else {
5706 text.into_owned()
5707 }
5708 })
5709 .collect()
5710}
5711
5712fn contains_directory(outer: &Path, inner: &Path) -> bool {
5715 path_key(inner).starts_with(&path_key(outer))
5716 || matches!(
5717 (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5718 (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5719 )
5720}
5721
5722#[must_use]
5727pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5728 let target = path_key(root);
5729 if cfg!(windows) {
5730 if target
5731 .windows(3)
5732 .any(|run| run == ["system32", "config", "systemprofile"])
5733 {
5734 return Some("LocalSystem".to_owned());
5735 }
5736 } else if [
5737 "/root",
5738 "/var/root",
5739 "/private/var/root",
5740 crate::paths::SYSTEM_DATA_ROOT,
5741 ]
5742 .iter()
5743 .any(|home| root.starts_with(home))
5744 {
5745 return Some("root".to_owned());
5746 }
5747 let users = path_key(home?.parent()?);
5748 if users.len() < 2 {
5751 return None;
5752 }
5753 if target.len() > users.len() && target[..users.len()] == users[..] {
5754 root.components()
5755 .nth(users.len())
5756 .map(|name| name.as_os_str().to_string_lossy().into_owned())
5757 } else {
5758 None
5759 }
5760}
5761
5762#[derive(Debug, Clone, PartialEq, Eq)]
5776pub struct ForeignService {
5777 pub directories: ServiceDirectories,
5779 pub root: PathBuf,
5781 pub owner: Option<String>,
5783 pub this_account: Option<String>,
5788 pub local_root: PathBuf,
5790 pub local_record: PathBuf,
5792 pub unused_local_record: Option<InstallRecord>,
5794 pub unreadable: Option<String>,
5797}
5798
5799impl ForeignService {
5800 #[must_use]
5802 pub fn where_to_look(&self) -> String {
5803 if let Some(reach) = &self.this_account {
5804 return reach.clone();
5805 }
5806 let elevated = if cfg!(windows) {
5807 "from an elevated terminal (Run as administrator)"
5808 } else {
5809 "with sudo"
5810 };
5811 match &self.owner {
5812 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5813 format!("{elevated}, or signed in as {owner}")
5814 }
5815 _ => elevated.to_owned(),
5816 }
5817 }
5818
5819 #[must_use]
5824 pub fn act_as(&self) -> String {
5825 if let Some(reach) = &self.this_account {
5826 return reach.clone();
5827 }
5828 match &self.owner {
5829 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5830 format!("signed in as {owner}")
5831 }
5832 _ => format!(
5833 "from the account whose data root {} is",
5834 self.root.display()
5835 ),
5836 }
5837 }
5838
5839 #[must_use]
5842 pub const fn is_unread(&self) -> bool {
5843 self.unreadable.is_some()
5844 }
5845
5846 #[must_use]
5848 pub fn installed_by(&self) -> String {
5849 match &self.owner {
5850 Some(owner) => format!("{owner}'s data root {}", self.root.display()),
5851 None => format!("the data root {}", self.root.display()),
5852 }
5853 }
5854
5855 #[must_use]
5857 pub fn label(&self) -> String {
5858 match &self.owner {
5859 Some(owner) => format!("{} ({owner})", self.root.display()),
5860 None => self.root.display().to_string(),
5861 }
5862 }
5863
5864 #[must_use]
5866 pub fn unused_local_note(&self) -> Option<String> {
5867 let record = self.unused_local_record.as_ref()?;
5868 Some(format!(
5869 "unused local configuration from an earlier install: {} records a {} registration \
5870 made by runner-manager {} on {}, and no service on this machine runs against it. \
5871 `runner-manager host forget-local` moves it aside; it never touches the service's \
5872 own directories or any secret.",
5873 self.local_record.display(),
5874 record.start_mode,
5875 record.installed_by_version,
5876 record.installed_at.format("%Y-%m-%d"),
5877 ))
5878 }
5879}
5880
5881#[derive(Debug, Clone, PartialEq, Eq)]
5887pub struct StatusProblem {
5888 pub subject: &'static str,
5890 pub detail: String,
5892}
5893
5894impl fmt::Display for StatusProblem {
5895 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5896 write!(f, "{}: {}", self.subject, self.detail)
5897 }
5898}
5899
5900#[derive(Debug, Clone)]
5909pub struct ServiceStatus {
5910 identity: ServiceIdentity,
5911 record: Option<InstallRecord>,
5912 registration: Option<Registration>,
5913 binary: Option<BinaryPath>,
5914 log_file: PathBuf,
5915 store: Option<crate::secrets::ActiveStore>,
5916 last_github_contact: Option<DateTime<Utc>>,
5917 runner_root: Option<(PathBuf, RootAccessReport)>,
5918 credential_rejected_since: Option<DateTime<Utc>>,
5919 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
5920 unattended_gap: Option<UnattendedGap>,
5921 foreign: Option<ForeignService>,
5922 problems: Vec<StatusProblem>,
5923 notes: Vec<String>,
5924}
5925
5926#[derive(Debug, Clone, PartialEq, Eq)]
5929pub struct UnattendedGap {
5930 pub reason: String,
5932 pub remedy: String,
5934}
5935
5936#[must_use]
5939pub fn service_account_name(account: &ServiceAccount) -> String {
5940 match account {
5941 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
5943 .ok()
5944 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
5945 .or_else(crate::unattended_login::current_account)
5946 .unwrap_or_else(|| "the operator".to_owned()),
5947 other => other.as_str().to_owned(),
5948 }
5949}
5950
5951fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
5954 registration
5955 .and_then(|registration| registration.account.as_deref())
5956 .unwrap_or(recorded)
5957}
5958
5959#[cfg(any(windows, test))]
5963fn windows_service_account(name: &str) -> String {
5964 if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
5965 ServiceAccount::LocalSystem.as_str().to_string()
5966 } else {
5967 name.to_string()
5968 }
5969}
5970
5971#[cfg(any(windows, test))]
5974fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
5975 user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
5976}
5977
5978#[must_use]
5987pub fn unattended_gap(
5988 mode: StartMode,
5989 account: &str,
5990 found: Option<&crate::unattended_login::UnattendedLogin>,
5991) -> Option<UnattendedGap> {
5992 let Some(found) = found else {
5993 return (mode == StartMode::Login).then(|| UnattendedGap {
5994 reason: "This registration starts at login, so the agent does not run until the \
5995 operator signs in; this host does not resume work after an unattended \
5996 reboot."
5997 .to_owned(),
5998 remedy: "For machine-on operation, move the service and credential to `--start-at \
5999 boot` from an elevated terminal."
6000 .to_owned(),
6001 });
6002 };
6003 let resume = crate::unattended_login::resume(found, mode, account);
6004 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6005 return None;
6008 }
6009 Some(UnattendedGap {
6010 reason: resume.explain(account)?,
6011 remedy: resume.remedy().unwrap_or_else(|| {
6012 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6013 FileVault can be read."
6014 .to_owned()
6015 }),
6016 })
6017}
6018
6019impl ServiceStatus {
6020 fn compose(
6025 identity: ServiceIdentity,
6026 record: Option<InstallRecord>,
6027 record_refused: Option<&ServiceError>,
6028 registration: Option<Registration>,
6029 last_github_contact: Option<DateTime<Utc>>,
6030 paths: &AppPaths,
6031 ) -> Self {
6032 let mut problems = Vec::new();
6033 let mut notes = Vec::new();
6034
6035 if let Some(refusal) = record_refused {
6036 problems.push(StatusProblem {
6041 subject: "install record",
6042 detail: refusal.to_string(),
6043 });
6044 }
6045
6046 let log_file = record.as_ref().map_or_else(
6047 || ServiceDirectories::of(paths).log_file(),
6048 |record| record.log_file.clone(),
6049 );
6050
6051 let binary = record.as_ref().map(|record| {
6052 inspect_binary(
6053 &record.binary,
6054 registration
6055 .as_ref()
6056 .and_then(Registration::binary)
6057 .as_deref(),
6058 )
6059 });
6060 if let Some(state) = &binary
6061 && state.is_error()
6062 {
6063 problems.push(StatusProblem {
6064 subject: "binary",
6065 detail: state.to_string(),
6066 });
6067 }
6068
6069 match (&record, ®istration) {
6070 (Some(_), None) => problems.push(StatusProblem {
6071 subject: "registration",
6072 detail: "this host has a service record but no service manager knows the \
6073 registration. Run `service install` again; it deletes nothing."
6074 .to_string(),
6075 }),
6076 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6081 subject: "record",
6082 detail: format!(
6083 "{} holds a registration for this service but there is no install record, so \
6084 the path it was installed from and the directories it was installed against \
6085 are unknown. Run `service uninstall` and `service install`.",
6086 found.manager
6087 ),
6088 }),
6089 (None, Some(_)) => {}
6090 (Some(record), Some(found)) => {
6091 if record.start_mode != found.start_mode {
6092 problems.push(StatusProblem {
6093 subject: "start mode",
6094 detail: format!(
6095 "the record says {} and {} holds a {} registration. Switch the start \
6096 mode again to make them agree.",
6097 record.start_mode, found.manager, found.start_mode
6098 ),
6099 });
6100 }
6101 if record.start_mode == StartMode::Boot && !found.starts_automatically {
6102 problems.push(StatusProblem {
6103 subject: "start mode",
6104 detail: format!(
6105 "{} holds the registration but will not start it by itself, so this \
6106 host does not resume work after a reboot.",
6107 found.manager
6108 ),
6109 });
6110 }
6111 if let Some(actual) = found.restart_delay {
6112 let expected = record.restart().effective_delay(found.manager);
6113 if actual != expected {
6114 problems.push(StatusProblem {
6115 subject: "restart policy",
6116 detail: format!(
6117 "the record says the service restarts after {}s and {} reports \
6118 {}s. Something has edited the registration since it was \
6119 installed.",
6120 expected.as_secs(),
6121 found.manager,
6122 actual.as_secs()
6123 ),
6124 });
6125 } else if expected != record.restart().delay() {
6126 notes.push(format!(
6131 "{} expresses the restart delay in whole minutes, so the {}s asked \
6132 for is enforced as {}s. The service therefore never restarts faster \
6133 than the configured bound.",
6134 found.manager,
6135 record.restart().delay().as_secs(),
6136 expected.as_secs()
6137 ));
6138 }
6139 }
6140 if found.starts_automatically && !found.running {
6141 problems.push(StatusProblem {
6142 subject: "runtime",
6143 detail: format!(
6144 "{} holds an automatic registration, but the daemon is stopped. Run \
6145 `runner-manager service install --start-at {}`; if the service \
6146 manager denies replacement, retry from an elevated terminal. If it \
6147 stops again, inspect {}.",
6148 found.manager,
6149 found.start_mode,
6150 log_file.display()
6151 ),
6152 });
6153 }
6154 }
6155 (None, None) => {}
6156 }
6157
6158 let store = record.as_ref().and_then(|record| {
6163 let registered_mode = registration
6164 .as_ref()
6165 .map_or(record.start_mode, |found| found.start_mode);
6166 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6167 .ok()
6168 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6169 });
6170 if let Some(store) = &store
6171 && !store.agrees_with_start_mode()
6172 {
6173 problems.push(StatusProblem {
6174 subject: "secret store",
6175 detail: format!(
6176 "{store}. Run `auth login` again so the token is stored where the registered \
6177 start mode can read it."
6178 ),
6179 });
6180 }
6181
6182 if last_github_contact.is_none() {
6183 notes.push(
6184 "GitHub has not been reached successfully since this host's state directory was \
6185 created."
6186 .to_string(),
6187 );
6188 }
6189
6190 let runner_root = crate::runner_root::default_runner_root(paths)
6197 .ok()
6198 .map(|root| {
6199 let path = root.as_path().to_path_buf();
6200 let report = crate::runner_root_access::report(&path);
6201 (path, report)
6202 });
6203 if let Some((
6213 path,
6214 RootAccessReport::Present {
6215 broad_write: true, ..
6216 },
6217 )) = &runner_root
6218 {
6219 notes.push(format!(
6220 "the platform default runner root {} can be written by ordinary local users, so \
6221 it is not a safe place to run jobs. `service install` refuses it rather than \
6222 tightening it, because the contents of a directory anybody could write cannot \
6223 be trusted: remove or empty it, or choose another root with `runner-manager \
6224 host set-runtime-root --path <PATH>`.",
6225 path.display()
6226 ));
6227 }
6228
6229 match runner_root_refusals(paths) {
6252 Ok(refusals) => {
6253 for refusal in refusals {
6254 notes.push(format!(
6255 "policy {} started no runner: its runner root {} refused the launch \
6256 ({}), last at {}. {} This clears when that policy next places a \
6257 runner.",
6258 refusal.policy,
6259 refusal.root,
6260 refusal.kind,
6261 refusal.at.to_rfc3339(),
6262 refusal.detail,
6263 ));
6264 }
6265 }
6266 Err(error) => notes.push(format!(
6267 "whether the agent could use its runner roots could not be read: {error}"
6268 )),
6269 }
6270
6271 Self {
6272 identity,
6273 record,
6274 registration,
6275 binary,
6276 log_file,
6277 store,
6278 last_github_contact,
6279 runner_root,
6280 credential_rejected_since: None,
6281 launches_blocked: None,
6282 unattended_gap: None,
6283 foreign: None,
6284 problems,
6285 notes,
6286 }
6287 }
6288
6289 fn with_foreign(mut self, foreign: ForeignService) -> Self {
6300 let act_as = foreign.act_as();
6301 for problem in &mut self.problems {
6302 problem.detail = format!(
6303 "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6304 own directories, not the service's.",
6305 diagnosis_only(&problem.detail)
6306 );
6307 }
6308 if let Some(note) = foreign.unused_local_note() {
6309 self.notes.push(note);
6310 }
6311 self.foreign = Some(foreign);
6312 self
6313 }
6314
6315 fn foreign_unread(
6323 identity: ServiceIdentity,
6324 registration: Registration,
6325 foreign: ForeignService,
6326 ) -> Self {
6327 let mut problems = Vec::new();
6328 let look = foreign.where_to_look();
6329 if registration.starts_automatically && !registration.running {
6330 problems.push(StatusProblem {
6331 subject: "runtime",
6332 detail: format!(
6333 "{} holds an automatic registration for the service installed from {}, but \
6334 it is stopped. Run `runner-manager service status` {look} to see why; do not \
6335 reinstall it from this account.",
6336 registration.manager,
6337 foreign.installed_by(),
6338 ),
6339 });
6340 }
6341 if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6342 problems.push(StatusProblem {
6343 subject: "start mode",
6344 detail: format!(
6345 "{} holds the boot registration installed from {} but will not start it by \
6346 itself. Run `runner-manager service status` {look}.",
6347 registration.manager,
6348 foreign.installed_by(),
6349 ),
6350 });
6351 }
6352 let mut notes = Vec::new();
6353 if let Some(reason) = &foreign.unreadable {
6354 notes.push(format!(
6355 "its health is not known here: {reason}. Run `runner-manager service status` \
6356 {look} to see it."
6357 ));
6358 }
6359 notes.extend(foreign.unused_local_note());
6360 Self {
6361 identity,
6362 record: None,
6363 log_file: foreign.directories.log_file(),
6364 registration: Some(registration),
6365 binary: None,
6366 store: None,
6367 last_github_contact: None,
6368 runner_root: None,
6369 credential_rejected_since: None,
6370 launches_blocked: None,
6371 unattended_gap: None,
6372 foreign: Some(foreign),
6373 problems,
6374 notes,
6375 }
6376 }
6377
6378 #[must_use]
6381 pub const fn foreign(&self) -> Option<&ForeignService> {
6382 self.foreign.as_ref()
6383 }
6384
6385 #[must_use]
6388 pub fn is_unread(&self) -> bool {
6389 self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6390 }
6391
6392 #[must_use]
6394 pub fn verdict(&self) -> String {
6395 if !self.is_healthy() {
6396 return "NOT healthy".to_owned();
6397 }
6398 match &self.foreign {
6399 Some(foreign) if foreign.is_unread() => format!(
6400 "{}; installed from {}, so its health is read {}",
6401 if self.is_running() {
6402 "running"
6403 } else {
6404 "not running"
6405 },
6406 foreign.installed_by(),
6407 foreign.where_to_look()
6408 ),
6409 _ => "healthy".to_owned(),
6410 }
6411 }
6412
6413 #[must_use]
6415 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6416 if let Some(drift) = drift {
6417 if drift.manager == DefinitionKind::LaunchdPlist {
6418 let restart = match drift.start_mode {
6422 StartMode::Boot => format!(
6423 "sudo launchctl kickstart -k system/{}",
6424 self.identity.launchd_label()
6425 ),
6426 StartMode::Login => format!(
6427 "launchctl kickstart -k gui/$(id -u)/{}",
6428 self.identity.launchd_label()
6429 ),
6430 };
6431 self.problems.push(StatusProblem {
6432 subject: "definition",
6433 detail: format!(
6434 "{} was written by runner-manager {} with a `ProcessType` other than \
6435 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6436 daemon starts, so their jobs run below normal priority. The daemon \
6437 rewrites it and reloads itself when it starts with no runner, which it \
6438 does after every upgrade; to do it now, restart the service while no \
6439 job is running: `{restart}`. Configuration and credentials are kept.",
6440 drift.path.display(),
6441 drift.installed_by_version,
6442 ),
6443 });
6444 return self;
6445 }
6446 let repair = match drift.start_mode {
6447 StartMode::Boot => format!(
6448 "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6449 `runner-manager wsl install --distribution <name>` from Windows)",
6450 sudo_command("service install --start-at boot")
6451 ),
6452 StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6453 service install --start-at login`"
6454 .to_owned(),
6455 };
6456 self.problems.push(StatusProblem {
6457 subject: "definition",
6458 detail: format!(
6459 "{} was written by runner-manager {} and differs from what {} renders, so \
6460 the daemon runs under outdated settings (for example a credential frozen \
6461 at service start). Re-register it with {repair}; configuration and \
6462 credentials are kept.",
6463 drift.path.display(),
6464 drift.installed_by_version,
6465 env!("CARGO_PKG_VERSION"),
6466 ),
6467 });
6468 }
6469 self
6470 }
6471
6472 #[must_use]
6474 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6475 if let Some(since) = since {
6476 self.credential_rejected_since = Some(since);
6477 self.problems.push(StatusProblem {
6478 subject: "github credential",
6479 detail: format!(
6480 "GitHub has rejected the service's credential since {}, so the service \
6481 cannot see queued jobs or start runners. Run `runner-manager auth login` \
6482 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6483 from Windows).",
6484 since.to_rfc3339()
6485 ),
6486 });
6487 }
6488 self
6489 }
6490
6491 #[must_use]
6493 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6494 self.credential_rejected_since
6495 }
6496
6497 #[must_use]
6500 pub fn with_launches_blocked(
6501 mut self,
6502 blocked: Option<crate::launch_health::LaunchesBlocked>,
6503 ) -> Self {
6504 if let Some(blocked) = &blocked {
6505 self.problems.push(StatusProblem {
6506 subject: LAUNCHES_BLOCKED_SUBJECT,
6507 detail: format!(
6508 "the service has started no runner {blocked}. Fix: {}.",
6509 blocked.remedy
6510 ),
6511 });
6512 }
6513 self.launches_blocked = blocked;
6514 self
6515 }
6516
6517 #[must_use]
6519 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6520 self.launches_blocked.as_ref()
6521 }
6522
6523 #[must_use]
6526 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6527 if let Some(since) = since {
6528 let mode = self.start_mode().unwrap_or_default();
6529 self.problems.push(StatusProblem {
6530 subject: CREDENTIAL_UNREADABLE_SUBJECT,
6531 detail: format!(
6532 "the service has been unable to read its stored GitHub credential since {}, \
6533 so it starts no runner; the diagnostic log has the store's own answer.{} \
6534 Sign in again: {}.",
6535 since.to_rfc3339(),
6536 if cfg!(target_os = "macos") {
6537 " That is what a keychain item written by a different build looks like."
6538 } else {
6539 ""
6540 },
6541 sign_in_instruction(mode)
6542 ),
6543 });
6544 }
6545 self
6546 }
6547
6548 #[must_use]
6556 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6557 self.runner_root
6558 .as_ref()
6559 .map(|(path, report)| (path.as_path(), report))
6560 }
6561
6562 #[must_use]
6564 pub const fn is_installed(&self) -> bool {
6565 self.registration.is_some() || self.record.is_some()
6566 }
6567
6568 #[must_use]
6570 pub fn is_running(&self) -> bool {
6571 self.registration
6572 .as_ref()
6573 .is_some_and(|registration| registration.running)
6574 }
6575
6576 #[must_use]
6578 pub fn is_healthy(&self) -> bool {
6579 self.problems.is_empty()
6580 }
6581
6582 #[must_use]
6584 pub fn problems(&self) -> &[StatusProblem] {
6585 &self.problems
6586 }
6587
6588 #[must_use]
6591 pub fn notes(&self) -> &[String] {
6592 &self.notes
6593 }
6594
6595 #[must_use]
6600 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6601 if let Some(unfit) = unfit {
6602 self.problems.push(StatusProblem {
6603 subject: HOST_UNFIT_SUBJECT,
6604 detail: format!(
6605 "the service has started no runner since {}, because a required host check \
6606 fails from its own session: {}",
6607 unfit.since.to_rfc3339(),
6608 unfit.describe()
6609 ),
6610 });
6611 }
6612 self
6613 }
6614
6615 #[must_use]
6619 pub fn with_stall(mut self, stall: Option<String>) -> Self {
6620 if let Some(detail) = stall {
6621 self.problems.push(StatusProblem {
6622 subject: DAEMON_STALLED_SUBJECT,
6623 detail,
6624 });
6625 }
6626 self
6627 }
6628
6629 #[must_use]
6632 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6633 if let Some(gap) = &gap {
6634 self.notes.push(gap.reason.clone());
6635 }
6636 self.unattended_gap = gap;
6637 self
6638 }
6639
6640 #[must_use]
6643 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6644 self.unattended_gap.as_ref()
6645 }
6646
6647 #[must_use]
6650 pub fn restart_command(&self) -> String {
6651 self.identity
6652 .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6653 }
6654
6655 #[must_use]
6659 pub fn start_mode(&self) -> Option<StartMode> {
6660 self.record
6661 .as_ref()
6662 .map(|record| record.start_mode)
6663 .or_else(|| {
6664 self.registration
6665 .as_ref()
6666 .filter(|_| self.is_unread())
6667 .map(|registration| registration.start_mode)
6668 })
6669 }
6670
6671 #[must_use]
6675 pub fn service_binary(&self) -> Option<PathBuf> {
6676 self.record
6677 .as_ref()
6678 .map(|record| record.binary.clone())
6679 .or_else(|| {
6680 self.registration
6681 .as_ref()
6682 .filter(|_| self.is_unread())
6683 .and_then(Registration::binary)
6684 })
6685 }
6686
6687 #[must_use]
6690 pub const fn binary(&self) -> Option<&BinaryPath> {
6691 self.binary.as_ref()
6692 }
6693
6694 #[must_use]
6696 pub fn log_file(&self) -> &Path {
6697 &self.log_file
6698 }
6699
6700 #[must_use]
6702 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6703 self.last_github_contact
6704 }
6705
6706 #[must_use]
6709 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6710 self.store.as_ref()
6711 }
6712
6713 #[must_use]
6715 pub const fn record(&self) -> Option<&InstallRecord> {
6716 self.record.as_ref()
6717 }
6718
6719 #[must_use]
6721 pub const fn registration(&self) -> Option<&Registration> {
6722 self.registration.as_ref()
6723 }
6724}
6725
6726impl fmt::Display for ServiceStatus {
6727 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6728 writeln!(f, "Service: {}", self.identity)?;
6729 match (&self.record, &self.registration) {
6730 (None, None) => {
6731 writeln!(
6732 f,
6733 " installed no. `service install` registers `{} {}`.",
6734 SERVICE_NAME,
6735 DAEMON_ARGUMENTS.join(" ")
6736 )?;
6737 }
6738 _ => {
6739 let manager = self
6740 .registration
6741 .as_ref()
6742 .map(|registration| registration.manager.manager());
6743 writeln!(
6744 f,
6745 " installed {}",
6746 manager.unwrap_or("yes, but no service manager knows it")
6747 )?;
6748 writeln!(
6749 f,
6750 " state {}",
6751 if self.is_running() {
6752 "running"
6753 } else {
6754 "not running"
6755 }
6756 )?;
6757 }
6758 }
6759 if let Some(foreign) = &self.foreign {
6760 writeln!(
6761 f,
6762 " installed from {} -- another installation's, not this account's ({})",
6763 foreign.installed_by(),
6764 foreign.local_root.display()
6765 )?;
6766 if foreign.is_unread()
6768 && let Some(registration) = &self.registration
6769 {
6770 writeln!(f, " start mode {}", registration.start_mode)?;
6771 if let Some(account) = ®istration.account {
6772 writeln!(f, " account {account}")?;
6773 }
6774 if let Some(binary) = self.service_binary() {
6775 writeln!(f, " binary {}", binary.display())?;
6776 }
6777 }
6778 }
6779 if let Some(record) = &self.record {
6780 writeln!(f, " start mode {}", record.start_mode)?;
6781 let recorded = record.account.to_string();
6782 let account = registered_account(self.registration.as_ref(), &recorded);
6783 writeln!(f, " account {account}")?;
6784 writeln!(f, " restart on failure {}", record.restart())?;
6785 writeln!(
6786 f,
6787 " arguments {}",
6788 record.arguments.join(" ")
6789 )?;
6790 }
6791 if let Some(binary) = &self.binary {
6792 writeln!(f, " binary {binary}")?;
6793 }
6794 writeln!(f, " diagnostic log {}", self.log_file.display())?;
6795 if let Some((path, report)) = &self.runner_root {
6796 writeln!(f, " default runner root {}", path.display())?;
6803 if *report != RootAccessReport::NotApplicable {
6804 writeln!(f, " default root access {report}")?;
6805 }
6806 }
6807 if let Some(store) = &self.store {
6808 writeln!(f, " secret store {store}")?;
6809 }
6810 if !self.is_unread() {
6813 writeln!(
6814 f,
6815 " last GitHub contact {}",
6816 match self.last_github_contact {
6817 Some(at) => at.to_rfc3339(),
6818 None => "never".to_string(),
6819 }
6820 )?;
6821 }
6822 for note in &self.notes {
6823 writeln!(f, " note {note}")?;
6824 }
6825 for problem in &self.problems {
6826 writeln!(f, " ERROR {problem}")?;
6827 }
6828 write!(f, " verdict {}", self.verdict())
6829 }
6830}
6831
6832#[derive(Debug, Clone, Default)]
6849pub struct RecordingControls {
6850 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6851}
6852
6853#[derive(Debug, Default)]
6854struct RecordingState {
6855 registrations: BTreeMap<(StartMode, String), Registration>,
6856 definitions: BTreeMap<String, ServiceDefinition>,
6857 calls: Vec<String>,
6858 #[cfg(test)]
6859 install_failures: BTreeMap<StartMode, String>,
6860 #[cfg(test)]
6861 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
6862}
6863
6864#[cfg(test)]
6865#[derive(Debug, Clone)]
6866enum TestInstallSideEffect {
6867 HideDirectory { directory: PathBuf, hidden: PathBuf },
6868}
6869
6870impl RecordingControls {
6871 #[must_use]
6873 pub fn new() -> Self {
6874 Self::default()
6875 }
6876
6877 #[must_use]
6880 pub fn calls(&self) -> Vec<String> {
6881 self.state.lock().expect("not poisoned").calls.clone()
6882 }
6883
6884 #[must_use]
6886 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
6887 self.state
6888 .lock()
6889 .expect("not poisoned")
6890 .registrations
6891 .iter()
6892 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
6893 .collect()
6894 }
6895
6896 #[must_use]
6898 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
6899 self.state
6900 .lock()
6901 .expect("not poisoned")
6902 .definitions
6903 .get(name)
6904 .cloned()
6905 }
6906
6907 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
6917 let mut state = self.state.lock().expect("not poisoned");
6918 if let Some((_, registration)) = state
6919 .registrations
6920 .iter_mut()
6921 .find(|((_, held), _)| held == name)
6922 {
6923 edit(registration);
6924 }
6925 }
6926
6927 #[cfg(test)]
6928 fn fail_next_install(&self, mode: StartMode, detail: &str) {
6929 self.state
6930 .lock()
6931 .expect("not poisoned")
6932 .install_failures
6933 .insert(mode, detail.to_string());
6934 }
6935
6936 #[cfg(test)]
6937 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
6938 self.state
6939 .lock()
6940 .expect("not poisoned")
6941 .after_install
6942 .insert(
6943 mode,
6944 TestInstallSideEffect::HideDirectory { directory, hidden },
6945 );
6946 }
6947}
6948
6949impl ControlFactory for RecordingControls {
6950 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6951 Ok(Box::new(RecordingControl {
6952 mode,
6953 state: std::sync::Arc::clone(&self.state),
6954 }))
6955 }
6956}
6957
6958#[derive(Debug)]
6959struct RecordingControl {
6960 mode: StartMode,
6961 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6962}
6963
6964impl RecordingControl {
6965 fn note(&self, operation: &str, name: &str) {
6966 self.state
6967 .lock()
6968 .expect("not poisoned")
6969 .calls
6970 .push(format!("{operation} {name} ({})", self.mode));
6971 }
6972}
6973
6974impl ServiceControl for RecordingControl {
6975 fn manager(&self) -> DefinitionKind {
6976 host_definition_kind(self.mode)
6980 }
6981
6982 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6983 self.note("install", plan.identity().name());
6984 #[cfg(test)]
6985 if let Some(detail) = self
6986 .state
6987 .lock()
6988 .expect("not poisoned")
6989 .install_failures
6990 .remove(&self.mode)
6991 {
6992 return Err(ServiceError::Control {
6993 operation: "install",
6994 name: plan.identity().name().to_string(),
6995 manager: "recording control",
6996 detail,
6997 });
6998 }
6999 let definition = ServiceDefinition::for_host(plan)?;
7004 let mut state = self.state.lock().expect("not poisoned");
7005 state.registrations.insert(
7006 (self.mode, plan.identity().name().to_string()),
7007 Registration {
7008 manager: host_definition_kind(self.mode),
7009 start_mode: self.mode,
7010 command_line: plan.command_line(),
7011 account: (*plan.account() != ServiceAccount::InvokingUser)
7012 .then(|| plan.account().as_str().to_string()),
7013 running: false,
7014 starts_automatically: true,
7015 restart_delay: Some(plan.restart().delay()),
7016 },
7017 );
7018 state
7019 .definitions
7020 .insert(plan.identity().name().to_string(), definition.clone());
7021 #[cfg(test)]
7022 let side_effect = state.after_install.remove(&self.mode);
7023 drop(state);
7024 #[cfg(test)]
7025 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7026 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7027 std::fs::write(&directory, b"blocks recreation")
7028 .expect("test fault can block record directory recreation");
7029 }
7030 Ok(definition)
7031 }
7032
7033 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7034 self.note("uninstall", identity.name());
7035 let mut state = self.state.lock().expect("not poisoned");
7036 state.definitions.remove(identity.name());
7037 Ok(state
7038 .registrations
7039 .remove(&(self.mode, identity.name().to_string()))
7040 .is_some())
7041 }
7042
7043 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7044 self.note("query", identity.name());
7045 Ok(self
7046 .state
7047 .lock()
7048 .expect("not poisoned")
7049 .registrations
7050 .get(&(self.mode, identity.name().to_string()))
7051 .cloned())
7052 }
7053
7054 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7055 self.note("start", identity.name());
7056 let mut state = self.state.lock().expect("not poisoned");
7057 match state
7058 .registrations
7059 .get_mut(&(self.mode, identity.name().to_string()))
7060 {
7061 Some(registration) => {
7062 registration.running = true;
7063 Ok(())
7064 }
7065 None => Err(ServiceError::NotInstalled {
7066 name: identity.name().to_string(),
7067 operation: "start",
7068 }),
7069 }
7070 }
7071
7072 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7073 self.note("stop", identity.name());
7074 let mut state = self.state.lock().expect("not poisoned");
7075 match state
7076 .registrations
7077 .get_mut(&(self.mode, identity.name().to_string()))
7078 {
7079 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7080 None => Err(ServiceError::NotInstalled {
7081 name: identity.name().to_string(),
7082 operation: "stop",
7083 }),
7084 }
7085 }
7086}
7087
7088impl ControlFactory for HostControls {
7089 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7090 sys::control(mode)
7091 }
7092
7093 fn installed_definition(&self, path: &Path) -> Option<String> {
7094 std::fs::read_to_string(path).ok()
7095 }
7096
7097 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7098 crate::unattended_login::probe()
7099 }
7100}
7101
7102#[must_use]
7109pub fn host_home() -> Option<PathBuf> {
7110 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7111}
7112
7113#[cfg(unix)]
7115fn home_directory() -> Option<PathBuf> {
7116 host_home()
7117}
7118
7119fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7128 let output = std::process::Command::new(program)
7129 .args(arguments)
7130 .output()?;
7131 Ok((
7132 output.status.success(),
7133 String::from_utf8_lossy(&output.stdout).into_owned(),
7134 String::from_utf8_lossy(&output.stderr).into_owned(),
7135 ))
7136}
7137
7138#[must_use]
7141pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7142 if cfg!(windows) {
7143 match mode {
7144 StartMode::Boot => DefinitionKind::WindowsService,
7145 StartMode::Login => DefinitionKind::WindowsScheduledTask,
7146 }
7147 } else if cfg!(target_os = "macos") {
7148 DefinitionKind::LaunchdPlist
7149 } else {
7150 DefinitionKind::SystemdUnit
7151 }
7152}
7153
7154#[cfg(any(target_os = "macos", test))]
7161fn enable_launchd_registration(
7162 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7163 domain: &str,
7164 service_target: &str,
7165 plist: &Path,
7166 name: &str,
7167 elevation_remedy: &'static str,
7168) -> Result<(), ServiceError> {
7169 let (enabled, cause) = launchctl(&[
7170 std::ffi::OsStr::new("enable"),
7171 std::ffi::OsStr::new(service_target),
7172 ]);
7173 if enabled {
7174 return Ok(());
7175 }
7176
7177 let (booted_out, bootout_detail) = launchctl(&[
7178 std::ffi::OsStr::new("bootout"),
7179 std::ffi::OsStr::new(service_target),
7180 ]);
7181 let removed = std::fs::remove_file(plist);
7182 if !booted_out || removed.is_err() {
7183 return Err(ServiceError::Rollback {
7184 operation: "enable launchd registration",
7185 name: name.to_string(),
7186 cause,
7187 rollback: format!(
7188 "launchctl bootout {domain}: {}; remove {}: {}",
7189 if booted_out {
7190 "succeeded".to_string()
7191 } else {
7192 bootout_detail
7193 },
7194 plist.display(),
7195 removed
7196 .err()
7197 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7198 ),
7199 });
7200 }
7201
7202 if cause.to_ascii_lowercase().contains("permission denied") {
7203 Err(ServiceError::NeedsElevation {
7204 operation: "enable",
7205 name: name.to_string(),
7206 detail: cause,
7207 remedy: elevation_remedy,
7208 })
7209 } else {
7210 Err(ServiceError::Control {
7211 operation: "enable",
7212 name: name.to_string(),
7213 manager: "launchd",
7214 detail: cause,
7215 })
7216 }
7217}
7218
7219#[derive(Debug, Clone)]
7228pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7229
7230impl ServiceShutdown {
7231 pub async fn wait(mut self) {
7233 if !*self.0.borrow() {
7234 let _ = self.0.changed().await;
7235 }
7236 }
7237}
7238
7239#[cfg(windows)]
7246pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7247where
7248 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7249{
7250 windows_host::run(Box::new(run))
7251}
7252
7253#[cfg(windows)]
7254mod windows_host {
7255 use std::ffi::OsString;
7256 use std::sync::{Arc, Mutex, OnceLock, mpsc};
7257 use std::time::Duration;
7258
7259 use windows_service::service::{
7260 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7261 ServiceType,
7262 };
7263 use windows_service::service_control_handler::{
7264 self, ServiceControlHandlerResult, ServiceStatusHandle,
7265 };
7266
7267 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7268
7269 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7270
7271 struct Invocation {
7272 run: Runner,
7273 result: mpsc::SyncSender<Result<u8, String>>,
7274 }
7275
7276 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7277
7278 windows_service::define_windows_service!(ffi_service_main, service_main);
7279
7280 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7281 let (result_tx, result_rx) = mpsc::sync_channel(1);
7282 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7283 let mut invocation = slot
7284 .lock()
7285 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7286 if invocation.is_some() {
7287 return Err(host_error(
7288 "prepare",
7289 "the service-host slot was already used",
7290 ));
7291 }
7292 *invocation = Some(Invocation {
7293 run,
7294 result: result_tx,
7295 });
7296 drop(invocation);
7297
7298 windows_service::service_dispatcher::start("", ffi_service_main)
7299 .map_err(|error| host_error("connect", &error.to_string()))?;
7300 result_rx
7301 .recv()
7302 .map_err(|error| host_error("finish", &error.to_string()))?
7303 .map_err(|detail| host_error("run", &detail))
7304 }
7305
7306 fn service_main(_arguments: Vec<OsString>) {
7307 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7308 return;
7309 };
7310 let result = run_service(invocation.run);
7311 let _ = invocation.result.send(result);
7312 }
7313
7314 fn run_service(run: Runner) -> Result<u8, String> {
7315 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7316 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7317 let handler_status = Arc::clone(&status);
7318 let handler = move |control| match control {
7319 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7320 ServiceControl::Stop | ServiceControl::Shutdown => {
7321 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7322 let _ = handle.set_service_status(service_status(
7323 ServiceState::StopPending,
7324 ServiceControlAccept::empty(),
7325 1,
7326 Duration::from_secs(300),
7327 0,
7328 ));
7329 }
7330 let _ = stop_tx.send(true);
7331 ServiceControlHandlerResult::NoError
7332 }
7333 _ => ServiceControlHandlerResult::NotImplemented,
7334 };
7335 let handle = service_control_handler::register("", handler)
7336 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7337 *status
7338 .lock()
7339 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7340 handle
7341 .set_service_status(service_status(
7342 ServiceState::Running,
7343 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7344 0,
7345 Duration::default(),
7346 0,
7347 ))
7348 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7349
7350 let exit = run(ServiceShutdown(stop_rx));
7351 handle
7352 .set_service_status(service_status(
7353 ServiceState::Stopped,
7354 ServiceControlAccept::empty(),
7355 0,
7356 Duration::default(),
7357 u32::from(exit),
7358 ))
7359 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7360 Ok(exit)
7361 }
7362
7363 fn service_status(
7364 state: ServiceState,
7365 accepted: ServiceControlAccept,
7366 checkpoint: u32,
7367 wait_hint: Duration,
7368 exit: u32,
7369 ) -> ServiceStatus {
7370 ServiceStatus {
7371 service_type: ServiceType::OWN_PROCESS,
7372 current_state: state,
7373 controls_accepted: accepted,
7374 exit_code: if exit == 0 {
7375 ServiceExitCode::Win32(0)
7376 } else {
7377 ServiceExitCode::ServiceSpecific(exit)
7378 },
7379 checkpoint,
7380 wait_hint,
7381 process_id: None,
7382 }
7383 }
7384
7385 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7386 ServiceError::Control {
7387 operation,
7388 name: SERVICE_NAME.to_string(),
7389 manager: "the Windows Service Control Manager",
7390 detail: detail.to_string(),
7391 }
7392 }
7393}
7394
7395#[cfg(windows)]
7396mod sys {
7397 use std::ffi::{OsStr, OsString};
7407 use std::time::{Duration, Instant};
7408
7409 use runner_manager_domain::model::StartMode;
7410 use windows_service::service::{
7411 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7412 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7413 ServiceState, ServiceType,
7414 };
7415 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7416
7417 use super::{
7418 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7419 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7420 };
7421
7422 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7425 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7429 const ACCESS_DENIED: i32 = 5;
7431 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7432 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7433
7434 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7435 choose \"Run as administrator\".";
7436
7437 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7438 Ok(match mode {
7439 StartMode::Boot => Box::new(ScmControl),
7440 StartMode::Login => Box::new(TaskControl),
7441 })
7442 }
7443
7444 #[derive(Debug)]
7447 struct ScmControl;
7448
7449 fn scm_error(
7453 operation: &'static str,
7454 name: &str,
7455 error: &windows_service::Error,
7456 ) -> ServiceError {
7457 let raw = match error {
7458 windows_service::Error::Winapi(io) => io.raw_os_error(),
7459 _ => None,
7460 };
7461 let detail = match error {
7462 windows_service::Error::Winapi(io) => io.to_string(),
7463 other => other.to_string(),
7464 };
7465 if raw == Some(ACCESS_DENIED) {
7466 return ServiceError::NeedsElevation {
7467 operation,
7468 name: name.to_string(),
7469 detail,
7470 remedy: ELEVATION_REMEDY,
7471 };
7472 }
7473 ServiceError::Control {
7474 operation,
7475 name: name.to_string(),
7476 manager: "the Windows Service Control Manager",
7477 detail,
7478 }
7479 }
7480
7481 fn open_manager(
7482 access: ServiceManagerAccess,
7483 operation: &'static str,
7484 name: &str,
7485 ) -> Result<ServiceManager, ServiceError> {
7486 ServiceManager::local_computer(None::<&OsStr>, access)
7487 .map_err(|error| scm_error(operation, name, &error))
7488 }
7489
7490 impl ServiceControl for ScmControl {
7491 fn manager(&self) -> DefinitionKind {
7492 DefinitionKind::WindowsService
7493 }
7494
7495 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7496 let spec = windows_service_spec(plan);
7497 let manager = open_manager(
7498 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7499 "install",
7500 &spec.name,
7501 )?;
7502 let info = ServiceInfo {
7503 name: OsString::from(&spec.name),
7504 display_name: OsString::from(&spec.display_name),
7505 service_type: ServiceType::OWN_PROCESS,
7507 start_type: if spec.automatic_start {
7508 ServiceStartType::AutoStart
7509 } else {
7510 ServiceStartType::OnDemand
7511 },
7512 error_control: ServiceErrorControl::Normal,
7513 executable_path: plan.binary().to_path_buf(),
7514 launch_arguments: plan.arguments().to_vec(),
7515 dependencies: Vec::new(),
7516 account_name: spec.account.as_ref().map(OsString::from),
7518 account_password: None,
7521 };
7522 let service = manager
7523 .create_service(
7524 &info,
7525 ServiceAccess::CHANGE_CONFIG
7526 | ServiceAccess::QUERY_CONFIG
7527 | ServiceAccess::QUERY_STATUS
7528 | ServiceAccess::START
7529 | ServiceAccess::STOP
7530 | ServiceAccess::DELETE,
7531 )
7532 .map_err(|error| scm_error("install", &spec.name, &error))?;
7533 service
7534 .set_description(&spec.description)
7535 .map_err(|error| scm_error("describe", &spec.name, &error))?;
7536 service
7537 .update_failure_actions(ServiceFailureActions {
7538 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7539 reboot_msg: None,
7540 command: None,
7541 actions: Some(vec![
7548 ServiceAction {
7549 action_type: ServiceActionType::Restart,
7550 delay: spec.restart.delay(),
7551 };
7552 3
7553 ]),
7554 })
7555 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7556 service
7559 .set_failure_actions_on_non_crash_failures(true)
7560 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7561 Ok(ServiceDefinition::windows_service(plan))
7562 }
7563
7564 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7565 let manager =
7566 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7567 let service = match manager.open_service(
7568 identity.name(),
7569 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7570 ) {
7571 Ok(service) => Some(service),
7572 Err(error) if is_missing(&error) => return Ok(false),
7573 Err(error) if is_marked_for_delete(&error) => None,
7578 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7579 };
7580 if let Some(service) = service {
7581 if let Ok(status) = service.query_status()
7585 && status.current_state != ServiceState::Stopped
7586 {
7587 let _ = service.stop();
7588 }
7589 match service.delete() {
7590 Ok(()) => {}
7591 Err(error) if is_marked_for_delete(&error) => {}
7595 Err(error) => {
7596 return Err(scm_error("uninstall", identity.name(), &error));
7597 }
7598 }
7599 drop(service);
7600 }
7601
7602 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7609 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7610 Ok(service) => {
7611 drop(service);
7612 Ok(false)
7613 }
7614 Err(error) if is_missing(&error) => Ok(true),
7615 Err(error) if is_marked_for_delete(&error) => Ok(false),
7616 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7617 }
7618 })?;
7619 if !absent {
7620 return Err(ServiceError::Control {
7621 operation: "verify uninstall of",
7622 name: identity.name().to_string(),
7623 manager: "the Windows Service Control Manager",
7624 detail: format!(
7625 "the registration was still visible {} seconds after DeleteService; \
7626 retry `service uninstall` from an elevated prompt",
7627 DELETE_TIMEOUT.as_secs()
7628 ),
7629 });
7630 }
7631 Ok(true)
7632 }
7633
7634 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7635 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7636 let service = match manager.open_service(
7637 identity.name(),
7638 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7639 ) {
7640 Ok(service) => service,
7641 Err(error) if is_missing(&error) => return Ok(None),
7642 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7643 };
7644 let config = service
7645 .query_config()
7646 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7647 let status = service
7648 .query_status()
7649 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7650 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7651 actions
7652 .actions
7653 .and_then(|actions| actions.into_iter().next())
7654 .filter(|action| action.action_type == ServiceActionType::Restart)
7655 .map(|action| action.delay)
7656 });
7657 Ok(Some(Registration {
7658 manager: DefinitionKind::WindowsService,
7659 start_mode: StartMode::Boot,
7660 command_line: config.executable_path.to_string_lossy().into_owned(),
7664 account: config
7665 .account_name
7666 .map(|account| super::windows_service_account(&account.to_string_lossy())),
7667 running: status.current_state == ServiceState::Running,
7668 starts_automatically: config.start_type == ServiceStartType::AutoStart,
7669 restart_delay,
7670 }))
7671 }
7672
7673 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7674 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7675 let service = manager
7676 .open_service(identity.name(), ServiceAccess::START)
7677 .map_err(|error| scm_error("start", identity.name(), &error))?;
7678 service
7679 .start::<&OsStr>(&[])
7680 .map_err(|error| scm_error("start", identity.name(), &error))
7681 }
7682
7683 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7684 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7685 let service = manager
7686 .open_service(
7687 identity.name(),
7688 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7689 )
7690 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7691 let status = service
7692 .query_status()
7693 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7694 if status.current_state == ServiceState::Stopped {
7695 return Ok(false);
7696 }
7697 service
7698 .stop()
7699 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7700 Ok(true)
7701 }
7702 }
7703
7704 fn is_missing(error: &windows_service::Error) -> bool {
7705 matches!(error, windows_service::Error::Winapi(io)
7706 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7707 }
7708
7709 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7710 matches!(error, windows_service::Error::Winapi(io)
7711 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7712 }
7713
7714 pub(super) fn wait_until_scm_absent(
7715 timeout: Duration,
7716 poll_interval: Duration,
7717 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7718 ) -> Result<bool, ServiceError> {
7719 let deadline = Instant::now() + timeout;
7720 loop {
7721 if probe_absent()? {
7722 return Ok(true);
7723 }
7724 if Instant::now() >= deadline {
7725 return Ok(false);
7726 }
7727 std::thread::sleep(poll_interval);
7728 }
7729 }
7730
7731 #[derive(Debug)]
7734 struct TaskControl;
7735
7736 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7737 if detail.to_ascii_lowercase().contains("access is denied") {
7738 return ServiceError::NeedsElevation {
7739 operation,
7740 name: name.to_string(),
7741 detail,
7742 remedy: ELEVATION_REMEDY,
7743 };
7744 }
7745 ServiceError::Control {
7746 operation,
7747 name: name.to_string(),
7748 manager: "Windows Task Scheduler",
7749 detail,
7750 }
7751 }
7752
7753 fn schtasks(
7754 operation: &'static str,
7755 name: &str,
7756 arguments: &[&OsStr],
7757 ) -> Result<(bool, String), ServiceError> {
7758 match run("schtasks.exe", arguments) {
7759 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7760 Err(error) => Err(task_error(operation, name, error.to_string())),
7761 }
7762 }
7763
7764 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7767 let mut bytes = vec![0xFF, 0xFE];
7768 for unit in text.encode_utf16() {
7769 bytes.extend_from_slice(&unit.to_le_bytes());
7770 }
7771 std::fs::write(path, bytes)
7772 }
7773
7774 impl ServiceControl for TaskControl {
7775 fn manager(&self) -> DefinitionKind {
7776 DefinitionKind::WindowsScheduledTask
7777 }
7778
7779 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7780 let name = plan.identity().name().to_string();
7781 let principal = TaskPrincipal::current()?;
7782 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7783 let directory = tempfile::tempdir()
7784 .map_err(|error| task_error("install", &name, error.to_string()))?;
7785 let document = directory.path().join("task.xml");
7786 write_utf16(&document, definition.text())
7787 .map_err(|error| task_error("install", &name, error.to_string()))?;
7788 let (ok, message) = schtasks(
7789 "install",
7790 &name,
7791 &[
7792 OsStr::new("/Create"),
7793 OsStr::new("/TN"),
7794 OsStr::new(&name),
7795 OsStr::new("/XML"),
7796 document.as_os_str(),
7797 ],
7798 )?;
7799 if !ok {
7800 return Err(task_error("install", &name, message));
7801 }
7802 Ok(definition)
7803 }
7804
7805 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7806 if self.query(identity)?.is_none() {
7807 return Ok(false);
7808 }
7809 let name = identity.name().to_string();
7810 let (ok, message) = schtasks(
7811 "uninstall",
7812 &name,
7813 &[
7814 OsStr::new("/Delete"),
7815 OsStr::new("/TN"),
7816 OsStr::new(&name),
7817 OsStr::new("/F"),
7818 ],
7819 )?;
7820 if !ok {
7821 return Err(task_error("uninstall", &name, message));
7822 }
7823 Ok(true)
7824 }
7825
7826 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7827 let name = identity.name().to_string();
7828 let (ok, document) = schtasks(
7829 "inspect",
7830 &name,
7831 &[
7832 OsStr::new("/Query"),
7833 OsStr::new("/TN"),
7834 OsStr::new(&name),
7835 OsStr::new("/XML"),
7836 OsStr::new("ONE"),
7837 ],
7838 )?;
7839 if !ok {
7840 return Ok(None);
7846 }
7847 let command = xml_value(&document, "Command").unwrap_or_default();
7848 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
7849 let command_line = if arguments.is_empty() {
7850 super::quote_argument(&command)
7851 } else {
7852 format!("{} {arguments}", super::quote_argument(&command))
7853 };
7854 Ok(Some(Registration {
7855 manager: DefinitionKind::WindowsScheduledTask,
7856 start_mode: StartMode::Login,
7857 command_line,
7858 account: super::scheduled_task_account(xml_value(&document, "UserId")),
7859 running: task_is_running(&name),
7860 starts_automatically: super::windows_login_task_starts_automatically(&document),
7861 restart_delay: xml_value(&document, "Interval")
7862 .as_deref()
7863 .and_then(parse_iso8601),
7864 }))
7865 }
7866
7867 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7868 let name = identity.name().to_string();
7869 let (ok, message) = schtasks(
7870 "start",
7871 &name,
7872 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
7873 )?;
7874 if ok {
7875 Ok(())
7876 } else {
7877 Err(task_error("start", &name, message))
7878 }
7879 }
7880
7881 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7882 let running = self
7883 .query(identity)?
7884 .is_some_and(|registration| registration.running);
7885 if !running {
7886 return Ok(false);
7887 }
7888 let name = identity.name().to_string();
7889 let (ok, message) = schtasks(
7890 "stop",
7891 &name,
7892 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
7893 )?;
7894 if ok {
7895 Ok(true)
7896 } else {
7897 Err(task_error("stop", &name, message))
7898 }
7899 }
7900 }
7901
7902 fn task_is_running(name: &str) -> bool {
7916 let Ok((true, stdout, _)) = run(
7917 "schtasks.exe",
7918 &[
7919 OsStr::new("/Query"),
7920 OsStr::new("/TN"),
7921 OsStr::new(name),
7922 OsStr::new("/FO"),
7923 OsStr::new("CSV"),
7924 OsStr::new("/NH"),
7925 ],
7926 ) else {
7927 return false;
7928 };
7929 stdout
7930 .lines()
7931 .filter_map(|line| line.rsplit(',').next())
7932 .any(|status| {
7933 status
7934 .trim()
7935 .trim_matches('"')
7936 .eq_ignore_ascii_case("running")
7937 })
7938 }
7939
7940 fn parse_iso8601(value: &str) -> Option<Duration> {
7944 let rest = value.strip_prefix("PT")?;
7945 if let Some(minutes) = rest.strip_suffix('M') {
7946 return minutes
7947 .parse::<u64>()
7948 .ok()
7949 .map(|minutes| Duration::from_secs(minutes * 60));
7950 }
7951 rest.strip_suffix('S')?
7952 .parse::<u64>()
7953 .ok()
7954 .map(Duration::from_secs)
7955 }
7956}
7957
7958#[cfg(unix)]
7970fn write_definition(
7971 operation: &'static str,
7972 name: &str,
7973 path: &Path,
7974 text: &str,
7975 remedy: &'static str,
7976) -> Result<(), ServiceError> {
7977 if let Some(parent) = path.parent()
7978 && let Err(error) = std::fs::create_dir_all(parent)
7979 && error.kind() != std::io::ErrorKind::AlreadyExists
7980 {
7981 return Err(definition_error(operation, name, error, remedy, parent));
7982 }
7983 std::fs::write(path, text)
7984 .map_err(|error| definition_error(operation, name, error, remedy, path))
7985}
7986
7987#[cfg(all(unix, any(target_os = "macos", test)))]
7994fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
7995 use std::io::Write as _;
7996
7997 let path = drift.path.as_path();
7998 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
7999 let failed = |error: std::io::Error, at: &Path| {
8000 definition_error("rewrite", name, error, SUDO_REMEDY, at)
8001 };
8002 let mut staged =
8003 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8004 staged
8005 .write_all(drift.rendered.as_bytes())
8006 .and_then(|()| staged.as_file().sync_all())
8007 .map_err(|error| failed(error, staged.path()))?;
8008 std::fs::set_permissions(
8009 staged.path(),
8010 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8011 )
8012 .map_err(|error| failed(error, path))?;
8013 staged
8014 .persist(path)
8015 .map(drop)
8016 .map_err(|error| failed(error.error, path))
8017}
8018
8019#[cfg(unix)]
8020fn definition_error(
8021 operation: &'static str,
8022 name: &str,
8023 error: std::io::Error,
8024 remedy: &'static str,
8025 path: &Path,
8026) -> ServiceError {
8027 let detail = format!("{}: {error}", path.display());
8028 if error.kind() == std::io::ErrorKind::PermissionDenied {
8029 ServiceError::NeedsElevation {
8030 operation,
8031 name: name.to_string(),
8032 detail,
8033 remedy,
8034 }
8035 } else {
8036 ServiceError::Control {
8037 operation,
8038 name: name.to_string(),
8039 manager: "the local service manager",
8040 detail,
8041 }
8042 }
8043}
8044
8045#[cfg(unix)]
8047const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8048 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8049 agent not running until you sign in.";
8050
8051#[cfg(target_os = "macos")]
8056mod sys {
8057 use std::ffi::OsStr;
8070 use std::path::PathBuf;
8071 use std::time::Duration;
8072
8073 use runner_manager_domain::model::StartMode;
8074
8075 use super::{
8076 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8077 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8078 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8079 write_definition, xml_value,
8080 };
8081
8082 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8083 Ok(Box::new(LaunchdControl { mode }))
8084 }
8085
8086 #[derive(Debug)]
8087 struct LaunchdControl {
8088 mode: StartMode,
8089 }
8090
8091 impl LaunchdControl {
8092 fn domain(&self) -> String {
8094 match self.mode {
8095 StartMode::Boot => "system".to_string(),
8096 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8099 }
8100 }
8101
8102 fn service_target(&self, identity: &ServiceIdentity) -> String {
8103 format!("{}/{}", self.domain(), identity.launchd_label())
8104 }
8105
8106 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8109 let file = format!("{}.plist", identity.launchd_label());
8110 match self.mode {
8111 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8112 StartMode::Login => {
8113 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8114 }
8115 }
8116 }
8117
8118 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8119 ServiceError::Control {
8120 operation,
8121 name: name.to_string(),
8122 manager: "launchd",
8123 detail,
8124 }
8125 }
8126
8127 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8128 match run("launchctl", arguments) {
8129 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8130 Err(error) => (false, error.to_string()),
8131 }
8132 }
8133 }
8134
8135 impl ServiceControl for LaunchdControl {
8136 fn manager(&self) -> DefinitionKind {
8137 DefinitionKind::LaunchdPlist
8138 }
8139
8140 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8141 let name = plan.identity().name().to_string();
8142 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8143 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8144 return Err(self.failed(
8145 "install",
8146 &name,
8147 "this account has no home directory, so there is nowhere to put a \
8148 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8149 /Library/LaunchDaemons."
8150 .to_string(),
8151 ));
8152 };
8153 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8154 let target = self.domain();
8155 let (ok, message) = self.launchctl(&[
8156 OsStr::new("bootstrap"),
8157 OsStr::new(&target),
8158 path.as_os_str(),
8159 ]);
8160 if !ok {
8161 let _ = std::fs::remove_file(&path);
8165 if message.to_ascii_lowercase().contains("permission denied") {
8166 return Err(ServiceError::NeedsElevation {
8167 operation: "install",
8168 name,
8169 detail: message,
8170 remedy: SUDO_REMEDY,
8171 });
8172 }
8173 return Err(self.failed("install", &name, message));
8174 }
8175 let service_target = self.service_target(plan.identity());
8178 enable_launchd_registration(
8179 |arguments| self.launchctl(arguments),
8180 &target,
8181 &service_target,
8182 &path,
8183 &name,
8184 SUDO_REMEDY,
8185 )?;
8186 Ok(definition)
8187 }
8188
8189 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8190 let Some(path) = self.plist_path(identity) else {
8191 return Ok(false);
8192 };
8193 if !path.exists() {
8194 return Ok(false);
8195 }
8196 let target = self.service_target(identity);
8197 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8198 if !ok
8199 && !message.to_ascii_lowercase().contains("no such process")
8200 && !message.contains("113")
8201 {
8202 if message.to_ascii_lowercase().contains("permission denied") {
8203 return Err(ServiceError::NeedsElevation {
8204 operation: "uninstall",
8205 name: identity.name().to_string(),
8206 detail: message,
8207 remedy: SUDO_REMEDY,
8208 });
8209 }
8210 return Err(self.failed("uninstall", identity.name(), message));
8211 }
8212 std::fs::remove_file(&path).map_err(|error| {
8215 super::definition_error(
8216 "uninstall",
8217 identity.name(),
8218 error,
8219 SUDO_REMEDY,
8220 path.as_path(),
8221 )
8222 })?;
8223 Ok(true)
8224 }
8225
8226 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8227 let Some(path) = self.plist_path(identity) else {
8228 return Ok(None);
8229 };
8230 let Ok(document) = std::fs::read_to_string(&path) else {
8231 return Ok(None);
8232 };
8233 let target = self.service_target(identity);
8234 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8235 Ok(Some(Registration {
8236 manager: DefinitionKind::LaunchdPlist,
8237 start_mode: self.mode,
8238 command_line: program_arguments(&document),
8239 account: plist_string_value(&document, "UserName").or_else(|| {
8242 (self.mode == StartMode::Boot)
8243 .then(|| super::ServiceAccount::Root.as_str().to_string())
8244 }),
8245 running: loaded && printed.contains("state = running"),
8246 starts_automatically: document.contains("<key>RunAtLoad</key>")
8247 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8248 restart_delay: xml_value(
8249 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8250 "integer",
8251 )
8252 .and_then(|value| value.parse::<u64>().ok())
8253 .map(Duration::from_secs),
8254 }))
8255 }
8256
8257 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8258 let target = self.service_target(identity);
8259 let (ok, message) = self.launchctl(&[
8260 OsStr::new("kickstart"),
8261 OsStr::new("-k"),
8262 OsStr::new(&target),
8263 ]);
8264 if ok {
8265 Ok(())
8266 } else {
8267 Err(self.failed("start", identity.name(), message))
8268 }
8269 }
8270
8271 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8272 let running = self
8273 .query(identity)?
8274 .is_some_and(|registration| registration.running);
8275 if !running {
8276 return Ok(false);
8277 }
8278 let target = self.service_target(identity);
8279 let (ok, message) = self.launchctl(&[
8280 OsStr::new("kill"),
8281 OsStr::new("SIGTERM"),
8282 OsStr::new(&target),
8283 ]);
8284 if ok {
8285 Ok(true)
8286 } else {
8287 Err(self.failed("stop", identity.name(), message))
8288 }
8289 }
8290 }
8291
8292 pub(super) fn reload_detached(
8307 identity: &ServiceIdentity,
8308 mode: StartMode,
8309 plist: &std::path::Path,
8310 ) -> Result<(), ServiceError> {
8311 use std::os::unix::process::CommandExt;
8312
8313 const SCRIPT: &str = r#"(
8314 sleep 1
8315 /bin/launchctl bootout "$1"
8316 tries=0
8317 until /bin/launchctl bootstrap "$2" "$3"; do
8318 tries=$((tries + 1))
8319 if [ "$tries" -ge 60 ]; then
8320 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8321 exit 1
8322 fi
8323 sleep 1
8324 done
8325) </dev/null &"#;
8326
8327 let control = LaunchdControl { mode };
8328 let reinstall = match mode {
8331 StartMode::Boot => "sudo runner-manager service install --start-at boot",
8332 StartMode::Login => "runner-manager service install --start-at login",
8333 };
8334 let mut command = std::process::Command::new("/bin/sh");
8335 command
8336 .args(["-c", SCRIPT, "runner-manager-reload"])
8337 .arg(control.service_target(identity))
8338 .arg(control.domain())
8339 .arg(plist)
8340 .arg(reinstall)
8341 .stdin(std::process::Stdio::null());
8342 unsafe {
8345 command.pre_exec(|| {
8346 if libc::setsid() == -1 {
8347 return Err(std::io::Error::last_os_error());
8348 }
8349 Ok(())
8350 });
8351 }
8352 let status = command.status().map_err(|error| {
8353 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8354 })?;
8355 if status.success() {
8356 Ok(())
8357 } else {
8358 Err(control.failed(
8359 "reload",
8360 identity.name(),
8361 format!("the reload helper exited with {status}"),
8362 ))
8363 }
8364 }
8365
8366 fn program_arguments(document: &str) -> String {
8368 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8369 return String::new();
8370 };
8371 let Some(end) = rest.find("</array>") else {
8372 return String::new();
8373 };
8374 let mut out = Vec::new();
8375 let mut cursor = &rest[..end];
8376 while let Some(open) = cursor.find("<string>") {
8377 let after = &cursor[open + "<string>".len()..];
8378 let Some(close) = after.find("</string>") else {
8379 break;
8380 };
8381 out.push(quote_argument(&super::xml_unescape(&after[..close])));
8382 cursor = &after[close..];
8383 }
8384 out.join(" ")
8385 }
8386}
8387
8388#[cfg(all(unix, not(target_os = "macos")))]
8393mod sys {
8394 use std::ffi::OsStr;
8404 use std::path::PathBuf;
8405
8406 use runner_manager_domain::model::StartMode;
8407
8408 use super::{
8409 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8410 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8411 home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8412 write_definition,
8413 };
8414
8415 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8416 Ok(Box::new(SystemdControl { mode }))
8417 }
8418
8419 #[derive(Debug)]
8420 struct SystemdControl {
8421 mode: StartMode,
8422 }
8423
8424 impl SystemdControl {
8425 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8426 let file = identity.systemd_unit();
8427 match self.mode {
8428 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8429 StartMode::Login => {
8430 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8431 }
8432 }
8433 }
8434
8435 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8437 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8438 if self.mode == StartMode::Login {
8439 all.push(OsStr::new("--user"));
8440 }
8441 all.extend(arguments.iter().map(OsStr::new));
8442 match run("systemctl", &all) {
8443 Ok((ok, stdout, stderr)) => (
8444 ok,
8445 if stdout.trim().is_empty() {
8446 stderr
8447 } else {
8448 stdout
8449 },
8450 ),
8451 Err(error) => (false, error.to_string()),
8452 }
8453 }
8454
8455 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8456 ServiceError::Control {
8457 operation,
8458 name: name.to_string(),
8459 manager: "systemd",
8460 detail,
8461 }
8462 }
8463 }
8464
8465 impl ServiceControl for SystemdControl {
8466 fn manager(&self) -> DefinitionKind {
8467 DefinitionKind::SystemdUnit
8468 }
8469
8470 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8471 use std::os::unix::fs::MetadataExt as _;
8472
8473 let name = plan.identity().name().to_string();
8474 if self.mode == StartMode::Boot {
8475 let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8478 .iter()
8479 .filter_map(|path| {
8480 let path = std::path::Path::new(path);
8481 let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8482 super::boot_directory_refusal(path, owner)
8483 })
8484 .collect::<Vec<_>>();
8485 if !refusals.is_empty() {
8486 return Err(self.failed(
8487 "install",
8488 &name,
8489 format!(
8490 "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8491 directory there that root does not own is handed back with \
8492 `sudo chown -R root:root <directory>`",
8493 refusals.join("; "),
8494 super::sudo_command("service install --start-at boot"),
8495 crate::paths::SYSTEM_DATA_ROOT,
8496 crate::paths::SYSTEM_LOGS_DIR,
8497 ),
8498 ));
8499 }
8500 }
8501 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8502 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8503 return Err(self.failed(
8504 "install",
8505 &name,
8506 "this account has no home directory, so there is nowhere to put a systemd \
8507 user unit. Use --start-at boot, which installs a system unit under \
8508 /etc/systemd/system."
8509 .to_string(),
8510 ));
8511 };
8512 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8513 let unit = plan.identity().systemd_unit();
8514 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8515 if !reloaded {
8516 let _ = std::fs::remove_file(&path);
8517 return Err(self.failed("install", &name, message));
8518 }
8519 let (enabled, message) = self.systemctl(&["enable", &unit]);
8520 if !enabled {
8521 let _ = std::fs::remove_file(&path);
8522 let _ = self.systemctl(&["daemon-reload"]);
8523 return Err(self.failed("install", &name, message));
8524 }
8525 Ok(definition)
8526 }
8527
8528 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8529 let Some(path) = self.unit_path(identity) else {
8530 return Ok(false);
8531 };
8532 if !path.exists() {
8533 return Ok(false);
8534 }
8535 let unit = identity.systemd_unit();
8536 let _ = self.systemctl(&["disable", "--now", &unit]);
8541 std::fs::remove_file(&path).map_err(|error| {
8542 super::definition_error(
8543 "uninstall",
8544 identity.name(),
8545 error,
8546 SUDO_REMEDY,
8547 path.as_path(),
8548 )
8549 })?;
8550 let _ = self.systemctl(&["daemon-reload"]);
8551 Ok(true)
8552 }
8553
8554 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8555 let Some(path) = self.unit_path(identity) else {
8556 return Ok(None);
8557 };
8558 if !path.exists() {
8559 return Ok(None);
8560 }
8561 let unit = identity.systemd_unit();
8562 let effective = match self.systemctl(&["cat", &unit]) {
8566 (true, text) => text,
8567 _ => match std::fs::read_to_string(&path) {
8568 Ok(document) => document,
8569 Err(_) => return Ok(None),
8570 },
8571 };
8572 let directives = ini_directives(&effective, "Service");
8573 let (_, shown) = self.systemctl(&[
8575 "show",
8576 "--property=ActiveState",
8577 "--property=UnitFileState",
8578 &unit,
8579 ]);
8580 let state = |key: &str| {
8581 shown
8582 .lines()
8583 .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8584 .map(str::trim)
8585 };
8586 Ok(Some(Registration {
8587 manager: DefinitionKind::SystemdUnit,
8588 start_mode: self.mode,
8589 command_line: directives
8590 .get("ExecStart")
8591 .map(|value| systemd_exec_command_line(value))
8592 .unwrap_or_default(),
8593 account: directives
8596 .get("User")
8597 .filter(|user| !user.is_empty())
8598 .cloned()
8599 .or_else(|| {
8600 (self.mode == StartMode::Boot)
8601 .then(|| super::ServiceAccount::Root.as_str().to_string())
8602 }),
8603 running: state("ActiveState") == Some("active"),
8604 starts_automatically: state("UnitFileState") == Some("enabled"),
8605 restart_delay: directives
8606 .get("RestartSec")
8607 .and_then(|value| systemd_timespan(value)),
8608 }))
8609 }
8610
8611 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8612 let unit = identity.systemd_unit();
8613 let (ok, message) = self.systemctl(&["start", &unit]);
8614 if ok {
8615 Ok(())
8616 } else {
8617 Err(self.failed("start", identity.name(), message))
8618 }
8619 }
8620
8621 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8622 let running = self
8623 .query(identity)?
8624 .is_some_and(|registration| registration.running);
8625 if !running {
8626 return Ok(false);
8627 }
8628 let unit = identity.systemd_unit();
8629 let (ok, message) = self.systemctl(&["stop", &unit]);
8630 if ok {
8631 Ok(true)
8632 } else {
8633 Err(self.failed("stop", identity.name(), message))
8634 }
8635 }
8636 }
8637}
8638
8639#[cfg(all(test, target_os = "macos"))]
8641pub(crate) mod launchd_fixture {
8642 use std::path::{Path, PathBuf};
8643
8644 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8648
8649 pub(crate) fn enabled() -> bool {
8652 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8653 if !enabled {
8654 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8655 }
8656 enabled
8657 }
8658
8659 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8667 let binary = directory.join("launch-agent-job");
8668 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8669 .expect("test binary copied");
8670 binary
8671 }
8672
8673 pub(crate) struct LoadedAgent(String);
8675
8676 impl LoadedAgent {
8677 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8680 let domain = format!("gui/{}", unsafe { libc::getuid() });
8683 let loaded = Self(format!("{domain}/{label}"));
8685 let bootstrap = std::process::Command::new("launchctl")
8686 .arg("bootstrap")
8687 .arg(&domain)
8688 .arg(plist)
8689 .output()
8690 .expect("launchctl runs");
8691 assert!(
8692 bootstrap.status.success(),
8693 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8694 String::from_utf8_lossy(&bootstrap.stderr)
8695 );
8696 loaded
8697 }
8698
8699 pub(crate) fn target(&self) -> &str {
8701 &self.0
8702 }
8703 }
8704
8705 impl Drop for LoadedAgent {
8706 fn drop(&mut self) {
8707 let _ = std::process::Command::new("launchctl")
8708 .args(["bootout", &self.0])
8709 .output();
8710 }
8711 }
8712}
8713
8714#[cfg(test)]
8715mod tests {
8716 use super::*;
8717
8718 use std::collections::BTreeMap;
8719
8720 #[test]
8721 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8722 assert!(windows_login_task_starts_automatically(
8723 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8724 ));
8725 assert!(windows_login_task_starts_automatically(
8726 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8727 ));
8728 assert!(!windows_login_task_starts_automatically(
8729 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8730 ));
8731 assert!(!windows_login_task_starts_automatically(
8732 "<Task><BootTrigger></BootTrigger></Task>"
8733 ));
8734 }
8735
8736 fn linux_plan(mode: StartMode) -> InstallPlan {
8743 InstallPlan::unchecked(
8744 ServiceIdentity::product(),
8745 mode,
8746 "/opt/runner-manager/bin/runner-manager",
8747 ServiceDirectories {
8748 config: PathBuf::from("/var/lib/runner-manager/config"),
8749 state: PathBuf::from("/var/lib/runner-manager/state"),
8750 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8751 logs: PathBuf::from("/var/log/runner-manager"),
8752 },
8753 )
8754 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8755 }
8756
8757 fn windows_plan(mode: StartMode) -> InstallPlan {
8758 InstallPlan::unchecked(
8759 ServiceIdentity::product(),
8760 mode,
8761 "C:\\Program Files\\runner-manager\\runner-manager.exe",
8762 ServiceDirectories {
8763 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8764 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8765 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8766 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8767 },
8768 )
8769 }
8770
8771 fn edited(text: &str, from: &str, to: &str) -> String {
8779 assert!(
8780 text.contains(from),
8781 "the rendered definition does not contain `{from}`, so this test would assert \
8782 nothing about a widened one"
8783 );
8784 text.replace(from, to)
8785 }
8786
8787 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8789 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8790 let Ok(entries) = std::fs::read_dir(directory) else {
8791 return;
8792 };
8793 for entry in entries.flatten() {
8794 let path = entry.path();
8795 if path.is_dir() {
8796 walk(&path, out);
8797 } else if let Ok(bytes) = std::fs::read(&path) {
8798 out.insert(path, bytes);
8799 }
8800 }
8801 }
8802 let mut out = BTreeMap::new();
8803 for root in roots {
8804 walk(root, &mut out);
8805 }
8806 out
8807 }
8808
8809 struct Host {
8810 _root: tempfile::TempDir,
8811 paths: AppPaths,
8812 binary: PathBuf,
8813 runner_root: LocalAbsolutePath,
8822 controls: RecordingControls,
8823 }
8824
8825 impl Host {
8826 fn new() -> Self {
8827 let root = tempfile::tempdir().expect("a temporary directory");
8828 let paths = AppPaths::rooted_at(root.path());
8829 paths.create_all().expect("the four directories");
8830 let binary = root.path().join(if cfg!(windows) {
8831 "runner-manager.exe"
8832 } else {
8833 "runner-manager"
8834 });
8835 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
8836 let runner_root = LocalAbsolutePath::new(
8837 root.path()
8838 .join("runner-root")
8839 .to_str()
8840 .expect("a unicode temporary path"),
8841 )
8842 .expect("a local absolute path");
8843 Self {
8844 _root: root,
8845 paths,
8846 binary,
8847 runner_root,
8848 controls: RecordingControls::new(),
8849 }
8850 }
8851
8852 fn operations(&self) -> ServiceOperations {
8853 ServiceOperations::with_controls(
8854 self.paths.clone(),
8855 ServiceIdentity::product(),
8856 std::sync::Arc::new(self.controls.clone()),
8857 )
8858 .with_runner_root(self.runner_root.clone())
8859 }
8860
8861 fn request(&self, mode: StartMode) -> InstallRequest {
8862 InstallRequest::new(mode).for_binary(&self.binary)
8863 }
8864 }
8865
8866 #[cfg(windows)]
8867 #[test]
8868 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
8869 let probes = std::cell::Cell::new(0);
8870 let absent =
8871 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
8872 let next = probes.get() + 1;
8873 probes.set(next);
8874 Ok(next == 3)
8875 })
8876 .expect("the simulated SCM probe succeeds");
8877
8878 assert!(absent);
8879 assert_eq!(
8880 probes.get(),
8881 3,
8882 "uninstall must recheck after transient presence instead of treating it as a leak"
8883 );
8884 }
8885
8886 #[cfg(windows)]
8887 #[test]
8888 fn windows_recognises_an_already_pending_service_deletion() {
8889 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
8890
8891 assert!(super::sys::is_marked_for_delete(&error));
8892 }
8893
8894 #[test]
8895 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
8896 let root = tempfile::tempdir().expect("a temporary directory");
8897 let plist = root.path().join("fixture.plist");
8898 std::fs::write(&plist, b"fixture").expect("a plist fixture");
8899 let calls = std::cell::RefCell::new(Vec::new());
8900
8901 let error = enable_launchd_registration(
8902 |arguments| {
8903 let call = arguments
8904 .iter()
8905 .map(|argument| argument.to_string_lossy().into_owned())
8906 .collect::<Vec<_>>();
8907 let operation = call[0].clone();
8908 calls.borrow_mut().push(call);
8909 if operation == "enable" {
8910 (false, "label remains disabled".to_string())
8911 } else {
8912 (true, String::new())
8913 }
8914 },
8915 "system",
8916 "system/com.openai.runner-manager-selftest",
8917 &plist,
8918 "runner-manager-selftest",
8919 "rerun with administrative rights",
8920 )
8921 .expect_err("enable failure must fail the install");
8922
8923 assert!(
8924 matches!(
8925 error,
8926 ServiceError::Control {
8927 operation: "enable",
8928 ..
8929 }
8930 ),
8931 "{error}"
8932 );
8933 assert_eq!(calls.borrow().len(), 2);
8934 assert_eq!(calls.borrow()[0][0], "enable");
8935 assert_eq!(calls.borrow()[1][0], "bootout");
8936 assert!(!plist.exists(), "rollback must remove the plist");
8937 }
8938
8939 #[test]
8944 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
8945 let plan = windows_plan(StartMode::Boot);
8946 let command_line = plan.command_line();
8947 assert!(
8948 command_line.starts_with('"'),
8949 "a path with a space must be quoted, got {command_line}"
8950 );
8951 assert_eq!(
8952 executable_from_command_line(&command_line).as_deref(),
8953 Some(plan.binary())
8954 );
8955 }
8956
8957 #[test]
8958 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
8959 let plan = linux_plan(StartMode::Boot);
8960 let command_line = plan.command_line();
8961 assert!(!command_line.starts_with('"'), "got {command_line}");
8962 assert_eq!(
8963 executable_from_command_line(&command_line).as_deref(),
8964 Some(plan.binary())
8965 );
8966 }
8967
8968 #[test]
8969 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
8970 let awkward = r#"C:\odd "name"\rm.exe"#;
8973 let quoted = quote_argument(awkward);
8974 assert_eq!(
8975 executable_from_command_line(&format!("{quoted} daemon run"))
8976 .as_deref()
8977 .map(Path::to_string_lossy)
8978 .as_deref(),
8979 Some(awkward)
8980 );
8981 }
8982
8983 #[test]
8984 fn an_empty_command_line_has_no_executable() {
8985 assert_eq!(executable_from_command_line(" "), None);
8986 assert_eq!(executable_from_command_line(""), None);
8987 }
8988
8989 #[test]
8990 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
8991 let awkward = r#"DOMAIN\R&D <team> "ops""#;
8992 assert_eq!(
8993 xml_escape(awkward),
8994 "DOMAIN\\R&D <team> "ops""
8995 );
8996 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
8997 }
8998
8999 #[test]
9004 fn a_restart_delay_under_the_floor_is_refused() {
9005 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9006 .expect_err("half a second is under the one-second floor");
9007 assert!(
9008 matches!(error, ServiceError::RestartDelay { .. }),
9009 "{error}"
9010 );
9011 }
9012
9013 #[test]
9014 fn a_restart_delay_over_the_ceiling_is_refused() {
9015 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9016 .expect_err("an hour is over the five-minute ceiling");
9017 assert!(
9018 matches!(error, ServiceError::RestartDelay { .. }),
9019 "{error}"
9020 );
9021 }
9022
9023 #[test]
9024 fn a_reset_window_no_longer_than_the_delay_is_refused() {
9025 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9026 .expect_err("a window equal to the delay can never elapse between restarts");
9027 assert!(
9028 matches!(error, ServiceError::RestartResetWindow { .. }),
9029 "{error}"
9030 );
9031 }
9032
9033 #[test]
9034 fn a_delay_inside_the_bound_is_accepted() {
9035 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9036 .expect("twenty seconds is inside the bound");
9037 assert_eq!(policy.delay(), Duration::from_secs(20));
9038 assert_eq!(policy.reset_after(), Duration::from_secs(300));
9039 }
9040
9041 #[test]
9046 fn a_fixture_identity_can_never_be_the_product_identity() {
9047 let fixture = ServiceIdentity::fixture("abc123");
9048 assert!(fixture.is_fixture());
9049 assert!(!ServiceIdentity::product().is_fixture());
9050 assert_ne!(fixture.name(), ServiceIdentity::product().name());
9051 assert_ne!(
9052 fixture.launchd_label(),
9053 ServiceIdentity::product().launchd_label()
9054 );
9055 assert_ne!(
9056 fixture.systemd_unit(),
9057 ServiceIdentity::product().systemd_unit()
9058 );
9059 }
9060
9061 #[test]
9062 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9063 let fixture = ServiceIdentity::fixture("A b/c:\\d");
9064 assert!(
9065 fixture
9066 .name()
9067 .chars()
9068 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9069 "got {}",
9070 fixture.name()
9071 );
9072 }
9073
9074 #[test]
9079 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9080 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9081 assert!(unit.contains("KillMode=process\n"), "{unit}");
9082 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9083 assert!(unit.contains("RestartSec=15\n"), "{unit}");
9084 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9085 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9086 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9087 }
9088
9089 #[test]
9090 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9091 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9092 assert!(
9093 !unit.contains("LoadCredential="),
9094 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9095 );
9096 }
9097
9098 #[test]
9099 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9100 let unit = systemd_unit(&linux_plan(StartMode::Login));
9101 assert!(
9102 !unit.contains("LoadCredential="),
9103 "a user unit must not name a root-owned credential file, got:\n{unit}"
9104 );
9105 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9106 }
9107
9108 #[test]
9109 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9110 let plan = linux_plan(StartMode::Boot);
9111 let unit = systemd_unit(&plan);
9112 let directives = ini_directives(&unit, "Service");
9113 let listed = split_quoted(
9114 directives
9115 .get("ReadWritePaths")
9116 .expect("the unit names its writable paths"),
9117 );
9118 assert_eq!(listed.len(), 5, "{listed:?}");
9119 for path in plan.directories().all() {
9120 assert!(
9121 listed.iter().any(|entry| entry == &path.to_string_lossy()),
9122 "{} is missing from {listed:?}",
9123 path.display()
9124 );
9125 }
9126 assert!(
9127 listed
9128 .iter()
9129 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9130 "atomic credential replacement needs its parent directory in {listed:?}"
9131 );
9132 }
9133
9134 #[test]
9135 fn the_unit_records_the_absolute_binary_path() {
9136 let plan = linux_plan(StartMode::Boot);
9137 let unit = systemd_unit(&plan);
9138 assert!(
9139 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9140 "{unit}"
9141 );
9142 }
9143
9144 #[test]
9149 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9150 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9151 assert!(
9154 plist.contains(
9155 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
9156 ),
9157 "{plist}"
9158 );
9159 assert!(
9160 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
9161 "{plist}"
9162 );
9163 }
9164
9165 #[test]
9166 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9167 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9168 assert_eq!(
9169 plist_string_value(&daemon, "UserName").as_deref(),
9170 Some("root")
9171 );
9172 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9173
9174 let agent = launchd_plist(&linux_plan(StartMode::Login));
9175 assert_eq!(
9176 plist_string_value(&agent, "UserName"),
9177 None,
9178 "a LaunchAgent already runs as the operator:\n{agent}"
9179 );
9180 }
9181
9182 #[test]
9183 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9184 for mode in [StartMode::Boot, StartMode::Login] {
9185 let plan = linux_plan(mode);
9186 let plist = launchd_plist(&plan);
9187 assert_eq!(
9188 plist_string_value(&plist, "ProcessType").as_deref(),
9189 Some("Interactive"),
9190 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9191 );
9192 let review = review_least_privilege(
9193 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9194 &plan,
9195 );
9196 assert!(
9197 review
9198 .controls()
9199 .iter()
9200 .any(|control| control == "ProcessType=Interactive"),
9201 "{review}"
9202 );
9203 assert!(
9204 !review
9205 .findings()
9206 .iter()
9207 .any(|finding| finding.subject == "ProcessType"),
9208 "{review}"
9209 );
9210 }
9211 }
9212
9213 #[test]
9217 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9218 let plan = linux_plan(StartMode::Login);
9219 let rendered = launchd_plist(&plan);
9220 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
9221 for (label, throttled) in [
9222 (
9223 "Background",
9224 edited(
9225 &rendered,
9226 interactive,
9227 "<key>ProcessType</key>\n <string>Background</string>\n",
9228 ),
9229 ),
9230 ("absent", edited(&rendered, interactive, "")),
9231 ] {
9232 let review = review_least_privilege(
9233 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9234 &plan,
9235 );
9236 assert!(review.is_least_privilege(), "{label}: {review}");
9237 assert!(
9238 review.findings().iter().any(|finding| {
9239 finding.kind == FindingKind::Shortfall
9240 && finding.subject == "ProcessType"
9241 && finding.detail.contains(label)
9242 }),
9243 "{label}: {review}"
9244 );
9245 }
9246 }
9247
9248 #[test]
9249 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9250 let host = Host::new();
9251 let identity = ServiceIdentity::product();
9252 let plan = InstallPlan::resolve(
9253 identity.clone(),
9254 &host.request(StartMode::Login),
9255 ServiceDirectories::of(&host.paths),
9256 )
9257 .expect("a resolvable plan");
9258 let installed = launchd_plist(&plan);
9259 let definition =
9260 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9261 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9262 record.installed_by_version = "0.4.31".into();
9263 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9264
9265 assert_eq!(
9266 launchd_definition_drift(&identity, &record, path.clone(), &installed),
9267 None
9268 );
9269 let edited_by_operator = edited(
9270 &installed,
9271 "</dict>\n</plist>\n",
9272 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
9273 );
9274 assert_eq!(
9275 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9276 None,
9277 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9278 );
9279
9280 let written_by_0_4_31 = edited(
9281 &installed,
9282 "<string>Interactive</string>",
9283 "<string>Background</string>",
9284 );
9285 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9286 .expect("a Background plist is drift");
9287 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9288 assert_eq!(drift.start_mode, StartMode::Login);
9289 assert_eq!(
9290 drift.rendered, installed,
9291 "the repair must write exactly what `service install` writes"
9292 );
9293
9294 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9295 .with_definition_drift(Some(drift));
9296 let problem = status
9297 .problems()
9298 .iter()
9299 .find(|problem| problem.subject == "definition")
9300 .expect("the drift is reported");
9301 assert!(
9304 problem.detail.contains("0.4.31")
9305 && problem.detail.contains(
9306 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9307 )
9308 && !problem.detail.contains("service install")
9309 && !problem.detail.contains("systemctl")
9310 && !problem.detail.contains("sudo"),
9311 "{}",
9312 problem.detail
9313 );
9314 }
9315
9316 #[cfg(unix)]
9317 #[test]
9318 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9319 use std::os::unix::fs::PermissionsExt as _;
9320
9321 let root = tempfile::tempdir().expect("a temporary directory");
9322 let path = root
9323 .path()
9324 .join("io.github.IvanMurzak.runner-manager.plist");
9325 std::fs::write(&path, "<string>Background</string>").expect("old plist");
9326 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9327 let drift = DefinitionDrift {
9328 path: path.clone(),
9329 installed_by_version: "0.4.31".into(),
9330 rendered: launchd_plist(&linux_plan(StartMode::Login)),
9331 start_mode: StartMode::Login,
9332 manager: DefinitionKind::LaunchdPlist,
9333 };
9334
9335 replace_definition("runner-manager", &drift).expect("replaced");
9336
9337 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9338 assert_eq!(
9339 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9340 0o644
9341 );
9342 assert_eq!(
9343 std::fs::read_dir(root.path()).unwrap().count(),
9344 1,
9345 "no staging file is left beside the plist"
9346 );
9347 }
9348
9349 #[cfg(target_os = "macos")]
9358 #[test]
9359 #[ignore = "run only by launchd, as the job of the detached-reload test"]
9360 fn detached_reload_job() {
9361 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9362 let tag = label
9363 .split_once("-selftest-")
9364 .map(|(_, tag)| tag.to_string())
9365 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9366 let identity = ServiceIdentity::fixture(&tag);
9367 assert_eq!(identity.launchd_label(), label);
9368 let path = std::env::current_dir()
9369 .expect("working directory")
9370 .join(format!("{label}.plist"));
9371 let installed = std::fs::read_to_string(&path).expect("own plist");
9372 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9373 let drift = DefinitionDrift {
9374 path: path.clone(),
9375 installed_by_version: "0.4.31".into(),
9376 rendered: installed.replace(
9377 "<string>Background</string>",
9378 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9379 ),
9380 start_mode: StartMode::Login,
9381 manager: DefinitionKind::LaunchdPlist,
9382 };
9383 replace_definition(identity.name(), &drift).expect("plist replaced");
9384 super::sys::reload_detached(&identity, StartMode::Login, &path)
9385 .expect("helper started");
9386 }
9387 std::thread::sleep(Duration::from_secs(120));
9388 }
9389
9390 #[cfg(target_os = "macos")]
9400 #[test]
9401 fn a_job_reloads_itself_from_its_rewritten_plist() {
9402 if !super::launchd_fixture::enabled() {
9403 return;
9404 }
9405 let root = tempfile::tempdir().expect("a temporary directory");
9406 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9407 let label = identity.launchd_label();
9408 let path = root.path().join(format!("{label}.plist"));
9409 let binary = super::launchd_fixture::job_binary(root.path());
9410 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9411 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9412 plist.push_str(&plist_string("Label", &label));
9413 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
9414 for argument in [
9415 binary.to_string_lossy().as_ref(),
9416 "--ignored",
9417 "--exact",
9418 "service::tests::detached_reload_job",
9419 "--test-threads=1",
9420 ] {
9421 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
9422 }
9423 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
9424 plist.push_str(&plist_string(
9425 "WorkingDirectory",
9426 &root.path().to_string_lossy(),
9427 ));
9428 plist.push_str(&plist_string("ProcessType", "Background"));
9429 plist.push_str("</dict>\n</plist>\n");
9430 std::fs::write(&path, plist).expect("plist written");
9431
9432 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9433 let spawn_type = || {
9434 let printed = std::process::Command::new("launchctl")
9435 .args(["print", loaded.target()])
9436 .output()
9437 .expect("launchctl runs");
9438 String::from_utf8_lossy(&printed.stdout)
9439 .lines()
9440 .find_map(|line| {
9441 line.trim()
9442 .strip_prefix("spawn type = ")
9443 .map(str::to_string)
9444 })
9445 };
9446 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9447
9448 let deadline = std::time::Instant::now() + Duration::from_secs(90);
9449 while spawn_type().as_deref() != Some("interactive (4)") {
9450 assert!(
9451 std::time::Instant::now() < deadline,
9452 "the job did not come back under its rewritten plist; launchd reports {:?}",
9453 spawn_type()
9454 );
9455 std::thread::sleep(Duration::from_millis(250));
9456 }
9457 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9458 assert_eq!(
9459 plist_string_value(&rewritten, "ProcessType").as_deref(),
9460 Some(LAUNCHD_PROCESS_TYPE)
9461 );
9462 }
9463
9464 #[test]
9465 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9466 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9467 assert!(
9468 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9469 "{plist}"
9470 );
9471 assert!(plist.contains("<string>daemon</string>"), "{plist}");
9472 assert!(plist.contains("<string>run</string>"), "{plist}");
9473 }
9474
9475 #[test]
9476 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9477 assert_eq!(
9478 ServiceIdentity::product().launchd_label(),
9479 "io.github.IvanMurzak.runner-manager"
9480 );
9481 }
9482
9483 #[test]
9488 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9489 let xml = windows_scheduled_task_xml(
9490 &windows_plan(StartMode::Login),
9491 &TaskPrincipal::named("HOST\\operator"),
9492 );
9493 assert!(xml.contains("<LogonTrigger>"), "{xml}");
9494 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9495 assert!(
9496 xml.contains("<LogonType>InteractiveToken</LogonType>"),
9497 "{xml}"
9498 );
9499 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9500 assert!(
9501 xml.contains("<Interval>PT1M</Interval>"),
9502 "Task Scheduler takes whole minutes only, and rejects the registration outright \
9503 for anything finer:\n{xml}"
9504 );
9505 }
9506
9507 #[test]
9508 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9509 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9514 let policy =
9515 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9516 .expect("inside the supported range");
9517 assert_eq!(
9518 policy
9519 .effective_delay(DefinitionKind::WindowsScheduledTask)
9520 .as_secs(),
9521 enforced,
9522 "a {asked}s delay must be enforced as {enforced}s"
9523 );
9524 assert!(
9525 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9526 "rounding must never shorten the bound"
9527 );
9528 }
9529 }
9530
9531 #[test]
9532 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9533 let policy = RestartPolicy::default();
9534 for kind in [
9535 DefinitionKind::WindowsService,
9536 DefinitionKind::LaunchdPlist,
9537 DefinitionKind::SystemdUnit,
9538 ] {
9539 assert_eq!(
9540 policy.effective_delay(kind),
9541 policy.delay(),
9542 "{kind:?} takes seconds and enforces exactly what it is given"
9543 );
9544 }
9545 }
9546
9547 #[test]
9548 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9549 let host = Host::new();
9550 let operations = host.operations();
9551 operations
9552 .install(&host.request(StartMode::Login))
9553 .expect("an install at login");
9554 host.controls.edit("runner-manager", |registration| {
9556 registration.manager = DefinitionKind::WindowsScheduledTask;
9557 registration.restart_delay = Some(Duration::from_secs(60));
9558 });
9559
9560 let status = operations.status().expect("a status");
9561 assert!(
9562 status.is_healthy(),
9563 "minute granularity is the manager's, not a mis-registration: {status}"
9564 );
9565 assert!(
9566 status
9567 .notes()
9568 .iter()
9569 .any(|note| note.contains("whole minutes")),
9570 "but the operator must be told why 15 became 60: {status}"
9571 );
9572
9573 host.controls.edit("runner-manager", |registration| {
9576 registration.restart_delay = Some(Duration::from_secs(1));
9577 });
9578 assert!(
9579 !operations.status().expect("a status").is_healthy(),
9580 "a one-second delay is not what any manager was asked for"
9581 );
9582 }
9583
9584 #[test]
9585 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9586 let plan = windows_plan(StartMode::Login);
9587 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9588 assert_eq!(
9589 xml_value(&xml, "Command").as_deref(),
9590 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9591 "{xml}"
9592 );
9593 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9594 }
9595
9596 #[test]
9597 fn an_account_name_holding_xml_punctuation_is_escaped() {
9598 let xml = windows_scheduled_task_xml(
9599 &windows_plan(StartMode::Login),
9600 &TaskPrincipal::named("R&D\\ops"),
9601 );
9602 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
9603 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9604 }
9605
9606 #[test]
9611 fn the_service_starts_automatically_under_the_account_the_store_admits() {
9612 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9613 let directives = ini_directives(&text, "windows-service");
9614 assert_eq!(
9615 directives.get("StartType").map(String::as_str),
9616 Some("AutoStart")
9617 );
9618 assert_eq!(
9619 directives.get("Account").map(String::as_str),
9620 Some("NT AUTHORITY\\SYSTEM")
9621 );
9622 assert_eq!(
9623 directives.get("ServiceType").map(String::as_str),
9624 Some("OWN_PROCESS")
9625 );
9626 assert_eq!(
9627 directives
9628 .get("FailureActionRestartDelaySecs")
9629 .map(String::as_str),
9630 Some("15")
9631 );
9632 assert_eq!(
9633 directives
9634 .get("FailureActionsOnNonCrashFailures")
9635 .map(String::as_str),
9636 Some("true"),
9637 "without this flag a non-zero exit is not a failure the manager restarts"
9638 );
9639 }
9640
9641 #[test]
9642 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9643 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9644 assert_eq!(spec.account, None);
9645 assert!(spec.automatic_start);
9646 assert!(
9647 spec.command_line.contains("daemon run"),
9648 "{}",
9649 spec.command_line
9650 );
9651 }
9652
9653 #[test]
9658 fn each_definition_goes_where_its_platform_expects_it() {
9659 let home = PathBuf::from("/home/op");
9660 assert_eq!(
9661 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9662 Some(Path::new(
9663 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9664 ))
9665 );
9666 assert_eq!(
9667 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9668 Some(Path::new(
9669 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9670 ))
9671 );
9672 assert_eq!(
9673 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9674 Some(Path::new("/etc/systemd/system/runner-manager.service"))
9675 );
9676 assert_eq!(
9677 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9678 Some(Path::new(
9679 "/home/op/.config/systemd/user/runner-manager.service"
9680 ))
9681 );
9682 assert_eq!(
9683 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9684 None,
9685 "the Service Control Manager has no file"
9686 );
9687 }
9688
9689 #[test]
9690 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9691 assert_eq!(
9692 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9693 None
9694 );
9695 assert_eq!(
9696 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9697 None
9698 );
9699 }
9700
9701 #[test]
9706 fn the_rendered_definitions_are_all_least_privilege() {
9707 let linux = linux_plan(StartMode::Boot);
9708 let windows = windows_plan(StartMode::Boot);
9709 for (definition, plan) in [
9710 (ServiceDefinition::systemd(&linux, None), &linux),
9711 (ServiceDefinition::launchd(&linux, None), &linux),
9712 (ServiceDefinition::windows_service(&windows), &windows),
9713 ] {
9714 let review = review_least_privilege(&definition, plan);
9715 assert!(
9716 review.is_least_privilege(),
9717 "{:?} should be least privilege, got:\n{review}",
9718 definition.kind()
9719 );
9720 assert!(
9721 !review.controls().is_empty(),
9722 "a review that confirms nothing proves nothing: {:?}",
9723 definition.kind()
9724 );
9725 }
9726 }
9727
9728 #[test]
9729 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9730 let plan = windows_plan(StartMode::Login);
9731 let definition =
9732 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9733 let review = review_least_privilege(&definition, &plan);
9734 assert!(review.is_least_privilege(), "{review}");
9735 assert!(
9736 review
9737 .controls()
9738 .iter()
9739 .any(|control| control.contains("LeastPrivilege")),
9740 "{review}"
9741 );
9742 }
9743
9744 #[test]
9745 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9746 let plan = linux_plan(StartMode::Boot);
9747 let rendered = systemd_unit(&plan);
9748 let widened = edited(
9749 &rendered,
9750 "ReadWritePaths=/var/lib/runner-manager/config",
9751 "ReadWritePaths=/etc /var/lib/runner-manager/config",
9752 );
9753 let review = review_least_privilege(
9754 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9755 &plan,
9756 );
9757 assert!(!review.is_least_privilege(), "{review}");
9758 assert!(
9759 review
9760 .excesses()
9761 .iter()
9762 .any(|finding| finding.detail.contains("/etc")),
9763 "the review must name the directory it objects to: {review}"
9764 );
9765 }
9766
9767 #[test]
9768 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9769 let plan = linux_plan(StartMode::Boot);
9770 let rendered = systemd_unit(&plan);
9771 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9772 let review = review_least_privilege(
9773 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9774 &plan,
9775 );
9776 assert!(!review.is_least_privilege(), "{review}");
9777 assert!(
9778 review
9779 .excesses()
9780 .iter()
9781 .any(|finding| finding.subject == "NoNewPrivileges"),
9782 "{review}"
9783 );
9784 }
9785
9786 fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
9788 review_least_privilege(
9789 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
9790 plan,
9791 )
9792 .findings()
9793 .iter()
9794 .any(|finding| finding.subject == subject && finding.kind == kind)
9795 }
9796
9797 #[test]
9798 fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
9799 for mode in [StartMode::Boot, StartMode::Login] {
9800 let unit = systemd_unit(&linux_plan(mode));
9801 assert!(
9802 !unit.contains("RestrictSUIDSGID"),
9803 "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
9804 );
9805 }
9806 }
9807
9808 #[test]
9809 fn every_unit_drops_every_capability_and_boot_units_hide_home_trees() {
9810 let boot = systemd_unit(&linux_plan(StartMode::Boot));
9811 let directives = ini_directives(&boot, "Service");
9812 assert_eq!(directives["CapabilityBoundingSet"], "");
9813 assert_eq!(directives["AmbientCapabilities"], "");
9814 assert_eq!(directives["ProtectHome"], "tmpfs");
9815 assert!(!directives.contains_key("BindPaths"));
9816
9817 let login = systemd_unit(&linux_plan(StartMode::Login));
9818 let directives = ini_directives(&login, "Service");
9819 assert_eq!(directives["CapabilityBoundingSet"], "");
9820 assert!(!directives.contains_key("ProtectHome"));
9821
9822 let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
9823 assert!(reports(
9824 &unprotected,
9825 &linux_plan(StartMode::Boot),
9826 "ProtectHome",
9827 FindingKind::Excess
9828 ));
9829 let inverted = boot.replace(
9830 "CapabilityBoundingSet=\n",
9831 "CapabilityBoundingSet=~CAP_SYS_ADMIN\n",
9832 );
9833 assert!(reports(
9834 &inverted,
9835 &linux_plan(StartMode::Boot),
9836 "CapabilityBoundingSet",
9837 FindingKind::Excess
9838 ));
9839 }
9840
9841 #[test]
9842 fn status_reports_the_registered_account_and_falls_back_to_the_record() {
9843 let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
9844 for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
9845 let host = Host::new();
9846 let operations = host.operations();
9847 operations.install(&host.request(StartMode::Boot)).unwrap();
9848 host.controls.edit("runner-manager", |registration| {
9849 registration.account = registered.map(str::to_string);
9850 });
9851 let status = operations.status().unwrap().to_string();
9852 assert!(
9853 status.contains(&format!("account {shown}\n")),
9854 "{status}"
9855 );
9856 }
9857 }
9858
9859 #[cfg(target_os = "linux")]
9860 #[test]
9861 fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
9862 let host = Host::new();
9863 let operations = host.operations();
9864 operations.install(&host.request(StartMode::Boot)).unwrap();
9865 host.controls.edit("runner-manager", |registration| {
9866 registration.account = Some("ivan".to_string());
9867 });
9868 let refused = operations
9869 .install(&host.request(StartMode::Boot))
9870 .expect_err("a unit running as ivan cannot use root's tree");
9871 let message = refused.to_string();
9872 assert!(message.contains("run as ivan"), "{message}");
9873 assert!(message.contains("Remove `User=`"), "{message}");
9874 }
9875
9876 #[test]
9877 fn manager_account_spellings_match_the_record() {
9878 assert_eq!(
9879 windows_service_account("LocalSystem"),
9880 ServiceAccount::LocalSystem.as_str()
9881 );
9882 assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
9883 assert_eq!(
9884 scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
9885 None
9886 );
9887 assert_eq!(
9888 scheduled_task_account(Some("HOST\\op".into())),
9889 Some("HOST\\op".to_string())
9890 );
9891 }
9892
9893 #[test]
9894 fn exec_start_prefixes_are_not_part_of_the_binary() {
9895 for (value, expected) in [
9896 ("/opt/rm daemon run", "/opt/rm daemon run"),
9897 ("-/opt/rm daemon run", "/opt/rm daemon run"),
9898 ("+!/opt/rm daemon run", "/opt/rm daemon run"),
9899 ("!!/opt/rm daemon run", "/opt/rm daemon run"),
9900 ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
9901 ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
9902 ] {
9903 assert_eq!(systemd_exec_command_line(value), expected, "{value}");
9904 }
9905 assert_eq!(
9906 executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
9907 Some(PathBuf::from("/opt/rm"))
9908 );
9909 }
9910
9911 #[test]
9912 fn restart_sec_accepts_systemd_time_spans() {
9913 for (value, expected) in [
9914 ("15", Duration::from_secs(15)),
9915 ("15s", Duration::from_secs(15)),
9916 ("1min", Duration::from_secs(60)),
9917 ("1min 30s", Duration::from_secs(90)),
9918 ("1min30s", Duration::from_secs(90)),
9919 ("2h", Duration::from_secs(7_200)),
9920 ("500ms", Duration::from_millis(500)),
9921 ("1.5s", Duration::from_millis(1_500)),
9922 ] {
9923 assert_eq!(systemd_timespan(value), Some(expected), "{value}");
9924 }
9925 for value in ["", "infinity", "soon", "5 parsecs", "s"] {
9926 assert_eq!(systemd_timespan(value), None, "{value}");
9927 }
9928 }
9929
9930 #[cfg(unix)]
9931 #[test]
9932 fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
9933 let system = ServiceDirectories::of(&AppPaths::system());
9934 assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
9935 assert_eq!(
9936 data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
9937 Some("root")
9938 );
9939 }
9940
9941 #[test]
9942 fn a_boot_service_refuses_directories_it_could_not_open() {
9943 let var = Path::new("/var/lib/runner-manager/state");
9944 assert_eq!(boot_directory_refusal(var, Some(0)), None);
9945 let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
9946 assert!(refused.contains("uid 1000"), "{refused}");
9947 assert!(boot_directory_refusal(var, None).is_some());
9948 for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
9949 let refused =
9950 boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
9951 assert!(refused.contains("cannot see"), "{refused}");
9952 }
9953 }
9954
9955 #[cfg(unix)]
9956 #[test]
9957 fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
9958 use std::os::unix::fs::PermissionsExt as _;
9959
9960 let from = tempfile::tempdir().unwrap();
9961 let to = tempfile::tempdir().unwrap();
9962 let target = to.path().join("state");
9963 std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
9964 std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
9965 std::fs::write(from.path().join("secret"), b"x").unwrap();
9966 std::fs::set_permissions(
9967 from.path().join("secret"),
9968 std::fs::Permissions::from_mode(0o600),
9969 )
9970 .unwrap();
9971 std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
9972 std::fs::create_dir(from.path().join("bin")).unwrap();
9973
9974 copy_tree(from.path(), &target, Some("bin")).unwrap();
9975
9976 assert_eq!(
9977 std::fs::read(target.join("journal/2026/attempt")).unwrap(),
9978 b"entry"
9979 );
9980 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
9981 assert_eq!(mode(&target.join("secret")), 0o600);
9982 assert_eq!(mode(&target.join("journal")), 0o700);
9983 assert_eq!(
9984 std::fs::read_link(target.join("latest")).unwrap(),
9985 Path::new("journal")
9986 );
9987 assert!(!target.join("bin").exists());
9988
9989 std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
9991 set_aside(&target, "20261110T000000Z").unwrap();
9992 assert!(!target.exists());
9993 let aside = to.path().join("state.before-move-20261110T000000Z");
9994 assert_eq!(
9995 std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
9996 b"stale"
9997 );
9998 copy_tree(from.path(), &target, Some("bin")).unwrap();
9999 assert!(!target.join("stale.sqlite3-wal").exists());
10000 set_aside(&to.path().join("absent"), "x").unwrap();
10001 }
10002
10003 #[test]
10004 fn systemd_drop_ins_replace_base_service_directives() {
10005 let base = systemd_unit(&linux_plan(StartMode::Boot));
10006 let combined = format!(
10007 "# /etc/systemd/system/runner-manager.service\n{base}\n\
10008 # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10009 [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10010 ExecStart=/opt/runner-manager daemon run\n"
10011 );
10012 let directives = ini_directives(&combined, "Service");
10013 assert_eq!(directives["User"], "ivan");
10014 assert_eq!(directives["RestartSec"], "30");
10015 assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10016 assert_eq!(directives["ProtectSystem"], "strict");
10017 }
10018
10019 #[test]
10020 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10021 let plan = linux_plan(StartMode::Boot);
10022 let rendered = systemd_unit(&plan);
10023 let widened = edited(
10024 &rendered,
10025 "CapabilityBoundingSet=\n",
10026 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10027 );
10028 let review = review_least_privilege(
10029 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10030 &plan,
10031 );
10032 assert!(!review.is_least_privilege(), "{review}");
10033 assert!(
10034 review
10035 .excesses()
10036 .iter()
10037 .any(|finding| finding.subject == "CapabilityBoundingSet"),
10038 "{review}"
10039 );
10040 }
10041
10042 #[test]
10043 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10044 let plan = linux_plan(StartMode::Boot);
10045 let rendered = systemd_unit(&plan);
10046 let widened = edited(
10047 &rendered,
10048 "[Install]",
10049 "ListenStream=127.0.0.1:9000\n\n[Install]",
10050 );
10051 let review = review_least_privilege(
10052 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10053 &plan,
10054 );
10055 assert!(
10056 !review.is_least_privilege(),
10057 "07-security.md rule 2 forbids any inbound surface: {review}"
10058 );
10059 }
10060
10061 #[test]
10062 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10063 let plan = linux_plan(StartMode::Boot);
10064 let rendered = systemd_unit(&plan);
10065 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10066 let review = review_least_privilege(
10067 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10068 &plan,
10069 );
10070 assert!(
10071 review.is_least_privilege(),
10072 "too little authority is not an excess: {review}"
10073 );
10074 assert!(
10075 review
10076 .findings()
10077 .iter()
10078 .any(|finding| finding.kind == FindingKind::Shortfall
10079 && finding.detail.contains("runtime")),
10080 "{review}"
10081 );
10082 }
10083
10084 #[test]
10085 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10086 let plan = linux_plan(StartMode::Login);
10087 let rendered = launchd_plist(&plan);
10088 let widened = edited(
10089 &rendered,
10090 "<key>ProcessType</key>",
10091 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
10092 );
10093 let review = review_least_privilege(
10094 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10095 &plan,
10096 );
10097 assert!(!review.is_least_privilege(), "{review}");
10098 assert!(
10099 review
10100 .excesses()
10101 .iter()
10102 .any(|finding| finding.subject == "UserName"),
10103 "{review}"
10104 );
10105 }
10106
10107 #[test]
10108 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10109 let plan = linux_plan(StartMode::Boot);
10110 let rendered = launchd_plist(&plan);
10111 let widened = edited(
10112 &rendered,
10113 "<key>SessionCreate</key>\n <false/>",
10114 "<key>SessionCreate</key>\n <true/>",
10115 );
10116 let review = review_least_privilege(
10117 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10118 &plan,
10119 );
10120 assert!(!review.is_least_privilege(), "{review}");
10121 assert!(
10122 review
10123 .excesses()
10124 .iter()
10125 .any(|finding| finding.subject == "SessionCreate"),
10126 "{review}"
10127 );
10128 }
10129
10130 #[test]
10131 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10132 let plan = linux_plan(StartMode::Boot);
10133 let rendered = launchd_plist(&plan);
10134 let widened = edited(
10135 &rendered,
10136 "<key>ProcessType</key>",
10137 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
10138 );
10139 let review = review_least_privilege(
10140 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10141 &plan,
10142 );
10143 assert!(!review.is_least_privilege(), "{review}");
10144 }
10145
10146 #[test]
10147 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10148 let plan = windows_plan(StartMode::Login);
10149 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10150 let widened = edited(
10151 &rendered,
10152 "<RunLevel>LeastPrivilege</RunLevel>",
10153 "<RunLevel>HighestAvailable</RunLevel>",
10154 );
10155 let review = review_least_privilege(
10156 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10157 &plan,
10158 );
10159 assert!(!review.is_least_privilege(), "{review}");
10160 assert!(
10161 review
10162 .excesses()
10163 .iter()
10164 .any(|finding| finding.subject == "RunLevel"),
10165 "{review}"
10166 );
10167 }
10168
10169 #[test]
10170 fn a_task_that_would_store_a_password_is_not_least_privilege() {
10171 let plan = windows_plan(StartMode::Login);
10172 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10173 let widened = edited(
10174 &rendered,
10175 "<LogonType>InteractiveToken</LogonType>",
10176 "<LogonType>Password</LogonType>",
10177 );
10178 let review = review_least_privilege(
10179 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10180 &plan,
10181 );
10182 assert!(!review.is_least_privilege(), "{review}");
10183 }
10184
10185 #[test]
10186 fn an_interactive_windows_service_is_not_least_privilege() {
10187 let plan = windows_plan(StartMode::Boot);
10188 let rendered = windows_service_descriptor(&plan);
10189 let widened = edited(
10190 &rendered,
10191 "ServiceType=OWN_PROCESS",
10192 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10193 );
10194 let review = review_least_privilege(
10195 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10196 &plan,
10197 );
10198 assert!(!review.is_least_privilege(), "{review}");
10199 assert!(
10200 review
10201 .excesses()
10202 .iter()
10203 .any(|finding| finding.subject == "ServiceType"),
10204 "{review}"
10205 );
10206 }
10207
10208 #[test]
10209 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10210 let plan = windows_plan(StartMode::Boot);
10211 let rendered = windows_service_descriptor(&plan);
10212 let changed = edited(
10213 &rendered,
10214 "Account=NT AUTHORITY\\SYSTEM",
10215 "Account=NT AUTHORITY\\LocalService",
10216 );
10217 let review = review_least_privilege(
10218 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10219 &plan,
10220 );
10221 assert!(review.is_least_privilege(), "{review}");
10227 assert!(
10228 review.findings().iter().any(|finding| {
10229 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10230 }),
10231 "{review}"
10232 );
10233 }
10234
10235 #[test]
10240 fn a_recorded_path_that_is_still_there_is_current() {
10241 let host = Host::new();
10242 let state = inspect_binary(&host.binary, Some(&host.binary));
10243 assert!(!state.is_error(), "{state}");
10244 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10245 }
10246
10247 #[test]
10248 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10249 let host = Host::new();
10250 let recorded = host.binary.clone();
10254 let healthy = inspect_binary(&recorded, Some(&recorded));
10255 assert!(
10256 !healthy.is_error(),
10257 "the discriminator: before the binary moves, this must be healthy"
10258 );
10259
10260 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10261
10262 let state = inspect_binary(&recorded, Some(&recorded));
10263 assert!(state.is_error(), "{state}");
10264 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10265 assert!(
10266 state.to_string().contains("npm"),
10267 "the message must name the cause an operator will not otherwise connect: {state}"
10268 );
10269 }
10270
10271 #[test]
10272 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10273 let root = tempfile::tempdir().expect("a temporary directory");
10274 let state = inspect_binary(root.path(), None);
10275 assert!(state.is_error(), "{state}");
10276 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10277 }
10278
10279 #[test]
10280 fn a_registration_naming_a_different_binary_is_a_divergence() {
10281 let host = Host::new();
10282 let other = host.binary.with_file_name("something-else");
10283 let state = inspect_binary(&host.binary, Some(&other));
10284 assert!(state.is_error(), "{state}");
10285 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10286 }
10287
10288 #[test]
10289 fn absence_is_reported_before_divergence() {
10290 let host = Host::new();
10291 let recorded = host.binary.clone();
10292 std::fs::remove_file(&recorded).expect("removable");
10293 let other = recorded.with_file_name("something-else");
10294 assert!(matches!(
10298 inspect_binary(&recorded, Some(&other)),
10299 BinaryPath::Missing { .. }
10300 ));
10301 }
10302
10303 #[test]
10308 fn the_record_round_trips_through_toml() {
10309 let host = Host::new();
10310 let plan = InstallPlan::resolve(
10311 ServiceIdentity::product(),
10312 &host.request(StartMode::Boot),
10313 ServiceDirectories::of(&host.paths),
10314 )
10315 .expect("a resolvable plan");
10316 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10317 let record = InstallRecord::of(&plan, &definition, Utc::now());
10318 record.write(&host.paths).expect("a writable record");
10319 let read = InstallRecord::read(&host.paths)
10320 .expect("a readable record")
10321 .expect("a record is there");
10322 assert_eq!(read, record);
10323 assert_eq!(read.binary, host.binary);
10324 assert!(read.binary.is_absolute());
10325 }
10326
10327 #[test]
10328 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10329 let host = Host::new();
10330 let identity = ServiceIdentity::product();
10331 let plan = InstallPlan::resolve(
10332 identity.clone(),
10333 &host.request(StartMode::Boot),
10334 ServiceDirectories::of(&host.paths),
10335 )
10336 .expect("a resolvable plan");
10337 let installed = systemd_unit(&plan);
10338 let definition =
10339 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10340 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10341 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10342
10343 assert_eq!(
10344 systemd_definition_drift(&identity, &record, path.clone(), &installed),
10345 None,
10346 "the record must rebuild exactly the unit `service install` wrote"
10347 );
10348
10349 record.installed_by_version = "0.4.7".into();
10350 let frozen = installed.replace(
10351 "[Service]\n",
10352 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10353 );
10354 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10355 .expect("an outdated unit is drift");
10356 assert_eq!(drift.installed_by_version, "0.4.7");
10357 assert_eq!(drift.rendered, installed);
10358
10359 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10360 .with_definition_drift(Some(drift));
10361 assert!(!status.is_healthy());
10362 assert!(
10363 status
10364 .problems()
10365 .iter()
10366 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10367 "{status}"
10368 );
10369 }
10370
10371 #[test]
10372 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10373 let host = Host::new();
10374 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10375 let first = Utc::now() - chrono::Duration::hours(9);
10376 record_github_credential_rejected(&host.paths, first).unwrap();
10377 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10378 let since = github_credential_rejected_since(&host.paths).unwrap();
10379 assert_eq!(since, Some(first));
10380
10381 let status = ServiceStatus::compose(
10382 ServiceIdentity::product(),
10383 None,
10384 None,
10385 None,
10386 None,
10387 &host.paths,
10388 )
10389 .with_credential_rejection(since);
10390 assert!(!status.is_healthy());
10391 assert_eq!(status.credential_rejected_since(), Some(first));
10392 assert!(status.to_string().contains("auth login"), "{status}");
10393
10394 clear_github_credential_rejection(&host.paths).unwrap();
10395 clear_github_credential_rejection(&host.paths).unwrap();
10396 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10397 }
10398
10399 #[test]
10401 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10402 use crate::launch_health::{
10403 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10404 record_launches_blocked,
10405 };
10406
10407 let host = Host::new();
10408 let compose = || {
10409 ServiceStatus::compose(
10410 ServiceIdentity::product(),
10411 None,
10412 None,
10413 None,
10414 None,
10415 &host.paths,
10416 )
10417 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10418 };
10419 assert!(
10420 !compose()
10421 .problems()
10422 .iter()
10423 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10424 );
10425 let blocked = BlockCause::FenceHeld {
10426 directory: PathBuf::from("launch-fence"),
10427 owner: None,
10428 }
10429 .blocked_since(Utc::now(), Viewpoint::Guest);
10430 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10431
10432 let status = compose();
10433 assert!(!status.is_healthy());
10434 assert_eq!(status.launches_blocked(), Some(&blocked));
10435 let problem = status
10436 .problems()
10437 .iter()
10438 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10439 .expect("reported under its own subject");
10440 assert!(problem.detail.contains("systemctl restart"), "{status}");
10441
10442 clear_launches_blocked(&host.paths).unwrap();
10443 assert_eq!(compose().launches_blocked(), None);
10444 }
10445
10446 #[test]
10449 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10450 let host = Host::new();
10451 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10452 let first = Utc::now() - chrono::Duration::minutes(40);
10453 record_credential_unreadable(&host.paths, first).unwrap();
10454 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10455 let since = credential_unreadable_since(&host.paths).unwrap();
10456 assert_eq!(since, Some(first), "a restart loop must not move the start");
10457
10458 let status = ServiceStatus::compose(
10459 ServiceIdentity::product(),
10460 None,
10461 None,
10462 None,
10463 None,
10464 &host.paths,
10465 )
10466 .with_credential_unreadable(since);
10467 assert!(!status.is_healthy());
10468 let problem = status
10469 .problems()
10470 .iter()
10471 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10472 .expect("reported under its own subject");
10473 assert!(
10475 problem
10476 .detail
10477 .contains(&sign_in_instruction(StartMode::default())),
10478 "{status}"
10479 );
10480
10481 clear_credential_unreadable(&host.paths).unwrap();
10482 clear_credential_unreadable(&host.paths).unwrap();
10483 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10484 let healthy = ServiceStatus::compose(
10485 ServiceIdentity::product(),
10486 None,
10487 None,
10488 None,
10489 None,
10490 &host.paths,
10491 )
10492 .with_credential_unreadable(None);
10493 assert!(
10494 !healthy
10495 .problems()
10496 .iter()
10497 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10498 "{healthy}"
10499 );
10500 }
10501
10502 #[test]
10503 fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10504 assert_eq!(sudo_program(None), "runner-manager");
10505 assert_eq!(
10506 sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10507 "runner-manager"
10508 );
10509 assert_eq!(
10510 sudo_program(Some(Path::new(
10511 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10512 runner-manager-linux-x64/bin/runner-manager"
10513 ))),
10514 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10515 runner-manager-linux-x64/bin/runner-manager"
10516 );
10517 assert_eq!(
10518 sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10519 "'/home/o'\\''p/my bin/runner-manager'"
10520 );
10521 assert_eq!(
10522 sudo_command("service status"),
10523 "sudo runner-manager service status"
10524 );
10525 }
10526
10527 #[test]
10528 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
10529 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
10530 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
10531 assert_eq!(
10532 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
10533 cfg!(unix),
10534 "a machine-scoped store needs root on Unix"
10535 );
10536 assert_eq!(
10537 sign_in_instruction(StartMode::Login).contains("SSH"),
10538 cfg!(target_os = "macos"),
10539 "only the macOS login keychain is out of an SSH session's reach"
10540 );
10541 }
10542
10543 #[cfg(unix)]
10552 #[test]
10553 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
10554 use std::os::unix::fs::PermissionsExt as _;
10555
10556 let host = Host::new();
10557 let plan = InstallPlan::resolve(
10558 ServiceIdentity::product(),
10559 &host.request(StartMode::Boot),
10560 ServiceDirectories::of(&host.paths),
10561 )
10562 .expect("a resolvable plan");
10563 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10564 InstallRecord::of(&plan, &definition, Utc::now())
10565 .write(&host.paths)
10566 .expect("a writable record");
10567
10568 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
10569 .expect("the record is there")
10570 .permissions()
10571 .mode()
10572 & 0o777;
10573 assert_eq!(
10574 mode, 0o644,
10575 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
10576 service install` wrote, and `service status` fails on their own host. It holds no \
10577 credential and sits in a 0700 directory, so 0644 discloses nothing"
10578 );
10579 }
10580
10581 #[test]
10585 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
10586 let host = Host::new();
10587 let path = InstallRecord::path(&host.paths);
10588 std::fs::write(
10589 &path,
10590 format!(
10591 "schema_version = {RECORD_SCHEMA_VERSION}
10592service_name = \"runner-manager\"
10593 manager = \"systemd\"
10594start_mode = \"boot\"
10595account = \"root\"
10596 binary = \"/x\"
10597arguments = []
10598restart_delay_secs = 15
10599 restart_reset_secs = 600
10600log_file = \"/x\"
10601 installed_at = \"2026-01-01T00:00:00Z\"
10602installed_by_version = \"0.1.0\"
10603 [directories]
10604config = \"/a\"
10605state = \"/b\"
10606runtime = \"/c\"
10607logs = \"/d\"
10608"
10609 ),
10610 )
10611 .expect("a writable record");
10612 let read = InstallRecord::read(&host.paths)
10613 .expect("a record missing an optional field is still readable")
10614 .expect("a record is there");
10615 assert_eq!(
10616 read.source_binary, None,
10617 "the legacy layout has no source, and must not invent one"
10618 );
10619 }
10620
10621 #[test]
10623 fn a_registration_remembers_the_file_it_was_copied_from() {
10624 let host = Host::new();
10625 let source = host.binary.with_file_name("npm-installed-runner-manager");
10626 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
10627 let plan = InstallPlan::resolve(
10628 ServiceIdentity::product(),
10629 &host.request(StartMode::Boot).copied_from(&source),
10630 ServiceDirectories::of(&host.paths),
10631 )
10632 .expect("a resolvable plan");
10633 let definition = ServiceDefinition::from_text(
10634 DefinitionKind::SystemdUnit,
10635 "[Service]
10636",
10637 );
10638 let record = InstallRecord::of(&plan, &definition, Utc::now());
10639 record.write(&host.paths).expect("a writable record");
10640
10641 let read = InstallRecord::read(&host.paths)
10642 .expect("a readable record")
10643 .expect("a record is there");
10644 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
10645 assert_ne!(
10646 read.source_binary.as_deref(),
10647 Some(read.binary.as_path()),
10648 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
10649 );
10650 }
10651
10652 #[test]
10653 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
10654 let host = Host::new();
10655 let path = InstallRecord::path(&host.paths);
10656 std::fs::write(
10657 &path,
10658 format!(
10659 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
10660 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
10661 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
10662 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
10663 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
10664 RECORD_SCHEMA_VERSION + 1
10665 ),
10666 )
10667 .expect("a writable record");
10668 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
10669 assert!(
10670 matches!(error, ServiceError::RecordUnreadable { .. }),
10671 "{error}"
10672 );
10673 assert!(
10674 error.to_string().contains("service uninstall"),
10675 "the message must say how to recover: {error}"
10676 );
10677 }
10678
10679 #[test]
10680 fn no_record_is_not_an_error() {
10681 let host = Host::new();
10682 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
10683 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
10684 }
10685
10686 #[test]
10691 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
10692 let host = Host::new();
10693 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
10694 }
10695
10696 #[test]
10697 fn the_heartbeat_round_trips_to_the_second() {
10698 let host = Host::new();
10699 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
10700 .expect("a valid timestamp")
10701 .with_timezone(&Utc);
10702 record_github_contact(&host.paths, at).expect("a writable heartbeat");
10703 assert_eq!(
10704 last_github_contact(&host.paths).expect("readable"),
10705 Some(at)
10706 );
10707 }
10708
10709 #[test]
10710 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
10711 let host = Host::new();
10712 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
10713 let error = last_github_contact(&host.paths)
10714 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
10715 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10716 }
10717
10718 #[test]
10724 fn a_runner_root_refusal_round_trips_for_service_status() {
10725 let host = Host::new();
10726 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10727
10728 assert!(
10729 runner_root_refusals(&host.paths)
10730 .expect("readable")
10731 .is_empty(),
10732 "no record means every policy is placing runners"
10733 );
10734
10735 record_runner_root_refusal(
10736 &host.paths,
10737 "policy-a",
10738 at,
10739 "denied_by_privacy_policy",
10740 "/Volumes/NVME/runners",
10741 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
10742 )
10743 .expect("a writable record");
10744
10745 let refusals = runner_root_refusals(&host.paths).expect("readable");
10746 assert_eq!(refusals.len(), 1);
10747 assert_eq!(refusals[0].policy, "policy-a");
10748 assert_eq!(refusals[0].at, at);
10749 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
10750 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
10751 assert!(
10752 refusals[0].detail.contains("/Volumes/NVME/runners")
10753 && refusals[0].detail.contains("Full Disk Access"),
10754 "the path and the remediation are the whole point of this file: {refusals:?}"
10755 );
10756 }
10757
10758 #[test]
10766 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
10767 let host = Host::new();
10768 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10769 record_runner_root_refusal(
10770 &host.paths,
10771 "broken",
10772 at,
10773 "denied_by_privacy_policy",
10774 "/Volumes/NVME/runners",
10775 "detail",
10776 )
10777 .expect("a writable record");
10778 record_runner_root_refusal(
10779 &host.paths,
10780 "also-broken",
10781 at,
10782 "not_writable",
10783 "/srv/other",
10784 "detail",
10785 )
10786 .expect("a writable record");
10787
10788 clear_runner_root_refusal(&host.paths, "healthy")
10790 .expect("clearing an absent policy is fine");
10791 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
10792
10793 let refusals = runner_root_refusals(&host.paths).expect("readable");
10794 assert_eq!(
10795 refusals
10796 .iter()
10797 .map(|r| r.policy.as_str())
10798 .collect::<Vec<_>>(),
10799 vec!["broken"],
10800 "the policy that is still refused must keep its record"
10801 );
10802 }
10803
10804 #[test]
10807 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
10808 let host = Host::new();
10809 record_runner_root_refusal(
10810 &host.paths,
10811 "p",
10812 Utc::now(),
10813 "not_writable",
10814 "/srv/x",
10815 "detail",
10816 )
10817 .expect("a writable record");
10818
10819 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
10820 assert!(
10821 !root_refusal_path(&host.paths).exists(),
10822 "a host with nothing refused leaves nothing behind"
10823 );
10824 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
10825 }
10826
10827 #[test]
10830 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
10831 let host = Host::new();
10832 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
10833 let error = runner_root_refusals(&host.paths)
10834 .expect_err("an unparseable record is not the same as no record");
10835 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10836 }
10837
10838 #[test]
10848 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
10849 let host = Host::new();
10850 record_runner_root_refusal(
10851 &host.paths,
10852 "policy-a",
10853 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
10854 "denied_by_privacy_policy",
10855 "/Volumes/NVME/runners",
10856 "Grant Full Disk Access to the program that runs the service",
10857 )
10858 .expect("a writable record");
10859
10860 let status = host.operations().status().expect("a readable status");
10861 let notes = status.notes().join("\n");
10862
10863 assert!(
10864 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
10865 "the directory and the remediation the log had to scrub must appear here: {notes}"
10866 );
10867 assert!(
10868 notes.contains("policy-a"),
10869 "the operator has to know which target placed no runner: {notes}"
10870 );
10871 assert!(
10872 !status
10873 .problems()
10874 .iter()
10875 .any(|problem| problem.subject == "runner root"),
10876 "a record nothing an operator types can clear must not drive the exit code"
10877 );
10878 }
10879
10880 #[test]
10886 fn install_records_the_absolute_binary_path_and_the_four_directories() {
10887 let host = Host::new();
10888 let installed = host
10889 .operations()
10890 .install(&host.request(StartMode::Boot))
10891 .expect("an install against the recording controls");
10892
10893 assert_eq!(installed.record.binary, host.binary);
10894 assert!(installed.record.binary.is_absolute());
10895 assert_eq!(installed.record.start_mode, StartMode::Boot);
10896 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
10897 assert_eq!(
10898 installed.record.directories,
10899 ServiceDirectories::of(&host.paths)
10900 );
10901 assert_eq!(
10902 installed.record.log_file,
10903 host.paths.logs_dir().join(LOG_FILE_STEM)
10904 );
10905 assert_eq!(installed.record.restart_delay_secs, 15);
10906
10907 let registrations = host.controls.registrations();
10908 assert_eq!(registrations.len(), 1);
10909 assert_eq!(registrations[0].0, StartMode::Boot);
10910 assert_eq!(registrations[0].1, "runner-manager");
10911 }
10912
10913 #[test]
10914 fn install_is_refused_while_the_single_instance_lock_is_held() {
10915 let host = Host::new();
10916 {
10918 let operations = host.operations();
10919 operations
10920 .install(&host.request(StartMode::Boot))
10921 .expect("an install with the lock free");
10922 operations.uninstall().expect("a clean slate");
10923 }
10924
10925 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
10926 .expect("this process takes the lock first");
10927
10928 let error = host
10929 .operations()
10930 .install(&host.request(StartMode::Boot))
10931 .expect_err("a second agent must not be registered while one is running");
10932 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
10933 assert!(
10934 error.to_string().contains("already running"),
10935 "the message must be actionable: {error}"
10936 );
10937 assert!(
10938 host.controls.registrations().is_empty(),
10939 "a refused install must register nothing"
10940 );
10941 assert_eq!(
10942 InstallRecord::read(&host.paths).expect("readable"),
10943 None,
10944 "a refused install must write no record"
10945 );
10946 }
10947
10948 #[test]
10956 fn installing_over_the_same_start_mode_replaces_the_registration() {
10957 let host = Host::new();
10958 let operations = host.operations();
10959 operations
10960 .install(&host.request(StartMode::Boot))
10961 .expect("the first install");
10962
10963 let again = operations
10964 .install(&host.request(StartMode::Boot))
10965 .expect("an install over the same mode replaces rather than refusing");
10966
10967 assert!(
10968 again.replaced_existing,
10969 "the operator is told this replaced something rather than made it"
10970 );
10971 assert_eq!(
10972 host.controls.registrations().len(),
10973 1,
10974 "replacing must not leave two registrations behind"
10975 );
10976 assert_eq!(
10977 InstallRecord::read(&host.paths)
10978 .expect("readable")
10979 .expect("a record")
10980 .start_mode,
10981 StartMode::Boot
10982 );
10983 }
10984
10985 #[test]
10989 fn installing_over_the_other_start_mode_is_refused() {
10990 let host = Host::new();
10991 let operations = host.operations();
10992 operations
10993 .install(&host.request(StartMode::Boot))
10994 .expect("the first install");
10995
10996 let error = operations
10997 .install(&host.request(StartMode::Login))
10998 .expect_err("a mode change is not an install");
10999 assert!(
11000 matches!(
11001 error,
11002 ServiceError::AlreadyInstalled {
11003 existing: StartMode::Boot,
11004 requested: StartMode::Login,
11005 ..
11006 }
11007 ),
11008 "{error}"
11009 );
11010 assert!(
11011 !error
11012 .to_string()
11013 .contains("switch the start mode in place,"),
11014 "the old remedy named a capability no command offers; the terminal UI is where \
11015 the start mode moves: {error}"
11016 );
11017 assert_eq!(host.controls.registrations().len(), 1);
11018 }
11019
11020 #[test]
11021 fn install_rolls_back_the_registration_when_record_persistence_fails() {
11022 let host = Host::new();
11023 let record_path = InstallRecord::path(&host.paths);
11024 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11025
11026 let error = host
11027 .operations()
11028 .install(&host.request(StartMode::Boot))
11029 .expect_err("record persistence must fail");
11030
11031 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11032 assert!(
11033 host.controls.registrations().is_empty(),
11034 "a failed install must not leave a live unrecorded registration"
11035 );
11036 assert!(
11037 host.controls
11038 .calls()
11039 .iter()
11040 .any(|call| call == "uninstall runner-manager (boot)"),
11041 "the registration must be explicitly rolled back: {:?}",
11042 host.controls.calls()
11043 );
11044 assert!(
11045 !host.runner_root.as_path().exists(),
11046 "the rollback must take the runner root this install created with it; a directory \
11047 prepared for a registration that does not exist is litter, and on Windows it is \
11048 litter with a security descriptor"
11049 );
11050 }
11051
11052 #[test]
11057 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11058 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11059
11060 assert_eq!(
11064 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11065 ServiceAccount::LocalSystem
11066 );
11067 assert_eq!(
11068 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11069 ServiceAccount::InvokingUser
11070 );
11071
11072 let boot = default_root_sddl(&RootAdmission::LocalSystem);
11073 assert!(!grants_broad_write(&boot), "{boot}");
11074 assert!(
11075 !boot.contains("S-1-5-21"),
11076 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11077 );
11078
11079 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11084 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11085 assert!(!grants_broad_write(&login), "{login}");
11086 }
11087
11088 #[test]
11089 fn an_install_reports_the_runner_root_it_prepared() {
11090 let host = Host::new();
11091 let installed = host
11092 .operations()
11093 .install(&host.request(StartMode::Boot))
11094 .expect("an install");
11095 let rendered = installed.runner_root.to_string();
11096 assert!(
11097 !rendered.contains("S-1-5-21"),
11098 "the report must add no identity to the output: {rendered}"
11099 );
11100 if cfg!(windows) {
11101 assert_eq!(
11102 installed.runner_root.path(),
11103 Some(host.runner_root.as_path())
11104 );
11105 assert!(
11106 host.runner_root.as_path().is_dir(),
11107 "the directory jobs would run in has to exist once the service is registered"
11108 );
11109 } else {
11110 assert_eq!(
11111 installed.runner_root,
11112 crate::runner_root_access::RootAccessSummary::NotApplicable,
11113 "macOS and Linux keep the runtime directory they have always used"
11114 );
11115 }
11116 }
11117
11118 #[test]
11119 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11120 let host = Host::new();
11121 let operations = host.operations();
11122 operations
11123 .install(&host.request(StartMode::Boot))
11124 .expect("an install at boot");
11125
11126 let change = operations
11127 .set_start_mode(StartMode::Login)
11128 .expect("a switch to login");
11129
11130 assert!(change.changed);
11131 if cfg!(windows) {
11132 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11133 assert!(
11134 host.runner_root.as_path().is_dir(),
11135 "the switch must not remove the directory it reconciled"
11136 );
11137 }
11138 assert!(
11141 change.to_string().contains("runner root"),
11142 "{}",
11143 change.to_string()
11144 );
11145 }
11146
11147 #[test]
11148 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11149 let host = Host::new();
11150 let operations = host.operations();
11151 operations
11152 .install(&host.request(StartMode::Boot))
11153 .expect("an install at boot");
11154
11155 let change = operations
11156 .set_start_mode(StartMode::Boot)
11157 .expect("a switch to the mode already in force");
11158
11159 assert!(!change.changed);
11160 assert_eq!(
11161 change.runner_root,
11162 crate::runner_root_access::RootAccessSummary::NotApplicable,
11163 "nothing moves, so nothing about the root's access control has to; reconciling here \
11164 would turn a no-op command into one that can fail on a permission it does not need"
11165 );
11166 }
11167
11168 #[cfg(windows)]
11169 #[test]
11170 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11171 let host = Host::new();
11172 host.controls
11173 .fail_next_install(StartMode::Boot, "injected registration failure");
11174
11175 let error = host
11176 .operations()
11177 .install(&host.request(StartMode::Boot))
11178 .expect_err("the manager refuses the registration");
11179
11180 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11181 assert!(
11182 !host.runner_root.as_path().exists(),
11183 "the directory was created for a registration that does not exist"
11184 );
11185 }
11186
11187 #[cfg(windows)]
11188 #[test]
11189 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11190 let host = Host::new();
11191 crate::runner_root_access::create_with_descriptor_for_tests(
11195 host.runner_root.as_path(),
11196 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11197 )
11198 .expect("a deliberately open runner root");
11199 let before = crate::runner_root_access::report(host.runner_root.as_path());
11200
11201 let error = host
11202 .operations()
11203 .install(&host.request(StartMode::Boot))
11204 .expect_err("an open runner root is refused");
11205
11206 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11207 assert!(
11208 error.to_string().contains("nothing was registered"),
11209 "{error}"
11210 );
11211 assert!(
11212 host.controls.registrations().is_empty(),
11213 "the refusal has to come before the platform is asked to register anything: {:?}",
11214 host.controls.calls()
11215 );
11216 assert_eq!(
11217 crate::runner_root_access::report(host.runner_root.as_path()),
11218 before,
11219 "an open directory is refused rather than tightened: its contents cannot be trusted, \
11220 so adopting it would be worse than declining it"
11221 );
11222 }
11223
11224 #[cfg(windows)]
11225 #[test]
11226 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11227 let host = Host::new();
11228 let operations = host.operations();
11229 operations
11230 .install(&host.request(StartMode::Boot))
11231 .expect("an install");
11232 assert!(host.runner_root.as_path().is_dir());
11233
11234 operations.uninstall().expect("an uninstall");
11235
11236 assert!(
11237 host.runner_root.as_path().is_dir(),
11238 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11239 runner root may hold an operator's retained workspaces."
11240 );
11241 }
11242
11243 #[test]
11244 fn install_reviews_what_it_registered() {
11245 let host = Host::new();
11246 let installed = host
11247 .operations()
11248 .install(&host.request(StartMode::Boot))
11249 .expect("an install");
11250 assert!(
11251 installed.review.is_least_privilege(),
11252 "{}",
11253 installed.review
11254 );
11255 assert!(
11256 !installed.review.controls().is_empty(),
11257 "a review that confirms nothing proves nothing: {}",
11258 installed.review
11259 );
11260 assert_eq!(
11261 installed.review.kind(),
11262 host_definition_kind(StartMode::Boot),
11263 "the review must be of the definition this host's manager was given"
11264 );
11265 assert!(
11266 !installed.review.account().justification().is_empty(),
11267 "a privileged account with no stated reason is an unreviewed one"
11268 );
11269 }
11270
11271 #[test]
11276 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11277 let host = Host::new();
11278 let operations = host.operations();
11279 operations
11280 .install(&host.request(StartMode::Boot))
11281 .expect("an install");
11282
11283 let config = host.paths.config_dir();
11287 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11288 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11289 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11290 std::fs::write(
11291 host.paths
11292 .state_dir()
11293 .join("packages/2.330.0/runner.tar.gz"),
11294 b"cached package",
11295 )
11296 .expect("writable");
11297 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11298 std::fs::write(
11299 host.paths.state_dir().join("secrets/user-access-token"),
11300 b"a stand-in for the stored credential",
11301 )
11302 .expect("writable");
11303 std::fs::write(
11304 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11305 b"diagnostics",
11306 )
11307 .expect("writable");
11308
11309 let roots: Vec<PathBuf> = host
11310 .paths
11311 .all()
11312 .iter()
11313 .map(|(_, path)| (*path).to_path_buf())
11314 .collect();
11315 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11316 let before = snapshot(&roots);
11317
11318 assert!(
11321 before.len() >= 6,
11322 "the fixture must actually contain the files this test is about, got {before:#?}"
11323 );
11324 let record_path = InstallRecord::path(&host.paths);
11325 assert!(
11326 before.contains_key(&record_path),
11327 "the install record must be present before uninstall"
11328 );
11329
11330 let uninstalled = operations.uninstall().expect("an uninstall");
11331 assert!(uninstalled.removed_registration);
11332 assert!(uninstalled.removed_record);
11333
11334 let after = snapshot(&roots);
11335
11336 let mut expected = before.clone();
11338 expected.remove(&record_path);
11339 assert_eq!(
11340 after, expected,
11341 "uninstall must remove its own record and nothing else"
11342 );
11343 assert!(
11344 !record_path.exists(),
11345 "the record itself must go, or `uninstall` did nothing at all"
11346 );
11347 assert!(
11348 uninstalled
11349 .preserved
11350 .iter()
11351 .all(|path| roots.contains(&path.as_path())),
11352 "the preserved list must name the four directories: {uninstalled}"
11353 );
11354 }
11355
11356 #[test]
11357 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11358 let host = Host::new();
11359 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11360 assert!(!uninstalled.removed_registration);
11361 assert!(!uninstalled.removed_record);
11362 }
11363
11364 #[test]
11365 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11366 let host = Host::new();
11367 let operations = host.operations();
11368 operations
11369 .install(&host.request(StartMode::Boot))
11370 .expect("an install");
11371 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11372
11373 let uninstalled = operations.uninstall().expect("an uninstall");
11374 assert!(
11375 uninstalled.removed_registration,
11376 "a lost record must not strand a registration"
11377 );
11378 assert!(host.controls.registrations().is_empty());
11379 }
11380
11381 #[test]
11386 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11387 let host = Host::new();
11388 let operations = host.operations();
11389 operations
11390 .install(&host.request(StartMode::Boot))
11391 .expect("an install at boot");
11392
11393 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11397
11398 let change = operations
11399 .set_start_mode(StartMode::Login)
11400 .expect("a switch that does not reinstall the product");
11401 assert!(change.changed);
11402 assert_eq!(change.from, StartMode::Boot);
11403 assert_eq!(change.to, StartMode::Login);
11404 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11405
11406 let record = InstallRecord::read(&host.paths)
11407 .expect("readable")
11408 .expect("a record");
11409 assert_eq!(record.start_mode, StartMode::Login);
11410 assert_eq!(
11411 record.binary, host.binary,
11412 "the recorded path must survive the switch untouched"
11413 );
11414
11415 let registrations = host.controls.registrations();
11416 assert_eq!(registrations.len(), 1, "{registrations:?}");
11417 assert_eq!(registrations[0].0, StartMode::Login);
11418 assert!(
11419 registrations[0]
11420 .2
11421 .command_line
11422 .contains(&host.binary.to_string_lossy().into_owned()),
11423 "{:?}",
11424 registrations[0].2
11425 );
11426 }
11427
11428 #[test]
11429 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11430 let host = Host::new();
11431 let operations = host.operations();
11432 operations
11433 .install(&host.request(StartMode::Boot))
11434 .expect("an install at boot");
11435 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11436 host.controls
11437 .fail_next_install(StartMode::Login, "injected target failure");
11438
11439 let error = operations
11440 .set_start_mode(StartMode::Login)
11441 .expect_err("the target manager refuses the install");
11442
11443 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11444 assert_eq!(
11445 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11446 record_before
11447 );
11448 let registrations = host.controls.registrations();
11449 assert_eq!(registrations.len(), 1, "{registrations:?}");
11450 assert_eq!(registrations[0].0, StartMode::Boot);
11451 }
11452
11453 #[test]
11454 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11455 let host = Host::new();
11456 let operations = host.operations();
11457 operations
11458 .install(&host.request(StartMode::Boot))
11459 .expect("an install at boot");
11460 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11461 let config = host.paths.config_dir().to_path_buf();
11462 let hidden = config.with_file_name("config-hidden-by-fault");
11463 host.controls.hide_directory_after_install(
11464 StartMode::Login,
11465 config.clone(),
11466 hidden.clone(),
11467 );
11468
11469 let error = operations
11470 .set_start_mode(StartMode::Login)
11471 .expect_err("the injected filesystem fault prevents persistence");
11472
11473 std::fs::remove_file(&config).expect("remove the injected blocker");
11474 std::fs::rename(&hidden, &config).expect("restore the record directory");
11475 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11476 assert_eq!(
11477 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11478 record_before
11479 );
11480 let registrations = host.controls.registrations();
11481 assert_eq!(registrations.len(), 1, "{registrations:?}");
11482 assert_eq!(registrations[0].0, StartMode::Boot);
11483 assert!(
11484 host.controls
11485 .calls()
11486 .iter()
11487 .any(|call| call == "uninstall runner-manager (login)"),
11488 "the target must be rolled back: {:?}",
11489 host.controls.calls()
11490 );
11491 }
11492
11493 #[test]
11494 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11495 let host = Host::new();
11496 let operations = host.operations();
11497 operations
11498 .install(&host.request(StartMode::Boot))
11499 .expect("an install");
11500 let before = host.controls.calls().len();
11501
11502 let change = operations
11503 .set_start_mode(StartMode::Boot)
11504 .expect("a no-op switch");
11505 assert!(!change.changed);
11506 assert_eq!(
11507 host.controls.calls().len(),
11508 before,
11509 "a no-op switch must not touch the service manager"
11510 );
11511 }
11512
11513 #[test]
11514 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11515 let host = Host::new();
11516 let error = host
11517 .operations()
11518 .set_start_mode(StartMode::Login)
11519 .expect_err("there is nothing to switch");
11520 assert!(
11521 matches!(error, ServiceError::NotInstalled { .. }),
11522 "{error}"
11523 );
11524 }
11525
11526 #[test]
11531 fn status_reports_the_four_facts_journey_five_asks_for() {
11532 let host = Host::new();
11533 let operations = host.operations();
11534 operations
11535 .install(&host.request(StartMode::Boot))
11536 .expect("an install");
11537 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
11538 .expect("a valid timestamp")
11539 .with_timezone(&Utc);
11540 record_github_contact(&host.paths, at).expect("a heartbeat");
11541
11542 let status = operations.status().expect("a status");
11543 assert_eq!(status.start_mode(), Some(StartMode::Boot));
11544 assert_eq!(
11545 status.binary().map(BinaryPath::recorded),
11546 Some(host.binary.as_path())
11547 );
11548 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
11549 assert_eq!(status.last_github_contact(), Some(at));
11550 assert!(status.is_installed());
11551 assert!(status.is_healthy(), "{status}");
11552
11553 let printed = status.to_string();
11554 for fragment in [
11555 "start mode",
11556 "diagnostic log",
11557 "last GitHub contact",
11558 "binary",
11559 ] {
11560 assert!(printed.contains(fragment), "{printed}");
11561 }
11562 }
11563
11564 #[cfg(unix)]
11576 #[test]
11577 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
11578 use std::os::unix::fs::PermissionsExt as _;
11579
11580 if unsafe { libc::geteuid() } == 0 {
11584 return;
11585 }
11586
11587 let host = Host::new();
11588 let operations = host.operations();
11589 operations
11590 .install(&host.request(StartMode::Boot))
11591 .expect("an install");
11592 assert!(
11593 operations.status().expect("a status").is_healthy(),
11594 "the discriminator: healthy before the record is made unreadable"
11595 );
11596
11597 let record = InstallRecord::path(&host.paths);
11598 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
11599 .expect("the mode is applied");
11600
11601 let status = operations
11602 .status()
11603 .expect("a record this account may not read is reported, not thrown");
11604 assert!(status.is_installed(), "{status}");
11605 assert!(!status.is_healthy(), "{status}");
11606
11607 let printed = status.to_string();
11608 assert!(
11609 printed.contains("this account may not read it"),
11610 "the operator is told which of the two states this is: {printed}"
11611 );
11612 assert!(
11613 !printed.contains("there is no install record"),
11614 "a record that is there and unreadable is not a record that is missing, and the \
11615 missing one's remedy starts with `service uninstall`: {printed}"
11616 );
11617
11618 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
11621 .expect("the mode is restored");
11622 }
11623
11624 #[test]
11625 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
11626 let host = Host::new();
11627 let operations = host.operations();
11628 operations
11629 .install(&host.request(StartMode::Boot))
11630 .expect("an install");
11631
11632 assert!(
11635 operations.status().expect("a status").is_healthy(),
11636 "the freshly installed host must be healthy"
11637 );
11638
11639 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
11640
11641 let status = operations.status().expect("a status");
11642 assert!(!status.is_healthy(), "{status}");
11643 assert!(
11644 status
11645 .problems()
11646 .iter()
11647 .any(|problem| problem.subject == "binary"),
11648 "{status}"
11649 );
11650 assert!(status.to_string().contains("STALE"), "{status}");
11651 }
11652
11653 #[test]
11654 fn status_reports_a_registration_that_would_not_start_at_boot() {
11655 let host = Host::new();
11656 let operations = host.operations();
11657 operations
11658 .install(&host.request(StartMode::Boot))
11659 .expect("an install");
11660 assert!(operations.status().expect("a status").is_healthy());
11661
11662 host.controls.edit("runner-manager", |registration| {
11663 registration.starts_automatically = false;
11664 });
11665
11666 let status = operations.status().expect("a status");
11667 assert!(!status.is_healthy(), "{status}");
11668 assert!(
11669 status
11670 .problems()
11671 .iter()
11672 .any(|problem| problem.detail.contains("after a reboot")),
11673 "{status}"
11674 );
11675 }
11676
11677 #[test]
11678 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
11679 let host = Host::new();
11680 let operations = host.operations();
11681 operations
11682 .install(&host.request(StartMode::Login))
11683 .expect("install and immediate start");
11684
11685 assert!(
11686 operations.status().expect("running status").is_running(),
11687 "install must not wait for the next login trigger"
11688 );
11689 operations.stop().expect("stop the registration");
11690 let status = operations.status().expect("stopped status");
11691 assert!(!status.is_healthy(), "{status}");
11692 assert!(
11693 status
11694 .problems()
11695 .iter()
11696 .any(|problem| problem.subject == "runtime"),
11697 "{status}"
11698 );
11699 let rendered = status.to_string();
11700 assert!(
11701 rendered.contains("runner-manager service install --start-at login"),
11702 "{rendered}"
11703 );
11704 assert!(
11705 !rendered.contains("runner-manager service start"),
11706 "{rendered}"
11707 );
11708 }
11709
11710 #[test]
11711 fn status_reports_a_restart_policy_something_else_edited() {
11712 let host = Host::new();
11713 let operations = host.operations();
11714 operations
11715 .install(&host.request(StartMode::Boot))
11716 .expect("an install");
11717 assert!(operations.status().expect("a status").is_healthy());
11718
11719 host.controls.edit("runner-manager", |registration| {
11720 registration.restart_delay = Some(Duration::from_secs(1));
11721 });
11722
11723 let status = operations.status().expect("a status");
11724 assert!(!status.is_healthy(), "{status}");
11725 assert!(
11726 status
11727 .problems()
11728 .iter()
11729 .any(|problem| problem.subject == "restart policy"),
11730 "{status}"
11731 );
11732 }
11733
11734 #[test]
11735 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
11736 let host = Host::new();
11737 let operations = host.operations();
11738 operations
11739 .install(&host.request(StartMode::Boot))
11740 .expect("an install");
11741 let other = host.binary.with_file_name("someone-elses.exe");
11742 std::fs::write(&other, b"x").expect("writable");
11743
11744 host.controls.edit("runner-manager", |registration| {
11745 registration.command_line = quote_argument(&other.to_string_lossy());
11746 });
11747
11748 let status = operations.status().expect("a status");
11749 assert!(!status.is_healthy(), "{status}");
11750 assert!(
11751 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11752 "{status}"
11753 );
11754 }
11755
11756 #[test]
11757 fn status_reports_a_record_no_service_manager_knows_about() {
11758 let host = Host::new();
11759 let operations = host.operations();
11760 operations
11761 .install(&host.request(StartMode::Boot))
11762 .expect("an install");
11763 for mode in [StartMode::Boot, StartMode::Login] {
11765 host.controls
11766 .control(mode)
11767 .expect("a control")
11768 .uninstall(&ServiceIdentity::product())
11769 .expect("removed");
11770 }
11771
11772 let status = operations.status().expect("a status");
11773 assert!(!status.is_healthy(), "{status}");
11774 assert!(
11775 status
11776 .problems()
11777 .iter()
11778 .any(|problem| problem.subject == "registration"),
11779 "{status}"
11780 );
11781 }
11782
11783 #[test]
11784 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
11785 let host = Host::new();
11786 let status = host.operations().status().expect("a status");
11787 assert!(!status.is_installed());
11788 assert!(
11789 status.is_healthy(),
11790 "a host that never installed the service has no fault to report: {status}"
11791 );
11792 assert!(status.to_string().contains("installed"), "{status}");
11793 }
11794
11795 #[test]
11796 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
11797 let host = Host::new();
11798 let operations = host.operations();
11799 operations
11800 .install(&host.request(StartMode::Login))
11801 .expect("an install at login");
11802 let status = operations.status().expect("a status");
11803 assert!(
11804 status
11805 .notes()
11806 .iter()
11807 .any(|note| note.contains("does not run until the operator signs in")),
11808 "05-infrastructure.md requires `service status` to say so: {status}"
11809 );
11810 }
11811
11812 #[test]
11815 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
11816 use crate::unattended_login::{AutoLogin, UnattendedLogin};
11817 let automatic = UnattendedLogin {
11818 auto_login: AutoLogin::As("ivan".into()),
11819 filevault_on: Some(false),
11820 };
11821 assert_eq!(
11822 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
11823 None
11824 );
11825
11826 let off = UnattendedLogin {
11827 auto_login: AutoLogin::Off,
11828 filevault_on: Some(false),
11829 };
11830 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
11831 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
11832 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
11833
11834 let locked = UnattendedLogin {
11835 auto_login: AutoLogin::As("ivan".into()),
11836 filevault_on: Some(true),
11837 };
11838 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
11839 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
11840
11841 let unread = UnattendedLogin {
11844 auto_login: AutoLogin::As("ivan".into()),
11845 filevault_on: None,
11846 };
11847 assert_eq!(
11848 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
11849 None
11850 );
11851
11852 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
11854 assert!(
11855 gap.reason
11856 .contains("does not run until the operator signs in")
11857 );
11858 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
11859 }
11860
11861 #[test]
11864 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
11865 let host = Host::new();
11866 let operations = host.operations();
11867 operations
11868 .install(&host.request(StartMode::Login))
11869 .expect("an install at login");
11870 let stalled_problem = |operations: &ServiceOperations| {
11871 operations
11872 .status()
11873 .expect("a status")
11874 .problems()
11875 .iter()
11876 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
11877 .map(|problem| problem.detail.clone())
11878 };
11879 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
11880 assert_eq!(stalled_problem(&operations), None, "beating");
11881
11882 crate::daemon_heartbeat::beat(
11883 &operations.paths,
11884 Utc::now() - chrono::Duration::minutes(20),
11885 )
11886 .unwrap();
11887 let detail = stalled_problem(&operations).expect("a stall is a problem");
11888 assert!(detail.contains("has made no progress since"), "{detail}");
11889 let restart = ServiceIdentity::product().restart_command(StartMode::Login);
11892 assert!(detail.contains(&restart), "{detail}");
11893 assert!(!detail.contains("runner-manager service stop"), "{detail}");
11894 assert!(!detail.contains("runner-manager service start"), "{detail}");
11895 }
11896
11897 #[test]
11901 fn the_restart_command_is_the_service_managers_own() {
11902 let identity = ServiceIdentity::product();
11903 let boot = identity.restart_command(StartMode::Boot);
11904 let login = identity.restart_command(StartMode::Login);
11905 for command in [&boot, &login] {
11906 assert!(!command.contains("runner-manager service"), "{command}");
11907 assert!(command.contains(identity.name()), "{command}");
11908 }
11909 assert_ne!(boot, login);
11910 if cfg!(windows) {
11911 assert!(
11912 boot.starts_with("Restart-Service -Name runner-manager"),
11913 "{boot}"
11914 );
11915 assert!(
11916 login.contains("schtasks /Run /TN runner-manager"),
11917 "{login}"
11918 );
11919 } else if cfg!(target_os = "macos") {
11920 assert!(
11921 boot.starts_with("sudo launchctl kickstart -k system/"),
11922 "{boot}"
11923 );
11924 assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
11925 } else {
11926 assert_eq!(boot, "sudo systemctl restart runner-manager.service");
11927 assert_eq!(login, "systemctl --user restart runner-manager.service");
11928 }
11929 }
11930
11931 #[test]
11933 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
11934 let host = Host::new();
11935 let operations = host.operations();
11936 operations
11937 .install(&host.request(StartMode::Login))
11938 .expect("an install at login");
11939 let paths = operations.paths.clone();
11940 crate::host_fitness::record_host_unfit(
11941 &paths,
11942 &[crate::host_fitness::UnfitFinding {
11943 id: "host.runner_root_responsive".into(),
11944 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
11945 remedy: Some("click Allow".into()),
11946 }],
11947 Utc::now(),
11948 )
11949 .unwrap();
11950 let status = operations.status().expect("a status");
11951 assert!(!status.is_healthy(), "{status}");
11952 let problem = status
11953 .problems()
11954 .iter()
11955 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
11956 .expect("the refusal is a problem");
11957 assert!(
11958 problem.detail.contains("/Volumes/NVME/runners"),
11959 "{problem:?}"
11960 );
11961 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
11962
11963 crate::host_fitness::clear_host_unfit(&paths).unwrap();
11966 crate::host_fitness::record_host_unfit(
11967 &paths,
11968 &[],
11969 Utc::now() - chrono::Duration::hours(2),
11970 )
11971 .unwrap();
11972 let status = operations.status().expect("a status");
11973 assert!(
11974 !status
11975 .problems()
11976 .iter()
11977 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
11978 "{status}"
11979 );
11980 }
11981
11982 #[test]
11987 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
11988 let host = Host::new();
11989 let operations = host.operations();
11990 operations
11991 .install(&host.request(StartMode::Login))
11992 .expect("an install at login");
11993 operations.start().expect("a start");
11994 assert!(operations.status().expect("a status").is_running());
11995 assert!(operations.stop().expect("a stop"));
11996 assert!(!operations.status().expect("a status").is_running());
11997 }
11998
11999 #[test]
12000 fn starting_a_host_with_no_registration_is_refused() {
12001 let host = Host::new();
12002 let error = host.operations().start().expect_err("nothing to start");
12003 assert!(
12004 matches!(error, ServiceError::NotInstalled { .. }),
12005 "{error}"
12006 );
12007 }
12008
12009 #[cfg(windows)]
12025 #[test]
12026 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12027 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12028
12029 let root = tempfile::tempdir().expect("a temporary directory");
12030 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12031 .expect("a rooted machine-scoped store");
12032 store
12033 .store(&secrecy::SecretString::from("a stand-in for the token"))
12034 .expect("the store accepts a value");
12035 let protection = store.protection().expect("the DACL can be read back");
12036
12037 assert!(
12038 protection.description().contains(";;;SY)"),
12039 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12040 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12041 protection.description()
12042 );
12043 assert_eq!(
12044 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12045 ServiceAccount::LocalSystem,
12046 "and that is the account this installer registers, which is why SY is what matters"
12047 );
12048 assert!(
12049 !protection.readable_by_other_local_users(),
12050 "the same DACL must still exclude ordinary local users: {}",
12051 protection.description()
12052 );
12053
12054 for rejected in [";;;LS)", ";;;NS)"] {
12058 assert!(
12059 !protection.description().contains(rejected),
12060 "if the store ever admitted {rejected}, the least-privilege analysis in \
12061 docs/service-account.md would need redoing: {}",
12062 protection.description()
12063 );
12064 }
12065 }
12066
12067 fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12072 let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12073 arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12074 host.request(mode).with_arguments(arguments)
12075 }
12076
12077 fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12080 ServiceOperations::with_controls(
12081 account.paths.clone(),
12082 ServiceIdentity::product(),
12083 std::sync::Arc::new(machine.controls.clone()),
12084 )
12085 .with_runner_root(account.runner_root.clone())
12086 }
12087
12088 fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12092 let owner = Host::new();
12093 owner
12094 .operations()
12095 .install(&request_with_directories(&owner, StartMode::Boot))
12096 .expect("the owner's boot install");
12097 let earlier = Host::new();
12098 earlier
12099 .operations()
12100 .install(&request_with_directories(&earlier, StartMode::Login))
12101 .expect("the second account's earlier login install");
12102 assert!(
12103 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12104 "the earlier install left its record behind"
12105 );
12106 (owner, earlier)
12107 }
12108
12109 const DESTRUCTIVE_ADVICE: [&str; 4] = [
12112 "service uninstall",
12113 "service install`",
12114 "service install --",
12115 "auth login",
12116 ];
12117
12118 #[test]
12119 fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12120 let (owner, earlier) = a_machine_with_a_stale_second_account();
12121
12122 let status = second_account(&earlier, &owner).status().expect("a status");
12123
12124 let foreign = status.foreign().expect("the service is the owner's");
12125 assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12126 assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12127 assert!(foreign.unreadable.is_none(), "{status}");
12128 assert!(
12129 status.is_healthy(),
12130 "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12131 {status}"
12132 );
12133 for subject in [
12134 "binary",
12135 "start mode",
12136 "secret store",
12137 "record",
12138 "registration",
12139 ] {
12140 assert!(
12141 !status
12142 .problems()
12143 .iter()
12144 .any(|problem| problem.subject == subject),
12145 "{subject} compared the second account's record against the owner's \
12146 registration: {status}"
12147 );
12148 }
12149 let rendered = status.to_string();
12150 for advice in DESTRUCTIVE_ADVICE {
12151 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12152 }
12153 assert!(rendered.contains("another installation's"), "{rendered}");
12154 assert!(
12155 status
12156 .notes()
12157 .iter()
12158 .any(|note| note.contains("unused local configuration")
12159 && note.contains("host forget-local")),
12160 "{status}"
12161 );
12162 assert_eq!(status.start_mode(), Some(StartMode::Boot));
12164 }
12165
12166 #[test]
12167 fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12168 let (owner, earlier) = a_machine_with_a_stale_second_account();
12169 InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12172
12173 let operations = second_account(&earlier, &owner);
12174 let status = operations.status().expect("a status");
12175 let foreign = status.foreign().expect("the service is the owner's");
12176 assert!(foreign.unreadable.is_some(), "{status}");
12177 assert!(
12178 status.is_healthy(),
12179 "running, and nothing it could judge: {status}"
12180 );
12181 let rendered = status.to_string();
12182 for advice in DESTRUCTIVE_ADVICE {
12183 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12184 }
12185 assert!(rendered.contains("running; installed from"), "{rendered}");
12186 assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12187
12188 owner.operations().stop().expect("stopped");
12190 let stopped = operations.status().expect("a status");
12191 assert!(!stopped.is_healthy(), "{stopped}");
12192 let rendered = stopped.to_string();
12193 for advice in DESTRUCTIVE_ADVICE {
12194 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12195 }
12196 }
12197
12198 #[test]
12199 fn this_accounts_own_registration_still_reports_every_mismatch() {
12200 let host = Host::new();
12201 let operations = host.operations();
12202 operations
12203 .install(&request_with_directories(&host, StartMode::Boot))
12204 .expect("an install");
12205 let other = host.binary.with_file_name("someone-elses");
12206 std::fs::write(&other, b"x").expect("writable");
12207 host.controls.edit("runner-manager", |registration| {
12209 let mut arguments = split_command_line(®istration.command_line);
12210 arguments[0] = other.to_string_lossy().into_owned();
12211 registration.command_line = arguments
12212 .iter()
12213 .map(|argument| quote_argument(argument))
12214 .collect::<Vec<_>>()
12215 .join(" ");
12216 registration.start_mode = StartMode::Login;
12217 });
12218
12219 let status = operations.status().expect("a status");
12220 assert!(status.foreign().is_none(), "{status}");
12221 assert!(
12222 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12223 "{status}"
12224 );
12225 assert!(
12226 status
12227 .problems()
12228 .iter()
12229 .any(|problem| problem.subject == "start mode"),
12230 "{status}"
12231 );
12232 assert!(status.to_string().contains("service uninstall"), "{status}");
12233 }
12234
12235 #[test]
12236 fn directories_read_back_from_a_command_line_with_spaces() {
12237 let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12238 let directories = ServiceDirectories {
12239 config: root.join("config"),
12240 state: root.join("data").join("state"),
12241 runtime: root.join("data").join("runtime"),
12242 logs: root.join("data").join("logs"),
12243 };
12244 let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12245 for argument in ["daemon", "run"]
12246 .map(OsString::from)
12247 .into_iter()
12248 .chain(directories.arguments())
12249 {
12250 command_line.push(' ');
12251 command_line.push_str("e_argument(&argument.to_string_lossy()));
12252 }
12253 assert_eq!(
12254 ServiceDirectories::from_command_line(&command_line),
12255 Some(directories.clone())
12256 );
12257 assert_eq!(directories.root(), root);
12258 assert_eq!(
12259 ServiceDirectories::from_command_line("runner-manager daemon run"),
12260 None,
12261 "a registration that names no directories cannot be judged foreign"
12262 );
12263 }
12264
12265 #[test]
12266 fn the_owner_of_a_data_root_is_read_from_its_path() {
12267 let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12268 let home = users.join("ivand");
12269 assert_eq!(
12270 data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12271 Some("yuriv")
12272 );
12273 assert_eq!(
12274 data_root_owner(
12275 Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12276 Some(&home)
12277 ),
12278 None
12279 );
12280 if cfg!(windows) {
12281 assert_eq!(
12282 data_root_owner(
12283 Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12284 Some(&home)
12285 )
12286 .as_deref(),
12287 Some("LocalSystem")
12288 );
12289 } else {
12290 assert_eq!(
12291 data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12292 Some("root")
12293 );
12294 }
12295 }
12296
12297 #[test]
12298 fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12299 let (owner, earlier) = a_machine_with_a_stale_second_account();
12300 let owner_before = snapshot(&[owner._root.path()]);
12301 let config = earlier.paths.config_dir().to_path_buf();
12302 let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12303
12304 let forgotten = second_account(&earlier, &owner)
12305 .forget_local(Utc::now())
12306 .expect("archived");
12307
12308 assert_eq!(
12309 snapshot(&[owner._root.path()]),
12310 owner_before,
12311 "the owner's directories must not change by one byte"
12312 );
12313 let archive = forgotten.archive.expect("something was archived");
12314 assert!(archive.starts_with(&config), "{}", archive.display());
12315 assert_eq!(
12316 std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12317 record,
12318 "moved, not rewritten or deleted"
12319 );
12320 assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12321 assert_eq!(
12322 forgotten.service_root,
12323 Some(ServiceDirectories::of(&owner.paths).root())
12324 );
12325 let status = second_account(&earlier, &owner).status().expect("status");
12327 assert!(
12328 !status
12329 .notes()
12330 .iter()
12331 .any(|note| note.contains("unused local configuration")),
12332 "{status}"
12333 );
12334 }
12335
12336 #[test]
12337 fn forget_local_refuses_a_configuration_a_service_runs_against() {
12338 let (owner, earlier) = a_machine_with_a_stale_second_account();
12339 let before = snapshot(&[owner._root.path()]);
12340 let refused = owner.operations().forget_local(Utc::now());
12341 assert!(
12342 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12343 "{refused:?}"
12344 );
12345 assert_eq!(snapshot(&[owner._root.path()]), before);
12346
12347 let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12349 .expect("the agent's lock");
12350 let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12351 assert!(
12352 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12353 "{refused:?}"
12354 );
12355 assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12356 }
12357
12358 #[test]
12359 fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12360 let (owner, earlier) = a_machine_with_a_stale_second_account();
12361 owner.operations().stop().expect("stopped");
12364 record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12365 record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12366
12367 let status = second_account(&earlier, &owner).status().expect("a status");
12368 assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12369 let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12370 for subject in [
12371 "runtime",
12372 "github credential",
12373 CREDENTIAL_UNREADABLE_SUBJECT,
12374 ] {
12375 assert!(subjects.contains(&subject), "{subject}: {status}");
12376 }
12377 let rendered = status.to_string();
12378 for advice in DESTRUCTIVE_ADVICE {
12379 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12380 }
12381 for problem in status.problems() {
12382 assert!(problem.detail.contains("Act on it"), "{problem}");
12383 }
12384 }
12385
12386 #[test]
12387 fn no_command_of_this_account_changes_another_installations_service() {
12388 let (owner, earlier) = a_machine_with_a_stale_second_account();
12389 let operations = second_account(&earlier, &owner);
12390 let foreign = |result: Result<(), ServiceError>| {
12391 assert!(
12392 matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12393 "{result:?}"
12394 );
12395 };
12396 foreign(
12397 operations
12398 .install(&request_with_directories(&earlier, StartMode::Boot))
12399 .map(drop),
12400 );
12401 foreign(operations.uninstall().map(drop));
12402 foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12403 foreign(operations.start());
12404 foreign(operations.stop().map(drop));
12405
12406 let registration = owner
12407 .controls
12408 .control(StartMode::Boot)
12409 .unwrap()
12410 .query(&ServiceIdentity::product())
12411 .unwrap()
12412 .expect("the owner's registration is still there");
12413 assert_eq!(
12414 ServiceDirectories::from_command_line(®istration.command_line),
12415 Some(ServiceDirectories::of(&owner.paths)),
12416 "and still runs against the owner's directories"
12417 );
12418 assert!(registration.running, "and was never stopped");
12419 assert!(
12420 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12421 "the refused uninstall kept this account's record"
12422 );
12423 }
12424
12425 #[test]
12426 fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12427 let root = tempfile::tempdir().unwrap();
12430 let config = root.path().join("Application Support");
12431 let paths = AppPaths::from_directories(
12432 &config,
12433 config.join("state"),
12434 config.join("runtime"),
12435 config.join("logs"),
12436 );
12437 paths.create_all().unwrap();
12438 std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12439 std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12440 let operations = ServiceOperations::with_controls(
12441 paths.clone(),
12442 ServiceIdentity::product(),
12443 std::sync::Arc::new(RecordingControls::new()),
12444 );
12445
12446 let forgotten = operations.forget_local(Utc::now()).expect("archived");
12447
12448 assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12449 for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12450 assert!(kept.is_dir(), "{} was moved", kept.display());
12451 }
12452 assert!(paths.logs_dir().join("runner-manager.log").is_file());
12453 }
12454
12455 #[test]
12456 fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12457 for (detail, diagnosis) in [
12458 (
12459 "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12460 service install --start-at boot`; if the service manager denies replacement",
12461 "x holds an automatic registration, but the daemon is stopped",
12462 ),
12463 (
12464 "store -- MISMATCH. Run `auth login` again so the token is stored",
12465 "store -- MISMATCH",
12466 ),
12467 (
12468 "unit differs, so the daemon runs under outdated settings. Re-register it with \
12469 `sudo runner-manager service install --start-at boot`",
12470 "unit differs, so the daemon runs under outdated settings",
12471 ),
12472 (
12473 "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12474 "it starts no runner; the log has it",
12475 ),
12476 (
12477 "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12478 install`",
12479 "C:\\a\\runner-manager.exe -- STALE: moved",
12480 ),
12481 (
12482 "a new builder: run `runner-manager service install` here, or not",
12483 "the installation's own records report this",
12484 ),
12485 ] {
12486 assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12487 }
12488 }
12489}