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] = [
1777 "NoNewPrivileges=yes",
1778 "CapabilityBoundingSet=",
1779 "AmbientCapabilities=",
1780 "PrivateTmp=yes",
1781 "PrivateDevices=yes",
1782 "ProtectSystem=strict",
1783 "ProtectKernelTunables=yes",
1784 "ProtectKernelModules=yes",
1785 "ProtectControlGroups=yes",
1786 "RestrictNamespaces=yes",
1787 "RestrictRealtime=yes",
1788 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1789];
1790
1791pub const BOOT_CAPABILITIES: &str = boot_capabilities!();
1805
1806macro_rules! boot_capabilities {
1809 () => {
1810 "CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_SETGID CAP_SETUID"
1811 };
1812}
1813use boot_capabilities;
1814
1815fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1824 let mut directives = SYSTEMD_HARDENING.to_vec();
1825 if mode == StartMode::Boot {
1826 if let Some(directive) = directives
1827 .iter_mut()
1828 .find(|directive| **directive == "CapabilityBoundingSet=")
1829 {
1830 *directive = concat!("CapabilityBoundingSet=", boot_capabilities!());
1831 }
1832 directives.push("ProtectHome=tmpfs");
1833 }
1834 directives
1835}
1836
1837#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1840const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1841
1842#[cfg(any(all(test, unix), target_os = "linux"))]
1845fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1846 let occupied = match std::fs::read_dir(path) {
1847 Ok(mut entries) => entries.next().is_some(),
1848 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1849 Err(error) => return Err(error),
1850 };
1851 if occupied {
1852 let name = path.file_name().map_or_else(
1853 || std::borrow::Cow::Borrowed("directory"),
1854 |name| name.to_string_lossy(),
1855 );
1856 std::fs::rename(
1857 path,
1858 path.with_file_name(format!("{name}.before-move-{stamp}")),
1859 )?;
1860 }
1861 Ok(())
1862}
1863
1864#[cfg(any(all(test, unix), target_os = "linux"))]
1868fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
1869 use std::os::unix::fs::DirBuilderExt as _;
1870
1871 std::fs::DirBuilder::new()
1872 .recursive(true)
1873 .mode(0o700)
1874 .create(to)?;
1875 for entry in std::fs::read_dir(from)? {
1876 let entry = entry?;
1877 if skip.is_some_and(|skip| entry.file_name() == skip) {
1878 continue;
1879 }
1880 let source = entry.path();
1881 let target = to.join(entry.file_name());
1882 let kind = entry.file_type()?;
1883 if kind.is_symlink() {
1884 std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
1885 } else if kind.is_dir() {
1886 copy_tree(&source, &target, None)?;
1887 } else {
1888 std::fs::copy(&source, &target)?;
1889 }
1890 }
1891 Ok(())
1892}
1893
1894#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1901fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
1902 if let Some(root) = SYSTEMD_HOME_ROOTS
1903 .iter()
1904 .find(|root| path.starts_with(root))
1905 {
1906 return Some(format!(
1907 "{} is under {root}, which a boot service cannot see",
1908 path.display()
1909 ));
1910 }
1911 match owner {
1912 Some(0) => None,
1913 Some(uid) => Some(format!(
1914 "{} belongs to uid {uid}, and a boot service runs as root with no capability \
1915 to open another account's directory",
1916 path.display()
1917 )),
1918 None => Some(format!("{} cannot be inspected", path.display())),
1919 }
1920}
1921
1922pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1946
1947#[must_use]
1954pub fn launchd_plist(plan: &InstallPlan) -> String {
1955 let identity = plan.identity();
1956 let directories = plan.directories();
1957 let mut out = String::new();
1958 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1959 out.push_str(
1960 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1961 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1962 );
1963 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1964 out.push_str(&plist_string("Label", &identity.launchd_label()));
1965
1966 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1967 out.push_str(&format!(
1968 " <string>{}</string>\n",
1969 xml_escape(&plan.binary().to_string_lossy())
1970 ));
1971 for argument in plan.arguments() {
1972 out.push_str(&format!(
1973 " <string>{}</string>\n",
1974 xml_escape(&argument.to_string_lossy())
1975 ));
1976 }
1977 out.push_str(" </array>\n");
1978
1979 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1980 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1981 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1982 out.push_str(" </dict>\n");
1983 out.push_str(&format!(
1984 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1985 plan.restart().delay().as_secs()
1986 ));
1987 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1988 out.push_str(&plist_string(
1989 "WorkingDirectory",
1990 &directories.state.to_string_lossy(),
1991 ));
1992 out.push_str(&plist_string(
1993 "StandardOutPath",
1994 &directories
1995 .logs
1996 .join("runner-manager.launchd.out.log")
1997 .to_string_lossy(),
1998 ));
1999 out.push_str(&plist_string(
2000 "StandardErrorPath",
2001 &directories
2002 .logs
2003 .join("runner-manager.launchd.err.log")
2004 .to_string_lossy(),
2005 ));
2006
2007 match plan.start_mode() {
2008 StartMode::Boot => {
2009 out.push_str(&plist_string(
2012 "UserName",
2013 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
2014 .as_str(),
2015 ));
2016 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
2018 }
2019 StartMode::Login => {
2020 }
2024 }
2025
2026 out.push_str("</dict>\n</plist>\n");
2027 out
2028}
2029
2030fn plist_string(key: &str, value: &str) -> String {
2032 format!(
2033 " <key>{}</key>\n <string>{}</string>\n",
2034 xml_escape(key),
2035 xml_escape(value)
2036 )
2037}
2038
2039#[derive(Debug, Clone, PartialEq, Eq)]
2047pub struct TaskPrincipal {
2048 user_id: String,
2049}
2050
2051impl TaskPrincipal {
2052 pub fn current() -> Result<Self, ServiceError> {
2060 let user = std::env::var("USERNAME")
2061 .ok()
2062 .filter(|value| !value.trim().is_empty());
2063 let Some(user) = user else {
2064 return Err(ServiceError::Control {
2065 operation: "identify the account for",
2066 name: SERVICE_NAME.to_string(),
2067 manager: "Windows Task Scheduler",
2068 detail: "this session reports no %USERNAME%, so there is no principal to \
2069 register a logon-triggered task for"
2070 .to_string(),
2071 });
2072 };
2073 let domain = std::env::var("USERDOMAIN")
2074 .ok()
2075 .filter(|value| !value.trim().is_empty());
2076 Ok(Self {
2077 user_id: match domain {
2078 Some(domain) => format!("{domain}\\{user}"),
2079 None => user,
2080 },
2081 })
2082 }
2083
2084 #[must_use]
2086 pub fn named(user_id: impl Into<String>) -> Self {
2087 Self {
2088 user_id: user_id.into(),
2089 }
2090 }
2091
2092 #[must_use]
2094 pub fn user_id(&self) -> &str {
2095 &self.user_id
2096 }
2097}
2098
2099#[must_use]
2106pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2107 let identity = plan.identity();
2108 let user = xml_escape(principal.user_id());
2109 let arguments = plan
2110 .arguments()
2111 .iter()
2112 .map(|argument| quote_argument(&argument.to_string_lossy()))
2113 .collect::<Vec<_>>()
2114 .join(" ");
2115 let mut out = String::new();
2116 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2117 out.push_str(
2118 "<Task version=\"1.4\" \
2119 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2120 );
2121 out.push_str(" <RegistrationInfo>\n");
2122 out.push_str(&format!(
2123 " <Description>{}</Description>\n",
2124 xml_escape(identity.description())
2125 ));
2126 out.push_str(&format!(
2127 " <URI>\\{}</URI>\n",
2128 xml_escape(identity.name())
2129 ));
2130 out.push_str(" </RegistrationInfo>\n");
2131
2132 out.push_str(" <Triggers>\n <LogonTrigger>\n");
2133 out.push_str(" <Enabled>true</Enabled>\n");
2134 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2135 out.push_str(" </LogonTrigger>\n </Triggers>\n");
2136
2137 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
2138 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2139 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
2140 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
2141 out.push_str(" </Principal>\n </Principals>\n");
2142
2143 out.push_str(" <Settings>\n");
2144 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2148 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2149 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2150 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
2151 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
2152 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2153 out.push_str(" <IdleSettings>\n");
2154 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2155 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
2156 out.push_str(" </IdleSettings>\n");
2157 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2158 out.push_str(" <Enabled>true</Enabled>\n");
2159 out.push_str(" <Hidden>false</Hidden>\n");
2160 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2161 out.push_str(" <WakeToRun>false</WakeToRun>\n");
2162 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2164 out.push_str(" <Priority>7</Priority>\n");
2165 out.push_str(" <RestartOnFailure>\n");
2166 out.push_str(&format!(
2167 " <Interval>{}</Interval>\n",
2168 iso8601_minutes(
2169 plan.restart()
2170 .effective_delay(DefinitionKind::WindowsScheduledTask)
2171 )
2172 ));
2173 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
2174 out.push_str(" </RestartOnFailure>\n");
2175 out.push_str(" </Settings>\n");
2176
2177 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
2178 out.push_str(&format!(
2179 " <Command>{}</Command>\n",
2180 xml_escape(&plan.binary().to_string_lossy())
2181 ));
2182 if !arguments.is_empty() {
2183 out.push_str(&format!(
2184 " <Arguments>{}</Arguments>\n",
2185 xml_escape(&arguments)
2186 ));
2187 }
2188 out.push_str(&format!(
2189 " <WorkingDirectory>{}</WorkingDirectory>\n",
2190 xml_escape(&plan.directories().state.to_string_lossy())
2191 ));
2192 out.push_str(" </Exec>\n </Actions>\n");
2193 out.push_str("</Task>\n");
2194 out
2195}
2196
2197fn iso8601_minutes(duration: Duration) -> String {
2200 format!("PT{}M", duration.as_secs() / 60)
2201}
2202
2203pub(crate) fn xml_escape(value: &str) -> String {
2211 let mut out = String::with_capacity(value.len());
2212 for c in value.chars() {
2213 match c {
2214 '&' => out.push_str("&"),
2215 '<' => out.push_str("<"),
2216 '>' => out.push_str(">"),
2217 '"' => out.push_str("""),
2218 '\'' => out.push_str("'"),
2219 other => out.push(other),
2220 }
2221 }
2222 out
2223}
2224
2225fn xml_unescape(value: &str) -> String {
2231 value
2232 .replace("<", "<")
2233 .replace(">", ">")
2234 .replace(""", "\"")
2235 .replace("'", "'")
2236 .replace("&", "&")
2237}
2238
2239#[derive(Debug, Clone, PartialEq, Eq)]
2248pub struct WindowsServiceSpec {
2249 pub name: String,
2251 pub display_name: String,
2253 pub description: String,
2255 pub automatic_start: bool,
2257 pub account: Option<String>,
2260 pub command_line: String,
2262 pub restart: RestartPolicy,
2264}
2265
2266#[must_use]
2268pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2269 WindowsServiceSpec {
2270 name: plan.identity().name().to_string(),
2271 display_name: plan.identity().display_name().to_string(),
2272 description: plan.identity().description().to_string(),
2273 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2278 account: match ServiceAccount::for_definition(
2279 DefinitionKind::WindowsService,
2280 plan.start_mode(),
2281 ) {
2282 ServiceAccount::LocalSystem => None,
2285 other => Some(other.as_str().to_string()),
2286 },
2287 command_line: plan.command_line(),
2288 restart: plan.restart(),
2289 }
2290}
2291
2292#[must_use]
2295fn windows_service_descriptor(plan: &InstallPlan) -> String {
2296 let spec = windows_service_spec(plan);
2297 let mut out = String::new();
2298 out.push_str("[windows-service]\n");
2299 out.push_str(&format!("Name={}\n", spec.name));
2300 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2301 out.push_str(&format!("Description={}\n", spec.description));
2302 out.push_str("ServiceType=OWN_PROCESS\n");
2306 out.push_str(&format!(
2307 "StartType={}\n",
2308 if spec.automatic_start {
2309 "AutoStart"
2310 } else {
2311 "OnDemand"
2312 }
2313 ));
2314 out.push_str("ErrorControl=Normal\n");
2315 out.push_str(&format!(
2316 "Account={}\n",
2317 spec.account
2318 .as_deref()
2319 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2320 ));
2321 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2322 out.push_str(&format!(
2323 "FailureActionRestartDelaySecs={}\n",
2324 spec.restart.delay().as_secs()
2325 ));
2326 out.push_str(&format!(
2327 "FailureActionsResetPeriodSecs={}\n",
2328 spec.restart.reset_after().as_secs()
2329 ));
2330 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2334 out.push_str(&format!(
2335 "ReadWritePaths={}\n",
2336 plan.directories()
2337 .all()
2338 .iter()
2339 .map(|path| quote_argument(&path.to_string_lossy()))
2340 .collect::<Vec<_>>()
2341 .join(" ")
2342 ));
2343 out
2344}
2345
2346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2352pub enum FindingKind {
2353 Excess,
2356 Shortfall,
2359}
2360
2361impl fmt::Display for FindingKind {
2362 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2363 f.write_str(match self {
2364 Self::Excess => "excess",
2365 Self::Shortfall => "shortfall",
2366 })
2367 }
2368}
2369
2370#[derive(Debug, Clone, PartialEq, Eq)]
2372pub struct PrivilegeFinding {
2373 pub kind: FindingKind,
2375 pub subject: String,
2377 pub detail: String,
2379}
2380
2381impl fmt::Display for PrivilegeFinding {
2382 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2383 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2384 }
2385}
2386
2387#[derive(Debug, Clone, PartialEq, Eq)]
2401pub struct PrivilegeReview {
2402 kind: DefinitionKind,
2403 account: ServiceAccount,
2404 controls: Vec<String>,
2405 findings: Vec<PrivilegeFinding>,
2406}
2407
2408impl PrivilegeReview {
2409 #[must_use]
2411 pub fn is_least_privilege(&self) -> bool {
2412 !self
2413 .findings
2414 .iter()
2415 .any(|finding| finding.kind == FindingKind::Excess)
2416 }
2417
2418 #[must_use]
2420 pub fn findings(&self) -> &[PrivilegeFinding] {
2421 &self.findings
2422 }
2423
2424 #[must_use]
2427 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2428 self.findings
2429 .iter()
2430 .filter(|finding| finding.kind == FindingKind::Excess)
2431 .collect()
2432 }
2433
2434 #[must_use]
2440 pub fn controls(&self) -> &[String] {
2441 &self.controls
2442 }
2443
2444 #[must_use]
2446 pub const fn account(&self) -> &ServiceAccount {
2447 &self.account
2448 }
2449
2450 #[must_use]
2452 pub const fn kind(&self) -> DefinitionKind {
2453 self.kind
2454 }
2455}
2456
2457impl fmt::Display for PrivilegeReview {
2458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2459 writeln!(
2460 f,
2461 "{} runs as {} ({})",
2462 self.kind,
2463 self.account,
2464 self.account.justification()
2465 )?;
2466 for control in &self.controls {
2467 writeln!(f, " confirmed {control}")?;
2468 }
2469 for finding in &self.findings {
2470 writeln!(f, " {finding}")?;
2471 }
2472 if self.is_least_privilege() {
2473 write!(f, " verdict least privilege")
2474 } else {
2475 write!(
2476 f,
2477 " verdict NOT least privilege: {} excess(es)",
2478 self.excesses().len()
2479 )
2480 }
2481 }
2482}
2483
2484#[must_use]
2490pub fn review_least_privilege(
2491 definition: &ServiceDefinition,
2492 plan: &InstallPlan,
2493) -> PrivilegeReview {
2494 let mut controls = Vec::new();
2495 let mut findings = Vec::new();
2496 match definition.kind() {
2497 DefinitionKind::SystemdUnit => {
2498 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2499 }
2500 DefinitionKind::LaunchdPlist => {
2501 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2502 }
2503 DefinitionKind::WindowsScheduledTask => {
2504 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2505 }
2506 DefinitionKind::WindowsService => {
2507 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2508 }
2509 }
2510 PrivilegeReview {
2511 kind: definition.kind(),
2512 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2517 controls,
2518 findings,
2519 }
2520}
2521
2522fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2524 let mut permitted = plan
2525 .directories()
2526 .all()
2527 .iter()
2528 .map(|path| path.to_string_lossy().into_owned())
2529 .collect::<Vec<_>>();
2530 if kind == DefinitionKind::SystemdUnit
2531 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2532 {
2533 permitted.push(secret_directory.to_string_lossy().into_owned());
2534 }
2535 permitted
2536}
2537
2538fn same_path_text(left: &str, right: &str) -> bool {
2545 if cfg!(windows) {
2546 left.eq_ignore_ascii_case(right)
2547 } else {
2548 left == right
2549 }
2550}
2551
2552fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2555 match kind {
2556 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2557 left.eq_ignore_ascii_case(right)
2558 }
2559 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2560 }
2561}
2562
2563fn review_writable_paths(
2565 kind: DefinitionKind,
2566 subject: &str,
2567 listed: &[String],
2568 plan: &InstallPlan,
2569 controls: &mut Vec<String>,
2570 findings: &mut Vec<PrivilegeFinding>,
2571) {
2572 let permitted = permitted_paths(kind, plan);
2573 for entry in listed {
2574 if !permitted
2575 .iter()
2576 .any(|allowed| same_path_for(kind, allowed, entry))
2577 {
2578 findings.push(PrivilegeFinding {
2579 kind: FindingKind::Excess,
2580 subject: subject.to_string(),
2581 detail: format!(
2582 "{entry} is writable but is not one of this registration's required paths"
2583 ),
2584 });
2585 }
2586 }
2587 for allowed in &permitted {
2588 if !listed
2589 .iter()
2590 .any(|entry| same_path_for(kind, allowed, entry))
2591 {
2592 findings.push(PrivilegeFinding {
2593 kind: FindingKind::Shortfall,
2594 subject: subject.to_string(),
2595 detail: format!(
2596 "{allowed} is one of this registration's directories but is not writable, \
2597 so the daemon cannot use it"
2598 ),
2599 });
2600 }
2601 }
2602 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2603 controls.push(format!(
2604 "{subject} names exactly the required application-data and credential paths"
2605 ));
2606 }
2607}
2608
2609fn review_capabilities(
2612 expected: &str,
2613 wanted: &str,
2614 actual: &str,
2615 controls: &mut Vec<String>,
2616 findings: &mut Vec<PrivilegeFinding>,
2617) {
2618 let subject = "CapabilityBoundingSet";
2619 if actual.trim_start().starts_with('~') {
2621 findings.push(PrivilegeFinding {
2622 kind: FindingKind::Excess,
2623 subject: subject.to_string(),
2624 detail: format!("is `{actual}`, which keeps every capability it does not name"),
2625 });
2626 return;
2627 }
2628 let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2629 let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2630 let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2631 let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2632 if !extra.is_empty() {
2633 findings.push(PrivilegeFinding {
2634 kind: FindingKind::Excess,
2635 subject: subject.to_string(),
2636 detail: format!(
2637 "keeps {}, which the requirement does not ask for",
2638 extra.join(" ")
2639 ),
2640 });
2641 }
2642 if !missing.is_empty() {
2643 findings.push(PrivilegeFinding {
2644 kind: FindingKind::Shortfall,
2645 subject: subject.to_string(),
2646 detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2647 });
2648 }
2649 if extra.is_empty() && missing.is_empty() {
2650 controls.push(expected.to_string());
2651 }
2652}
2653
2654fn review_inbound_surface(
2662 text: &str,
2663 markers: &[(&str, &str)],
2664 controls: &mut Vec<String>,
2665 findings: &mut Vec<PrivilegeFinding>,
2666) {
2667 let mut clean = true;
2668 for (marker, detail) in markers {
2669 if text.contains(marker) {
2670 clean = false;
2671 findings.push(PrivilegeFinding {
2672 kind: FindingKind::Excess,
2673 subject: (*marker).to_string(),
2674 detail: (*detail).to_string(),
2675 });
2676 }
2677 }
2678 if clean {
2679 controls.push(
2680 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2681 );
2682 }
2683}
2684
2685fn review_systemd(
2686 text: &str,
2687 plan: &InstallPlan,
2688 controls: &mut Vec<String>,
2689 findings: &mut Vec<PrivilegeFinding>,
2690) {
2691 let directives = ini_directives(text, "Service");
2692 for expected in systemd_hardening(plan.start_mode()) {
2693 let (key, value) = expected
2694 .split_once('=')
2695 .expect("every hardening directive is written as key=value");
2696 match directives.get(key) {
2697 Some(actual) if key == "CapabilityBoundingSet" => {
2698 review_capabilities(expected, value, actual, controls, findings);
2699 }
2700 Some(actual) if actual == value => controls.push((*expected).to_string()),
2701 Some(actual) => findings.push(PrivilegeFinding {
2702 kind: FindingKind::Excess,
2703 subject: key.to_string(),
2704 detail: format!(
2705 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2706 requirement does not ask for"
2707 ),
2708 }),
2709 None => findings.push(PrivilegeFinding {
2710 kind: FindingKind::Excess,
2711 subject: key.to_string(),
2712 detail: format!(
2713 "is absent, so the unit inherits systemd's default rather than `{value}`"
2714 ),
2715 }),
2716 }
2717 }
2718
2719 match directives.get("ReadWritePaths") {
2720 Some(value) => {
2721 let listed = split_quoted(value);
2722 review_writable_paths(
2723 DefinitionKind::SystemdUnit,
2724 "ReadWritePaths",
2725 &listed,
2726 plan,
2727 controls,
2728 findings,
2729 );
2730 }
2731 None => findings.push(PrivilegeFinding {
2732 kind: FindingKind::Shortfall,
2733 subject: "ReadWritePaths".to_string(),
2734 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2735 .to_string(),
2736 }),
2737 }
2738
2739 if directives.contains_key("PrivateUsers")
2742 && directives.get("PrivateUsers") == Some(&"no".to_string())
2743 {
2744 findings.push(PrivilegeFinding {
2745 kind: FindingKind::Excess,
2746 subject: "PrivateUsers".to_string(),
2747 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2748 .to_string(),
2749 });
2750 }
2751
2752 review_inbound_surface(
2753 text,
2754 &[
2755 (
2756 "ListenStream=",
2757 "asks systemd to open a listening socket for this service, which \
2758 07-security.md rule 2 forbids the product to have",
2759 ),
2760 (
2761 "ListenDatagram=",
2762 "asks systemd to open a listening socket for this service, which \
2763 07-security.md rule 2 forbids the product to have",
2764 ),
2765 ],
2766 controls,
2767 findings,
2768 );
2769}
2770
2771fn review_launchd(
2772 text: &str,
2773 plan: &InstallPlan,
2774 controls: &mut Vec<String>,
2775 findings: &mut Vec<PrivilegeFinding>,
2776) {
2777 match plist_string_value(text, "ProcessType") {
2781 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2782 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2783 }
2784 found => findings.push(PrivilegeFinding {
2785 kind: FindingKind::Shortfall,
2786 subject: "ProcessType".to_string(),
2787 detail: format!(
2788 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2789 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2790 when it next starts with no runner; `service status` says how to restart it",
2791 found.map_or_else(
2792 || "absent (launchd's `Standard`)".to_string(),
2793 |value| format!("`{value}`")
2794 )
2795 ),
2796 }),
2797 }
2798
2799 match plan.start_mode() {
2800 StartMode::Boot => {
2801 if plist_bool_value(text, "SessionCreate") == Some(true) {
2802 findings.push(PrivilegeFinding {
2803 kind: FindingKind::Excess,
2804 subject: "SessionCreate".to_string(),
2805 detail: "asks launchd to create a security session for a job that runs \
2806 outside every login session and has no use for one"
2807 .to_string(),
2808 });
2809 } else {
2810 controls.push("SessionCreate is not requested".to_string());
2811 }
2812 match plist_string_value(text, "UserName").as_deref() {
2813 Some("root") => {
2814 controls.push("UserName=root, stated rather than inherited".to_string())
2815 }
2816 Some(other) => findings.push(PrivilegeFinding {
2817 kind: FindingKind::Shortfall,
2818 subject: "UserName".to_string(),
2819 detail: format!(
2820 "is `{other}`, which cannot unlock the System Keychain: \
2821 /var/db/SystemKey is root-only, so the daemon would start and then \
2822 find no credential"
2823 ),
2824 }),
2825 None => findings.push(PrivilegeFinding {
2826 kind: FindingKind::Shortfall,
2827 subject: "UserName".to_string(),
2828 detail: "is absent, so the account is launchd's implicit default and this \
2829 review cannot confirm it"
2830 .to_string(),
2831 }),
2832 }
2833 }
2834 StartMode::Login => {
2835 if let Some(named) = plist_string_value(text, "UserName") {
2836 findings.push(PrivilegeFinding {
2837 kind: FindingKind::Excess,
2838 subject: "UserName".to_string(),
2839 detail: format!(
2840 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2841 naming an account here asks launchd for a switch a login-mode \
2842 registration has no reason to want"
2843 ),
2844 });
2845 } else {
2846 controls
2847 .push("no UserName: the agent runs as the operator and no other".to_string());
2848 }
2849 }
2850 }
2851
2852 review_inbound_surface(
2853 text,
2854 &[
2855 (
2856 "<key>Sockets</key>",
2857 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2858 forbids the product to have",
2859 ),
2860 (
2861 "<key>MachServices</key>",
2862 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2863 forbids the product to have",
2864 ),
2865 ],
2866 controls,
2867 findings,
2868 );
2869}
2870
2871fn review_scheduled_task(
2872 text: &str,
2873 controls: &mut Vec<String>,
2874 findings: &mut Vec<PrivilegeFinding>,
2875) {
2876 match xml_value(text, "RunLevel").as_deref() {
2877 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2878 Some(other) => findings.push(PrivilegeFinding {
2879 kind: FindingKind::Excess,
2880 subject: "RunLevel".to_string(),
2881 detail: format!(
2882 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2883 an administrator"
2884 ),
2885 }),
2886 None => findings.push(PrivilegeFinding {
2887 kind: FindingKind::Excess,
2888 subject: "RunLevel".to_string(),
2889 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2890 .to_string(),
2891 }),
2892 }
2893
2894 match xml_value(text, "LogonType").as_deref() {
2895 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2896 Some(other) => findings.push(PrivilegeFinding {
2897 kind: FindingKind::Excess,
2898 subject: "LogonType".to_string(),
2899 detail: format!(
2900 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2901 task; an interactive token needs neither"
2902 ),
2903 }),
2904 None => findings.push(PrivilegeFinding {
2905 kind: FindingKind::Shortfall,
2906 subject: "LogonType".to_string(),
2907 detail: "is absent, so this review cannot confirm that no credential is stored"
2908 .to_string(),
2909 }),
2910 }
2911}
2912
2913fn review_windows_service(
2914 text: &str,
2915 plan: &InstallPlan,
2916 controls: &mut Vec<String>,
2917 findings: &mut Vec<PrivilegeFinding>,
2918) {
2919 let directives = ini_directives(text, "windows-service");
2920
2921 match directives.get("ServiceType").map(String::as_str) {
2922 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2923 Some(other) => findings.push(PrivilegeFinding {
2924 kind: FindingKind::Excess,
2925 subject: "ServiceType".to_string(),
2926 detail: format!(
2927 "is `{other}`; an interactive or shared-process service reaches further than a \
2928 daemon that only talks to GitHub over HTTPS"
2929 ),
2930 }),
2931 None => findings.push(PrivilegeFinding {
2932 kind: FindingKind::Shortfall,
2933 subject: "ServiceType".to_string(),
2934 detail: "is absent, so this review cannot confirm the service is not interactive"
2935 .to_string(),
2936 }),
2937 }
2938
2939 match directives.get("Account").map(String::as_str) {
2943 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2944 controls.push(format!(
2945 "Account={account}: the only stock account the machine-scoped store's DACL \
2946 (SY, BA, OW) admits"
2947 ));
2948 }
2949 Some(other) => findings.push(PrivilegeFinding {
2950 kind: FindingKind::Shortfall,
2951 subject: "Account".to_string(),
2952 detail: format!(
2953 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2954 daemon would start and then find no credential. Widening that DACL is not this \
2955 registration's to do: an ACE reaching `{other}` would also reach every other \
2956 service running under it"
2957 ),
2958 }),
2959 None => findings.push(PrivilegeFinding {
2960 kind: FindingKind::Shortfall,
2961 subject: "Account".to_string(),
2962 detail: "is absent, so this review cannot confirm which account was registered"
2963 .to_string(),
2964 }),
2965 }
2966
2967 match directives.get("ReadWritePaths") {
2968 Some(value) => {
2969 let listed = split_quoted(value);
2970 review_writable_paths(
2971 DefinitionKind::WindowsService,
2972 "ReadWritePaths",
2973 &listed,
2974 plan,
2975 controls,
2976 findings,
2977 );
2978 }
2979 None => findings.push(PrivilegeFinding {
2980 kind: FindingKind::Shortfall,
2981 subject: "ReadWritePaths".to_string(),
2982 detail: "is absent, so the directories the service was installed against are not \
2983 recorded"
2984 .to_string(),
2985 }),
2986 }
2987}
2988
2989fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2997 let mut out = BTreeMap::new();
2998 let mut inside = false;
2999 for line in text.lines() {
3000 let line = line.trim();
3001 if line.starts_with('[') && line.ends_with(']') {
3002 inside = &line[1..line.len() - 1] == section;
3003 continue;
3004 }
3005 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
3006 continue;
3007 }
3008 if let Some((key, value)) = line.split_once('=') {
3009 out.insert(key.trim().to_string(), value.trim().to_string());
3010 }
3011 }
3012 out
3013}
3014
3015#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3022fn systemd_exec_command_line(value: &str) -> String {
3023 let value = value.trim();
3024 let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
3025 if !value[..value.len() - command.len()].contains('@') {
3026 return command.to_string();
3027 }
3028 let mut words = split_command_line(command);
3029 if words.len() > 1 {
3030 words.remove(1);
3031 }
3032 words
3033 .iter()
3034 .map(|word| quote_argument(word))
3035 .collect::<Vec<_>>()
3036 .join(" ")
3037}
3038
3039#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3043fn systemd_timespan(value: &str) -> Option<Duration> {
3044 let mut rest = value.trim();
3045 if rest.is_empty() || rest == "infinity" {
3046 return None;
3047 }
3048 let mut seconds = 0f64;
3049 while !rest.is_empty() {
3050 let digits = rest
3051 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3052 .unwrap_or(rest.len());
3053 if digits == 0 {
3054 return None;
3055 }
3056 let number: f64 = rest[..digits].parse().ok()?;
3057 rest = rest[digits..].trim_start();
3058 let letters = rest
3059 .find(|c: char| !c.is_alphabetic())
3060 .unwrap_or(rest.len());
3061 let scale = match &rest[..letters] {
3062 "" | "s" | "sec" | "second" | "seconds" => 1.0,
3063 "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3064 "ms" | "msec" => 1e-3,
3065 "m" | "min" | "minute" | "minutes" => 60.0,
3066 "h" | "hr" | "hour" | "hours" => 3_600.0,
3067 "d" | "day" | "days" => 86_400.0,
3068 "w" | "week" | "weeks" => 604_800.0,
3069 "M" | "month" | "months" => 2_629_800.0,
3070 "y" | "year" | "years" => 31_557_600.0,
3071 _ => return None,
3072 };
3073 seconds += number * scale;
3074 rest = rest[letters..].trim_start();
3075 }
3076 Duration::try_from_secs_f64(seconds).ok()
3077}
3078
3079fn split_quoted(value: &str) -> Vec<String> {
3082 let mut out = Vec::new();
3083 let mut rest = value.trim();
3084 while !rest.is_empty() {
3085 if rest.starts_with('"') {
3086 if let Some(parsed) = executable_from_command_line(rest) {
3089 out.push(parsed.to_string_lossy().into_owned());
3090 }
3091 let mut depth = 0usize;
3093 let mut end = rest.len();
3094 for (index, c) in rest.char_indices() {
3095 match c {
3096 '\\' => depth += 1,
3097 '"' => {
3098 if depth.is_multiple_of(2) && index > 0 {
3099 end = index + 1;
3100 break;
3101 }
3102 depth = 0;
3103 }
3104 _ => depth = 0,
3105 }
3106 }
3107 rest = rest[end..].trim_start();
3108 } else {
3109 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3110 out.push(rest[..end].to_string());
3111 rest = rest[end..].trim_start();
3112 }
3113 }
3114 out
3115}
3116
3117pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3129 let open = format!("<{tag}>");
3130 let close = format!("</{tag}>");
3131 let start = text.find(&open)? + open.len();
3132 let end = text[start..].find(&close)? + start;
3133 Some(xml_unescape(text[start..end].trim()))
3134}
3135
3136#[cfg(any(windows, test))]
3142fn windows_login_task_starts_automatically(document: &str) -> bool {
3143 document.contains("<LogonTrigger>")
3144 && xml_value(document, "Enabled").as_deref() != Some("false")
3145}
3146
3147fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3149 let marker = format!("<key>{key}</key>");
3150 let start = text.find(&marker)? + marker.len();
3151 Some(text[start..].trim_start())
3152}
3153
3154pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3155 let rest = plist_value_after_key(text, key)?;
3156 if !rest.starts_with("<string>") {
3157 return None;
3158 }
3159 xml_value(rest, "string")
3160}
3161
3162fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3163 let rest = plist_value_after_key(text, key)?;
3164 if rest.starts_with("<true/>") {
3165 Some(true)
3166 } else if rest.starts_with("<false/>") {
3167 Some(false)
3168 } else {
3169 None
3170 }
3171}
3172
3173pub const RECORD_SCHEMA_VERSION: u32 = 1;
3184
3185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3197pub struct InstallRecord {
3198 pub schema_version: u32,
3200 pub service_name: String,
3202 pub manager: String,
3204 pub start_mode: StartMode,
3206 pub account: ServiceAccount,
3208 pub binary: PathBuf,
3210 #[serde(default)]
3218 pub source_binary: Option<PathBuf>,
3219 pub arguments: Vec<String>,
3221 pub restart_delay_secs: u64,
3223 pub restart_reset_secs: u64,
3225 pub log_file: PathBuf,
3227 #[serde(default)]
3235 pub starts_on_demand: bool,
3236 pub definition_path: Option<PathBuf>,
3238 pub installed_at: DateTime<Utc>,
3240 pub installed_by_version: String,
3242 pub directories: ServiceDirectories,
3247}
3248
3249impl InstallRecord {
3250 #[must_use]
3252 pub fn path(paths: &AppPaths) -> PathBuf {
3253 paths.config_dir().join(RECORD_FILE)
3254 }
3255
3256 #[must_use]
3258 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3259 Self {
3260 schema_version: RECORD_SCHEMA_VERSION,
3261 service_name: plan.identity().name().to_string(),
3262 manager: definition.kind().manager().to_string(),
3263 start_mode: plan.start_mode(),
3264 account: plan.account().clone(),
3265 binary: plan.binary().to_path_buf(),
3266 arguments: plan
3267 .arguments()
3268 .iter()
3269 .map(|argument| argument.to_string_lossy().into_owned())
3270 .collect(),
3271 restart_delay_secs: plan.restart().delay().as_secs(),
3272 restart_reset_secs: plan.restart().reset_after().as_secs(),
3273 starts_on_demand: plan.is_on_demand(),
3274 source_binary: plan.source_binary().map(Path::to_path_buf),
3275 log_file: plan.directories().log_file(),
3276 definition_path: definition.install_path().map(Path::to_path_buf),
3277 installed_at: at,
3278 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3279 directories: plan.directories().clone(),
3280 }
3281 }
3282
3283 #[must_use]
3291 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3292 let plan = InstallPlan::unchecked(
3293 identity,
3294 self.start_mode,
3295 self.binary.clone(),
3296 self.directories.clone(),
3297 )
3298 .with_arguments(self.arguments.clone())
3299 .with_restart(self.restart());
3300 let plan = if self.starts_on_demand {
3301 plan.started_on_demand()
3302 } else {
3303 plan
3304 };
3305 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3306 Ok(store) => plan.with_secret_guard(store.guard()),
3307 Err(_) => plan,
3308 }
3309 }
3310
3311 #[must_use]
3315 pub fn restart(&self) -> RestartPolicy {
3316 RestartPolicy::new(
3317 Duration::from_secs(self.restart_delay_secs),
3318 Duration::from_secs(self.restart_reset_secs),
3319 )
3320 .unwrap_or_default()
3321 }
3322
3323 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3334 let path = Self::path(paths);
3335 let text = match std::fs::read_to_string(&path) {
3336 Ok(text) => text,
3337 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3338 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3341 return Err(ServiceError::RecordNotPermitted {
3342 path,
3343 detail: error.to_string(),
3344 });
3345 }
3346 Err(error) => {
3347 return Err(ServiceError::Record {
3348 operation: "read",
3349 path,
3350 detail: error.to_string(),
3351 });
3352 }
3353 };
3354 let record: Self =
3355 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3356 path: path.clone(),
3357 detail: error.to_string(),
3358 })?;
3359 if record.schema_version != RECORD_SCHEMA_VERSION {
3360 return Err(ServiceError::RecordUnreadable {
3361 path,
3362 detail: format!(
3363 "it declares schema version {} and this build reads version {}",
3364 record.schema_version, RECORD_SCHEMA_VERSION
3365 ),
3366 });
3367 }
3368 Ok(Some(record))
3369 }
3370
3371 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3377 use std::io::Write as _;
3378
3379 let path = Self::path(paths);
3380 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3381 operation: "encode",
3382 path: path.clone(),
3383 detail: error.to_string(),
3384 })?;
3385 if let Some(parent) = path.parent() {
3386 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3387 operation: "write",
3388 path: path.clone(),
3389 detail: error.to_string(),
3390 })?;
3391 }
3392 let parent = path.parent().ok_or_else(|| ServiceError::Record {
3393 operation: "write",
3394 path: path.clone(),
3395 detail: "the record path has no parent directory".to_string(),
3396 })?;
3397 let mut temporary =
3398 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3399 operation: "write",
3400 path: path.clone(),
3401 detail: error.to_string(),
3402 })?;
3403 temporary
3404 .write_all(text.as_bytes())
3405 .and_then(|()| temporary.as_file().sync_all())
3406 .map_err(|error| ServiceError::Record {
3407 operation: "write",
3408 path: path.clone(),
3409 detail: error.to_string(),
3410 })?;
3411 #[cfg(unix)]
3427 {
3428 use std::os::unix::fs::PermissionsExt as _;
3429
3430 temporary
3431 .as_file()
3432 .set_permissions(std::fs::Permissions::from_mode(0o644))
3433 .map_err(|error| ServiceError::Record {
3434 operation: "write",
3435 path: path.clone(),
3436 detail: error.to_string(),
3437 })?;
3438 }
3439 temporary
3440 .persist(&path)
3441 .map(|_| ())
3442 .map_err(|error| ServiceError::Record {
3443 operation: "write",
3444 path,
3445 detail: error.error.to_string(),
3446 })
3447 }
3448
3449 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3455 let path = Self::path(paths);
3456 match std::fs::remove_file(&path) {
3457 Ok(()) => Ok(true),
3458 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3459 Err(error) => Err(ServiceError::Record {
3460 operation: "remove",
3461 path,
3462 detail: error.to_string(),
3463 }),
3464 }
3465 }
3466}
3467
3468const CONTACT_SCHEMA_VERSION: u32 = 1;
3474
3475#[derive(Debug, Clone, Serialize, Deserialize)]
3476struct ContactRecord {
3477 schema_version: u32,
3478 last_success: DateTime<Utc>,
3479}
3480
3481pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3502 write_state_record(
3503 &contact_path(paths),
3504 &ContactRecord {
3505 schema_version: CONTACT_SCHEMA_VERSION,
3506 last_success: at,
3507 },
3508 )
3509}
3510
3511pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3523 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3524}
3525
3526#[must_use]
3528pub fn contact_path(paths: &AppPaths) -> PathBuf {
3529 paths.state_dir().join(CONTACT_FILE)
3530}
3531
3532pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3535 let failed = |detail: String| ServiceError::Record {
3536 operation: "write",
3537 path: path.to_path_buf(),
3538 detail,
3539 };
3540 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3541 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3542 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3543 let temporary = path.with_extension("toml.new");
3544 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3545 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3546}
3547
3548pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3551 path: &Path,
3552) -> Result<Option<T>, ServiceError> {
3553 let failed = |detail: String| ServiceError::Record {
3554 operation: "read",
3555 path: path.to_path_buf(),
3556 detail,
3557 };
3558 let text = match std::fs::read_to_string(path) {
3559 Ok(text) => text,
3560 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3561 Err(error) => return Err(failed(error.to_string())),
3562 };
3563 toml::from_str(&text)
3564 .map(Some)
3565 .map_err(|error| failed(error.to_string()))
3566}
3567
3568pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3570 match std::fs::remove_file(path) {
3571 Ok(()) => Ok(()),
3572 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3573 Err(error) => Err(ServiceError::Record {
3574 operation: "remove",
3575 path: path.to_path_buf(),
3576 detail: error.to_string(),
3577 }),
3578 }
3579}
3580
3581#[derive(Debug, Serialize, Deserialize)]
3586#[serde(deny_unknown_fields)]
3587struct CredentialRejectionRecord {
3588 schema_version: u32,
3589 rejected_since: DateTime<Utc>,
3590}
3591
3592pub fn record_github_credential_rejected(
3608 paths: &AppPaths,
3609 at: DateTime<Utc>,
3610) -> Result<(), ServiceError> {
3611 if github_credential_rejected_since(paths)
3612 .ok()
3613 .flatten()
3614 .is_some()
3615 {
3616 return Ok(());
3617 }
3618 write_state_record(
3619 &credential_rejection_path(paths),
3620 &CredentialRejectionRecord {
3621 schema_version: CONTACT_SCHEMA_VERSION,
3622 rejected_since: at,
3623 },
3624 )
3625}
3626
3627pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3633 remove_state_record(&credential_rejection_path(paths))
3634}
3635
3636pub fn github_credential_rejected_since(
3642 paths: &AppPaths,
3643) -> Result<Option<DateTime<Utc>>, ServiceError> {
3644 Ok(
3645 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3646 .map(|record| record.rejected_since),
3647 )
3648}
3649
3650#[must_use]
3652pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3653 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3654}
3655
3656#[derive(Debug, Serialize, Deserialize)]
3657#[serde(deny_unknown_fields)]
3658struct CredentialUnreadableRecord {
3659 schema_version: u32,
3660 unreadable_since: DateTime<Utc>,
3661}
3662
3663pub fn record_credential_unreadable(
3682 paths: &AppPaths,
3683 at: DateTime<Utc>,
3684) -> Result<(), ServiceError> {
3685 if credential_unreadable_since(paths).ok().flatten().is_some() {
3686 return Ok(());
3687 }
3688 write_state_record(
3689 &credential_unreadable_path(paths),
3690 &CredentialUnreadableRecord {
3691 schema_version: CONTACT_SCHEMA_VERSION,
3692 unreadable_since: at,
3693 },
3694 )
3695}
3696
3697pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3703 remove_state_record(&credential_unreadable_path(paths))
3704}
3705
3706pub fn credential_unreadable_since(
3713 paths: &AppPaths,
3714) -> Result<Option<DateTime<Utc>>, ServiceError> {
3715 Ok(
3716 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3717 .map(|record| record.unreadable_since),
3718 )
3719}
3720
3721#[must_use]
3723pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3724 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3725}
3726
3727#[must_use]
3735pub fn sign_in_instruction(mode: StartMode) -> String {
3736 let (command, place) = match mode {
3737 StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3738 StartMode::Boot => (
3739 "runner-manager auth login --start-at boot".to_owned(),
3740 " from an elevated terminal",
3741 ),
3742 StartMode::Login => (
3743 "runner-manager auth login --start-at login".to_owned(),
3744 MACOS_LOGIN_PLACE,
3745 ),
3746 };
3747 format!("`{command}`{place}")
3748}
3749
3750#[must_use]
3759pub fn sudo_command(arguments: &str) -> String {
3760 let executable = if cfg!(test) {
3763 None
3764 } else {
3765 std::env::current_exe().ok()
3766 };
3767 format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3768}
3769
3770fn sudo_program(executable: Option<&Path>) -> String {
3772 const SECURE_PATH: [&str; 7] = [
3775 "/usr/local/sbin",
3776 "/usr/local/bin",
3777 "/usr/sbin",
3778 "/usr/bin",
3779 "/sbin",
3780 "/bin",
3781 "/snap/bin",
3782 ];
3783 let Some(executable) = executable else {
3784 return "runner-manager".to_owned();
3785 };
3786 if executable
3787 .parent()
3788 .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3789 {
3790 return "runner-manager".to_owned();
3791 }
3792 let text = executable.to_string_lossy();
3793 if text
3794 .chars()
3795 .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3796 {
3797 text.into_owned()
3798 } else {
3799 format!("'{}'", text.replace('\'', "'\\''"))
3800 }
3801}
3802
3803pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3806 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3807 keychain)"
3808} else {
3809 ""
3810};
3811
3812#[derive(Debug, Clone, PartialEq, Eq)]
3829pub struct DefinitionDrift {
3830 pub path: PathBuf,
3832 pub installed_by_version: String,
3834 pub rendered: String,
3836 pub start_mode: StartMode,
3838 pub manager: DefinitionKind,
3840}
3841
3842fn definition_drift(
3850 controls: &dyn ControlFactory,
3851 identity: &ServiceIdentity,
3852 record: &InstallRecord,
3853) -> Option<DefinitionDrift> {
3854 let compare = match host_definition_kind(record.start_mode) {
3855 DefinitionKind::SystemdUnit => systemd_definition_drift,
3856 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3857 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3858 };
3859 let path = record.definition_path.clone()?;
3860 let installed = controls.installed_definition(&path)?;
3861 compare(identity, record, path, &installed)
3862}
3863
3864fn launchd_definition_drift(
3873 identity: &ServiceIdentity,
3874 record: &InstallRecord,
3875 path: PathBuf,
3876 installed: &str,
3877) -> Option<DefinitionDrift> {
3878 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3879 || DefinitionDrift {
3880 path,
3881 installed_by_version: record.installed_by_version.clone(),
3882 rendered: launchd_plist(&record.plan(identity.clone())),
3883 start_mode: record.start_mode,
3884 manager: DefinitionKind::LaunchdPlist,
3885 },
3886 )
3887}
3888
3889fn systemd_definition_drift(
3890 identity: &ServiceIdentity,
3891 record: &InstallRecord,
3892 path: PathBuf,
3893 installed: &str,
3894) -> Option<DefinitionDrift> {
3895 let rendered = systemd_unit(&record.plan(identity.clone()));
3896 (installed != rendered).then(|| DefinitionDrift {
3897 path,
3898 installed_by_version: record.installed_by_version.clone(),
3899 rendered,
3900 start_mode: record.start_mode,
3901 manager: DefinitionKind::SystemdUnit,
3902 })
3903}
3904
3905const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3910
3911#[derive(Debug, Clone, Serialize, Deserialize)]
3912struct RootRefusalFile {
3913 schema_version: u32,
3914 #[serde(default)]
3916 refusals: BTreeMap<String, RootRefusalEntry>,
3917}
3918
3919#[derive(Debug, Clone, Serialize, Deserialize)]
3920struct RootRefusalEntry {
3921 at: DateTime<Utc>,
3922 kind: String,
3923 root: String,
3924 detail: String,
3925}
3926
3927#[derive(Debug, Clone, PartialEq, Eq)]
3929pub struct RunnerRootRefusal {
3930 pub policy: String,
3932 pub at: DateTime<Utc>,
3934 pub kind: String,
3936 pub root: String,
3938 pub detail: String,
3941}
3942
3943pub fn record_runner_root_refusal(
3977 paths: &AppPaths,
3978 policy: &str,
3979 at: DateTime<Utc>,
3980 kind: &str,
3981 root: &str,
3982 detail: &str,
3983) -> Result<(), ServiceError> {
3984 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3985 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3986 refusals: BTreeMap::new(),
3987 });
3988 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3989 file.refusals.insert(
3990 policy.to_owned(),
3991 RootRefusalEntry {
3992 at,
3993 kind: kind.to_owned(),
3994 root: root.to_owned(),
3995 detail: detail.to_owned(),
3996 },
3997 );
3998 write_refusal_file(paths, &file)
3999}
4000
4001pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
4012 let Some(mut file) = read_refusal_file(paths)? else {
4013 return Ok(());
4014 };
4015 if file.refusals.remove(policy).is_none() {
4016 return Ok(());
4017 }
4018 if file.refusals.is_empty() {
4019 let path = root_refusal_path(paths);
4020 return match std::fs::remove_file(&path) {
4021 Ok(()) => Ok(()),
4022 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4023 Err(error) => Err(ServiceError::Record {
4024 operation: "remove",
4025 path,
4026 detail: error.to_string(),
4027 }),
4028 };
4029 }
4030 write_refusal_file(paths, &file)
4031}
4032
4033pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4045 Ok(read_refusal_file(paths)?
4046 .map(|file| {
4047 file.refusals
4048 .into_iter()
4049 .map(|(policy, entry)| RunnerRootRefusal {
4050 policy,
4051 at: entry.at,
4052 kind: entry.kind,
4053 root: entry.root,
4054 detail: entry.detail,
4055 })
4056 .collect()
4057 })
4058 .unwrap_or_default())
4059}
4060
4061fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4062 read_state_record(&root_refusal_path(paths))
4063}
4064
4065fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4066 write_state_record(&root_refusal_path(paths), file)
4067}
4068
4069#[must_use]
4071pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4072 paths.state_dir().join(ROOT_REFUSAL_FILE)
4073}
4074
4075#[derive(Debug, Clone, PartialEq, Eq)]
4086pub enum BinaryPath {
4087 Current {
4089 path: PathBuf,
4091 },
4092 Missing {
4099 recorded: PathBuf,
4101 },
4102 NotExecutable {
4105 recorded: PathBuf,
4107 detail: String,
4109 },
4110 Diverged {
4112 recorded: PathBuf,
4114 registered: PathBuf,
4116 },
4117}
4118
4119impl BinaryPath {
4120 #[must_use]
4122 pub const fn is_error(&self) -> bool {
4123 !matches!(self, Self::Current { .. })
4124 }
4125
4126 #[must_use]
4128 pub fn recorded(&self) -> &Path {
4129 match self {
4130 Self::Current { path } => path,
4131 Self::Missing { recorded }
4132 | Self::NotExecutable { recorded, .. }
4133 | Self::Diverged { recorded, .. } => recorded,
4134 }
4135 }
4136}
4137
4138impl fmt::Display for BinaryPath {
4139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4140 match self {
4141 Self::Current { path } => write!(f, "{}", path.display()),
4142 Self::Missing { recorded } => write!(
4143 f,
4144 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4145 package manager that moved the binary is the usual cause; an `npm i -g` \
4146 installation moves with the active Node version. Run `service install` again \
4147 from the binary that is now installed.",
4148 recorded.display()
4149 ),
4150 Self::NotExecutable { recorded, detail } => write!(
4151 f,
4152 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4153 from the installed binary.",
4154 recorded.display()
4155 ),
4156 Self::Diverged {
4157 recorded,
4158 registered,
4159 } => write!(
4160 f,
4161 "{} -- STALE: the service manager is registered to start {} instead. Something \
4162 has edited the registration since it was installed. Run `service uninstall` and \
4163 `service install`; neither touches configuration, secrets, or the cache.",
4164 recorded.display(),
4165 registered.display()
4166 ),
4167 }
4168 }
4169}
4170
4171#[must_use]
4179pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4180 match std::fs::metadata(recorded) {
4181 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4182 return BinaryPath::Missing {
4183 recorded: recorded.to_path_buf(),
4184 };
4185 }
4186 Err(error) => {
4187 return BinaryPath::NotExecutable {
4188 recorded: recorded.to_path_buf(),
4189 detail: format!("it cannot be inspected ({error})"),
4190 };
4191 }
4192 Ok(metadata) if !metadata.is_file() => {
4193 return BinaryPath::NotExecutable {
4194 recorded: recorded.to_path_buf(),
4195 detail: "what is there is not a file".to_string(),
4196 };
4197 }
4198 Ok(_) => {}
4199 }
4200 if let Some(registered) = registered
4201 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
4202 {
4203 return BinaryPath::Diverged {
4204 recorded: recorded.to_path_buf(),
4205 registered: registered.to_path_buf(),
4206 };
4207 }
4208 BinaryPath::Current {
4209 path: recorded.to_path_buf(),
4210 }
4211}
4212
4213#[derive(Debug, Clone, PartialEq, Eq)]
4219pub struct Registration {
4220 pub manager: DefinitionKind,
4225 pub start_mode: StartMode,
4227 pub command_line: String,
4229 pub account: Option<String>,
4236 pub running: bool,
4238 pub starts_automatically: bool,
4245 pub restart_delay: Option<Duration>,
4254}
4255
4256impl Registration {
4257 #[must_use]
4259 pub fn binary(&self) -> Option<PathBuf> {
4260 executable_from_command_line(&self.command_line)
4261 }
4262}
4263
4264pub trait ServiceControl: fmt::Debug {
4270 fn manager(&self) -> DefinitionKind;
4272
4273 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4279
4280 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4291
4292 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4298
4299 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4306
4307 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4313}
4314
4315pub trait ControlFactory: fmt::Debug + Send + Sync {
4321 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4327
4328 fn installed_definition(&self, _path: &Path) -> Option<String> {
4332 None
4333 }
4334
4335 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4340 None
4341 }
4342}
4343
4344#[derive(Debug, Clone, Copy, Default)]
4346pub struct HostControls;
4347
4348#[derive(Debug, Clone)]
4354pub struct Installed {
4355 pub plan: InstallPlan,
4357 pub definition: ServiceDefinition,
4359 pub record: InstallRecord,
4361 pub review: PrivilegeReview,
4363 pub runner_root: RootAccessSummary,
4368 pub replaced_existing: bool,
4376}
4377
4378#[derive(Debug, Clone, PartialEq, Eq)]
4380pub struct Uninstalled {
4381 pub removed_registration: bool,
4383 pub removed_record: bool,
4385 pub removed_definition: Option<PathBuf>,
4387 pub preserved: Vec<PathBuf>,
4394}
4395
4396impl fmt::Display for Uninstalled {
4397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4398 if self.removed_registration {
4399 writeln!(f, "The service registration was removed.")?;
4400 } else {
4401 writeln!(f, "There was no service registration to remove.")?;
4402 }
4403 writeln!(f, "Nothing else was deleted. These are untouched:")?;
4404 for path in &self.preserved {
4405 writeln!(f, " {}", path.display())?;
4406 }
4407 write!(
4408 f,
4409 "The stored GitHub token is untouched too; `auth logout` is what purges it."
4410 )
4411 }
4412}
4413
4414#[derive(Debug, Clone, PartialEq, Eq)]
4416pub struct StartModeChange {
4417 pub from: StartMode,
4419 pub to: StartMode,
4421 pub changed: bool,
4423 pub store_scope: crate::secrets::SecretScope,
4426 pub runner_root: RootAccessSummary,
4432}
4433
4434impl fmt::Display for StartModeChange {
4435 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4436 if !self.changed {
4437 return write!(f, "The service already starts at {}.", self.to);
4438 }
4439 write!(
4440 f,
4441 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4442 if the token was stored under the other scope, run `auth login` again. {}",
4443 self.to, self.from, self.store_scope, self.runner_root
4444 )
4445 }
4446}
4447
4448#[derive(Debug, Clone)]
4456pub struct ServiceOperations {
4457 paths: AppPaths,
4458 identity: ServiceIdentity,
4459 controls: std::sync::Arc<dyn ControlFactory>,
4460 runner_root: Option<LocalAbsolutePath>,
4461}
4462
4463impl ServiceOperations {
4464 #[must_use]
4466 pub fn on_this_host(paths: AppPaths) -> Self {
4467 Self::with_controls(
4468 paths,
4469 ServiceIdentity::product(),
4470 std::sync::Arc::new(HostControls),
4471 )
4472 }
4473
4474 #[must_use]
4482 pub fn with_controls(
4483 paths: AppPaths,
4484 identity: ServiceIdentity,
4485 controls: std::sync::Arc<dyn ControlFactory>,
4486 ) -> Self {
4487 Self {
4488 paths,
4489 identity,
4490 controls,
4491 runner_root: None,
4492 }
4493 }
4494
4495 #[must_use]
4514 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4515 self.runner_root = Some(root);
4516 self
4517 }
4518
4519 #[must_use]
4521 pub const fn paths(&self) -> &AppPaths {
4522 &self.paths
4523 }
4524
4525 #[must_use]
4527 pub const fn identity(&self) -> &ServiceIdentity {
4528 &self.identity
4529 }
4530
4531 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4551 self.paths
4552 .create_all()
4553 .map_err(|source| ServiceError::Paths {
4554 source: Box::new(source),
4555 })?;
4556
4557 let _guard = self.refuse_while_an_agent_runs()?;
4560 let adopted = self.adopt_boot_installation(request.start_mode())?;
4561 if !adopted {
4565 self.refuse_foreign("replace")?;
4566 }
4567
4568 let (replacing, previous_was_running) = match self.find_registration()? {
4597 Some((existing, registration)) if existing == request.start_mode() => {
4598 (true, registration.running)
4599 }
4600 Some((existing, _)) => {
4601 return Err(ServiceError::AlreadyInstalled {
4602 name: self.identity.name().to_string(),
4603 existing,
4604 requested: request.start_mode(),
4605 });
4606 }
4607 None => (false, false),
4608 };
4609
4610 let plan = InstallPlan::resolve(
4611 self.identity.clone(),
4612 request,
4613 ServiceDirectories::of(&self.paths),
4614 )?;
4615
4616 let control = self.controls.control(plan.start_mode())?;
4621
4622 let root = self.prepare_runner_root(plan.start_mode())?;
4628
4629 let previous = if replacing {
4634 InstallRecord::read(&self.paths).ok().flatten()
4635 } else {
4636 None
4637 };
4638 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4639 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4640 }
4641
4642 let definition = match control.install(&plan) {
4643 Ok(definition) => definition,
4644 Err(cause) => {
4645 let restored =
4648 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4649 return Err(rolled_back(
4650 retained_runner_root(&root),
4651 restored,
4652 "install",
4653 &self.identity,
4654 cause,
4655 ));
4656 }
4657 };
4658 let review = review_least_privilege(&definition, &plan);
4659 let record = InstallRecord::of(&plan, &definition, Utc::now());
4660 if let Err(cause) = record.write(&self.paths) {
4661 let rollback = control
4662 .uninstall(&self.identity)
4663 .map(|_| ())
4664 .and_then(|()| {
4665 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4666 });
4667 return Err(rolled_back(
4668 retained_runner_root(&root),
4669 rollback,
4670 "install",
4671 &self.identity,
4672 cause,
4673 ));
4674 }
4675
4676 if let Err(cause) = control.start(&self.identity) {
4683 let remove_new = control.uninstall(&self.identity).map(|_| ());
4684 let restore_registration = remove_new.and_then(|()| {
4685 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4686 });
4687 let restore_record = restore_registration.and_then(|()| match &previous {
4688 Some(previous) => previous.write(&self.paths),
4689 None => InstallRecord::remove(&self.paths).map(|_| ()),
4690 });
4691 return Err(rolled_back(
4692 retained_runner_root(&root),
4693 restore_record,
4694 "install and start",
4695 &self.identity,
4696 cause,
4697 ));
4698 }
4699 Ok(Installed {
4700 plan,
4701 definition,
4702 record,
4703 review,
4704 runner_root: root.summary().clone(),
4705 replaced_existing: replacing,
4706 })
4707 }
4708
4709 fn reinstate(
4720 &self,
4721 control: &dyn ServiceControl,
4722 previous: Option<&InstallRecord>,
4723 was_running: bool,
4724 ) -> Result<(), ServiceError> {
4725 let Some(record) = previous else {
4726 return Ok(());
4727 };
4728 control.install(&record.plan(self.identity.clone()))?;
4729 if was_running {
4730 control.start(&self.identity)?;
4731 }
4732 Ok(())
4733 }
4734
4735 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4744 let record = InstallRecord::read(&self.paths).ok().flatten();
4745 let removed_definition = record
4746 .as_ref()
4747 .and_then(|record| record.definition_path.clone());
4748
4749 let mut removed_registration = false;
4750 let mut foreign = None;
4757 for mode in [StartMode::Boot, StartMode::Login] {
4758 let control = self.controls.control(mode)?;
4759 if let Some(directories) = control
4760 .query(&self.identity)
4761 .ok()
4762 .flatten()
4763 .and_then(|registration| self.foreign_directories(®istration))
4764 {
4765 foreign = Some(directories);
4766 continue;
4767 }
4768 if control.uninstall(&self.identity)? {
4769 removed_registration = true;
4770 }
4771 }
4772 if !removed_registration && let Some(directories) = foreign {
4773 return Err(self.foreign_refusal("uninstall", directories));
4774 }
4775 let removed_record = InstallRecord::remove(&self.paths)?;
4776 Ok(Uninstalled {
4777 removed_registration,
4778 removed_record,
4779 removed_definition: removed_definition.filter(|_| removed_registration),
4780 preserved: self
4781 .paths
4782 .all()
4783 .iter()
4784 .map(|(_, path)| (*path).to_path_buf())
4785 .collect(),
4786 })
4787 }
4788
4789 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4811 let Some(record) = InstallRecord::read(&self.paths)? else {
4812 return Err(ServiceError::NotInstalled {
4813 name: self.identity.name().to_string(),
4814 operation: "switch the start mode of",
4815 });
4816 };
4817 let from = record.start_mode;
4818 if from == to {
4819 return Ok(StartModeChange {
4823 from,
4824 to,
4825 changed: false,
4826 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4827 runner_root: RootAccessSummary::NotApplicable,
4828 });
4829 }
4830
4831 self.refuse_foreign("switch the start mode of")?;
4834
4835 #[cfg(windows)]
4836 let arguments = {
4837 let mut arguments = record.arguments.clone();
4838 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4839 if to == StartMode::Boot {
4840 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4841 }
4842 arguments
4843 };
4844 #[cfg(not(windows))]
4845 let arguments = record.arguments.clone();
4846 let plan = InstallPlan::unchecked(
4847 self.identity.clone(),
4848 to,
4849 record.binary.clone(),
4850 record.directories.clone(),
4851 )
4852 .with_arguments(arguments)
4853 .with_restart(record.restart());
4854 let plan = if record.starts_on_demand {
4855 plan.started_on_demand()
4856 } else {
4857 plan
4858 };
4859 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4860 Ok(store) => plan.with_secret_guard(store.guard()),
4861 Err(_) => plan,
4862 };
4863
4864 let target = self.controls.control(to)?;
4871 let previous = self.controls.control(from)?;
4876
4877 let root = self.prepare_runner_root(to)?;
4885
4886 let definition = match target.install(&plan) {
4887 Ok(definition) => definition,
4888 Err(cause) => {
4889 return Err(undo_runner_root(
4890 &root,
4891 "switch start mode",
4892 &self.identity,
4893 cause,
4894 ));
4895 }
4896 };
4897 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4898 if let Err(cause) = next_record.write(&self.paths) {
4899 return Err(rolled_back(
4900 retained_runner_root(&root),
4901 target.uninstall(&self.identity),
4902 "switch start mode",
4903 &self.identity,
4904 cause,
4905 ));
4906 }
4907
4908 if let Err(cause) = previous.uninstall(&self.identity) {
4912 let target_rollback = target.uninstall(&self.identity);
4913 let record_rollback = record.write(&self.paths);
4914 return Err(rolled_back(
4915 retained_runner_root(&root),
4916 target_rollback.and(record_rollback),
4917 "switch start mode",
4918 &self.identity,
4919 cause,
4920 ));
4921 }
4922 Ok(StartModeChange {
4923 from,
4924 to,
4925 changed: true,
4926 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4927 runner_root: root.summary().clone(),
4928 })
4929 }
4930
4931 pub fn start(&self) -> Result<(), ServiceError> {
4937 let mode = self.own_registration_mode("start")?;
4938 self.controls.control(mode)?.start(&self.identity)
4939 }
4940
4941 fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
4949 match self.registration_for_status()? {
4950 None => Err(ServiceError::NotInstalled {
4951 name: self.identity.name().to_string(),
4952 operation,
4953 }),
4954 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4955 Some((registration, None)) => Ok(registration.start_mode),
4956 }
4957 }
4958
4959 pub fn stop(&self) -> Result<bool, ServiceError> {
4965 let mode = self.own_registration_mode("stop")?;
4966 self.controls.control(mode)?.stop(&self.identity)
4967 }
4968
4969 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
5000 match self.registration_for_status()? {
5001 Some((registration, Some(directories))) => {
5002 Ok(self.foreign_status(registration, directories))
5003 }
5004 found => {
5005 let (record, record_refused) = match InstallRecord::read(&self.paths) {
5006 Ok(record) => (record, None),
5007 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
5008 Err(error) => return Err(error),
5009 };
5010 self.status_at(
5011 &self.paths,
5012 record,
5013 record_refused.as_ref(),
5014 found.map(|(registration, _)| registration),
5015 )
5016 }
5017 }
5018 }
5019
5020 fn status_at(
5023 &self,
5024 paths: &AppPaths,
5025 record: Option<InstallRecord>,
5026 record_refused: Option<&ServiceError>,
5027 found: Option<Registration>,
5028 ) -> Result<ServiceStatus, ServiceError> {
5029 let last_github_contact = last_github_contact(paths)?;
5030 let drift = record
5031 .as_ref()
5032 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5033 let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5034 let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5035 let probed = self.controls.unattended_login();
5036 let unattended = record.as_ref().and_then(|record| {
5037 let account = probed.as_ref().map_or_else(String::new, |_| {
5040 found
5041 .as_ref()
5042 .and_then(|registration| registration.account.clone())
5043 .unwrap_or_else(|| service_account_name(&record.account))
5044 });
5045 unattended_gap(record.start_mode, &account, probed.as_ref())
5046 });
5047 let now = Utc::now();
5048 let installed = record.is_some() || found.is_some();
5049 let status = ServiceStatus::compose(
5050 self.identity.clone(),
5051 record,
5052 record_refused,
5053 found,
5054 last_github_contact,
5055 paths,
5056 )
5057 .with_definition_drift(drift)
5058 .with_credential_rejection(credential_rejected_since)
5059 .with_credential_unreadable(credential_unreadable_since)
5060 .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5061 .with_unattended_gap(unattended)
5062 .with_host_unfit(
5063 crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5064 );
5065 let restart = status.restart_command();
5066 Ok(status.with_stall(
5067 crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5068 ))
5069 }
5070
5071 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5078 Ok(InstallRecord::read(&self.paths)?
5079 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5080 }
5081
5082 #[cfg(target_os = "macos")]
5104 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5105 let root = unsafe { libc::getuid() } == 0;
5111 if root != (drift.start_mode == StartMode::Boot) {
5112 return Err(ServiceError::Control {
5113 operation: "rewrite",
5114 name: self.identity.name().to_string(),
5115 manager: "launchd",
5116 detail: format!(
5117 "the install record says --start-at {}, but this daemon runs as {}; the plist \
5118 is left as it is",
5119 drift.start_mode,
5120 if root { "root" } else { "the operator" }
5121 ),
5122 });
5123 }
5124 replace_definition(self.identity.name(), drift)?;
5125 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5129 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5130 let _ = record.write(&self.paths);
5131 }
5132 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5133 }
5134
5135 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5145 #[cfg(not(windows))]
5146 {
5147 let _ = (mode, &self.runner_root);
5150 Ok(RootAccessChange::not_applicable())
5151 }
5152 #[cfg(windows)]
5153 {
5154 let wrap = |source| ServiceError::RunnerRoot {
5155 source: Box::new(source),
5156 };
5157 if let Some(root) = self
5175 .runner_root
5176 .as_ref()
5177 .filter(|_| self.identity.is_fixture() || cfg!(test))
5178 {
5179 let admission = RootAdmission::of_this_account().map_err(wrap)?;
5180 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5181 .map_err(wrap);
5182 }
5183
5184 let admission = match ServiceAccount::for_start_mode(mode) {
5185 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5188 ServiceAccount::InvokingUser | ServiceAccount::Root => {
5192 RootAdmission::of_this_account().map_err(wrap)?
5193 }
5194 };
5195 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5196 }
5197 }
5198
5199 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5201 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5202 |source| match source {
5203 held @ LockError::Held { .. } => ServiceError::LockHeld {
5204 source: Box::new(held),
5205 },
5206 other => ServiceError::LockUnreadable {
5207 source: Box::new(other),
5208 },
5209 },
5210 )
5211 }
5212
5213 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5215 for mode in [StartMode::Boot, StartMode::Login] {
5216 let control = self.controls.control(mode)?;
5217 if let Some(registration) = control.query(&self.identity)? {
5218 return Ok(Some((registration.start_mode, registration)));
5219 }
5220 }
5221 Ok(None)
5222 }
5223
5224 fn registration_for_status(
5233 &self,
5234 ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5235 let Some((mode, found)) = self.find_registration()? else {
5236 return Ok(None);
5237 };
5238 let Some(directories) = self.foreign_directories(&found) else {
5239 return Ok(Some((found, None)));
5240 };
5241 let other = match mode {
5242 StartMode::Boot => StartMode::Login,
5243 StartMode::Login => StartMode::Boot,
5244 };
5245 let own = self
5246 .controls
5247 .control(other)
5248 .ok()
5249 .and_then(|control| control.query(&self.identity).ok().flatten())
5250 .filter(|registration| self.foreign_directories(registration).is_none());
5251 Ok(Some(match own {
5252 Some(own) => (own, None),
5253 None => (found, Some(directories)),
5254 }))
5255 }
5256
5257 pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5275 #[cfg(target_os = "linux")]
5276 return self.adopt_boot_installation_on_linux(mode);
5277 #[cfg(not(target_os = "linux"))]
5278 {
5279 let _ = mode;
5280 Ok(false)
5281 }
5282 }
5283
5284 #[cfg(target_os = "linux")]
5285 fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5286 if mode != StartMode::Boot {
5287 return Ok(false);
5288 }
5289 self.refuse_overridden_account()?;
5290 if !crate::host_fitness::is_elevated() {
5291 return Ok(false);
5292 }
5293 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5294 return Ok(false);
5295 };
5296 let Some(old) = self.foreign_directories(&found) else {
5297 return Ok(false);
5298 };
5299 self.controls
5300 .control(StartMode::Boot)?
5301 .stop(&self.identity)?;
5302 if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5307 let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5308 for (from, to, skip) in [
5309 (&old.config, self.paths.config_dir(), None),
5310 (&old.state, self.paths.state_dir(), Some("bin")),
5312 ] {
5313 set_aside(to, &stamp)
5314 .and_then(|()| copy_tree(from, to, skip))
5315 .map_err(|error| ServiceError::Control {
5316 operation: "move the data of",
5317 name: self.identity.name().to_string(),
5318 manager: "systemd",
5319 detail: format!(
5320 "cannot copy {} to {}: {error}",
5321 from.display(),
5322 to.display()
5323 ),
5324 })?;
5325 }
5326 }
5327 Ok(true)
5328 }
5329
5330 #[cfg(target_os = "linux")]
5337 fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5338 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5339 return Ok(());
5340 };
5341 match found.account.as_deref() {
5342 Some(account) if account != ServiceAccount::Root.as_str() => {
5343 Err(ServiceError::Control {
5344 operation: "install",
5345 name: self.identity.name().to_string(),
5346 manager: "systemd",
5347 detail: format!(
5348 "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5349 a boot service must run as root to use {}. Remove `User=` and `Group=` \
5350 from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5351 self.identity.systemd_unit(),
5352 crate::paths::SYSTEM_DATA_ROOT,
5353 ),
5354 })
5355 }
5356 _ => Ok(()),
5357 }
5358 }
5359
5360 fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5363 match self.registration_for_status()? {
5364 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5365 _ => Ok(()),
5366 }
5367 }
5368
5369 fn foreign_refusal(
5372 &self,
5373 operation: &'static str,
5374 directories: ServiceDirectories,
5375 ) -> ServiceError {
5376 let foreign = self.foreign_service_at(directories);
5377 ServiceError::ForeignInstallation {
5378 name: self.identity.name().to_string(),
5379 operation,
5380 installed_by: foreign.installed_by(),
5381 act_as: foreign.act_as(),
5382 }
5383 }
5384
5385 fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5388 ServiceDirectories::from_command_line(®istration.command_line)
5389 .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5390 }
5391
5392 #[must_use]
5397 pub fn foreign_service(&self) -> Option<ForeignService> {
5398 let (_, directories) = self.registration_for_status().ok()??;
5399 Some(self.foreign_service_at(directories?))
5400 }
5401
5402 fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5405 let root = directories.root();
5406 let home = host_home();
5407 let this_account = home
5410 .as_deref()
5411 .filter(|home| contains_directory(home, &root))
5412 .and_then(|_| {
5413 if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5414 Some(format!(
5415 "with `--data-dir {}`",
5416 quote_argument(&root.to_string_lossy())
5417 ))
5418 } else {
5419 AppPaths::discover()
5420 .ok()
5421 .filter(|standard| directories == ServiceDirectories::of(standard))
5422 .map(|_| "without `--data-dir`".to_owned())
5423 }
5424 });
5425 ForeignService {
5426 owner: data_root_owner(&root, home.as_deref()),
5427 this_account,
5428 root,
5429 directories,
5430 local_root: ServiceDirectories::of(&self.paths).root(),
5431 local_record: InstallRecord::path(&self.paths),
5432 unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5433 unreadable: None,
5434 }
5435 }
5436
5437 fn foreign_status(
5444 &self,
5445 registration: Registration,
5446 directories: ServiceDirectories,
5447 ) -> ServiceStatus {
5448 let service_paths = directories.paths();
5449 let mut foreign = self.foreign_service_at(directories);
5450 let unreadable = match InstallRecord::read(&service_paths) {
5451 Ok(Some(record)) => {
5452 match self.status_at(
5453 &service_paths,
5454 Some(record),
5455 None,
5456 Some(registration.clone()),
5457 ) {
5458 Ok(status) => return status.with_foreign(foreign),
5459 Err(error) => error.to_string(),
5460 }
5461 }
5462 Ok(None) => format!(
5463 "there is no install record at {}",
5464 InstallRecord::path(&service_paths).display()
5465 ),
5466 Err(
5471 ServiceError::RecordNotPermitted { path, detail }
5472 | ServiceError::RecordUnreadable { path, detail }
5473 | ServiceError::Record { path, detail, .. },
5474 ) => format!("this account cannot read {} ({detail})", path.display()),
5475 Err(error) => error.to_string(),
5476 };
5477 foreign.unreadable = Some(unreadable);
5478 ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5479 }
5480
5481 pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5505 let config = self.paths.config_dir();
5506 let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5507 let mut service_root = None;
5508 for mode in [StartMode::Boot, StartMode::Login] {
5509 let registration = self
5510 .controls
5511 .control(mode)
5512 .and_then(|control| control.query(&self.identity))
5513 .map_err(|error| {
5514 in_use(format!(
5515 "whether a {mode} registration runs against it could not be read ({error})"
5516 ))
5517 })?;
5518 let Some(registration) = registration else {
5519 continue;
5520 };
5521 let Some(directories) = self.foreign_directories(®istration) else {
5522 return Err(in_use(format!(
5523 "{} holds a registration that runs against {}. `service status` describes it",
5524 registration.manager,
5525 config.display()
5526 )));
5527 };
5528 if let Some(overlap) = directories.all().into_iter().find(|directory| {
5529 contains_directory(directory, config) || contains_directory(config, directory)
5530 }) {
5531 return Err(in_use(format!(
5532 "the machine's service runs against {}, which overlaps {}",
5533 overlap.display(),
5534 config.display()
5535 )));
5536 }
5537 service_root.get_or_insert_with(|| directories.root());
5538 }
5539 let _lock =
5540 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5541 in_use(format!(
5542 "an agent may be running against these directories: {source}"
5543 ))
5544 })?;
5545 let failed = |operation: &'static str, path: &Path| {
5546 let path = path.to_path_buf();
5547 move |error: std::io::Error| ServiceError::Record {
5548 operation,
5549 path,
5550 detail: error.to_string(),
5551 }
5552 };
5553 let kept = [
5555 self.paths.state_dir(),
5556 self.paths.runtime_dir(),
5557 self.paths.logs_dir(),
5558 ];
5559 let mut entries = Vec::new();
5560 for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5561 let entry = entry.map_err(failed("list", config))?.path();
5562 let archived = entry
5563 .file_name()
5564 .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5565 if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5566 entries.push(entry);
5567 }
5568 }
5569 if entries.is_empty() {
5570 return Ok(ForgottenLocal {
5571 archive: None,
5572 moved: entries,
5573 service_root,
5574 });
5575 }
5576 entries.sort();
5577 let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5578 std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5579 let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5580 for (index, entry) in entries.iter().enumerate() {
5581 if let Err(error) = std::fs::rename(entry, inside(entry)) {
5582 let stranded: Vec<String> = entries[..index]
5583 .iter()
5584 .rev()
5585 .filter(|done| std::fs::rename(inside(done), done).is_err())
5586 .map(|done| inside(done).display().to_string())
5587 .collect();
5588 let _ = std::fs::remove_dir(&archive);
5589 let mut detail = error.to_string();
5590 if !stranded.is_empty() {
5591 detail.push_str(&format!(
5592 "; these could not be put back and are still in the archive: {}",
5593 stranded.join(", ")
5594 ));
5595 }
5596 return Err(ServiceError::Record {
5597 operation: "archive",
5598 path: entry.clone(),
5599 detail,
5600 });
5601 }
5602 }
5603 Ok(ForgottenLocal {
5604 archive: Some(archive),
5605 moved: entries,
5606 service_root,
5607 })
5608 }
5609}
5610
5611pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5614
5615#[derive(Debug, Clone, PartialEq, Eq)]
5617pub struct ForgottenLocal {
5618 pub archive: Option<PathBuf>,
5620 pub moved: Vec<PathBuf>,
5622 pub service_root: Option<PathBuf>,
5625}
5626
5627impl fmt::Display for ForgottenLocal {
5628 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5629 let Some(archive) = &self.archive else {
5630 return write!(
5631 f,
5632 "Nothing to archive: this account's configuration directory is empty."
5633 );
5634 };
5635 writeln!(f, "Archived this account's unused local configuration")?;
5636 writeln!(f, " archive {}", archive.display())?;
5637 for path in &self.moved {
5638 writeln!(f, " moved {}", path.display())?;
5639 }
5640 if let Some(root) = &self.service_root {
5641 writeln!(
5642 f,
5643 " service data root {} (untouched)",
5644 root.display()
5645 )?;
5646 }
5647 write!(
5648 f,
5649 " restore move everything in the archive back into its parent \
5650 directory"
5651 )
5652 }
5653}
5654
5655fn same_directory(left: &Path, right: &Path) -> bool {
5658 let text = |path: &Path| {
5659 path.to_string_lossy()
5660 .trim_end_matches(['/', '\\'])
5661 .to_owned()
5662 };
5663 same_path_text(&text(left), &text(right))
5664 || matches!(
5665 (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5666 (Ok(left), Ok(right)) if left == right
5667 )
5668}
5669
5670const REMEDY_OPENINGS: [&str; 8] = [
5673 ". Run ",
5674 ". If nothing is waiting",
5675 ". Re-register",
5676 ". Switch the start mode",
5677 " Sign in again",
5678 ". Fix: ",
5679 "; to do it now",
5680 "; if the service manager denies",
5681];
5682
5683fn diagnosis_only(detail: &str) -> String {
5692 let cut = REMEDY_OPENINGS
5693 .iter()
5694 .filter_map(|opening| detail.find(opening))
5695 .min()
5696 .unwrap_or(detail.len());
5697 let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5698 let names_a_change = |text: &str| {
5699 [
5700 "service install",
5701 "service uninstall",
5702 "auth login",
5703 "launchctl",
5704 "systemctl",
5705 ]
5706 .iter()
5707 .any(|command| text.contains(command))
5708 };
5709 if !names_a_change(diagnosis) {
5710 return diagnosis.to_owned();
5711 }
5712 let end = [", ", "; ", ". "]
5714 .iter()
5715 .filter_map(|separator| diagnosis.find(separator))
5716 .min()
5717 .unwrap_or(diagnosis.len());
5718 let first = &diagnosis[..end];
5719 if names_a_change(first) {
5720 "the installation's own records report this".to_owned()
5721 } else {
5722 first.to_owned()
5723 }
5724}
5725
5726fn path_key(path: &Path) -> Vec<String> {
5729 path.components()
5730 .map(|component| {
5731 let text = component.as_os_str().to_string_lossy();
5732 if cfg!(windows) {
5733 text.to_lowercase()
5734 } else {
5735 text.into_owned()
5736 }
5737 })
5738 .collect()
5739}
5740
5741fn contains_directory(outer: &Path, inner: &Path) -> bool {
5744 path_key(inner).starts_with(&path_key(outer))
5745 || matches!(
5746 (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5747 (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5748 )
5749}
5750
5751#[must_use]
5756pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5757 let target = path_key(root);
5758 if cfg!(windows) {
5759 if target
5760 .windows(3)
5761 .any(|run| run == ["system32", "config", "systemprofile"])
5762 {
5763 return Some("LocalSystem".to_owned());
5764 }
5765 } else if [
5766 "/root",
5767 "/var/root",
5768 "/private/var/root",
5769 crate::paths::SYSTEM_DATA_ROOT,
5770 ]
5771 .iter()
5772 .any(|home| root.starts_with(home))
5773 {
5774 return Some("root".to_owned());
5775 }
5776 let users = path_key(home?.parent()?);
5777 if users.len() < 2 {
5780 return None;
5781 }
5782 if target.len() > users.len() && target[..users.len()] == users[..] {
5783 root.components()
5784 .nth(users.len())
5785 .map(|name| name.as_os_str().to_string_lossy().into_owned())
5786 } else {
5787 None
5788 }
5789}
5790
5791#[derive(Debug, Clone, PartialEq, Eq)]
5805pub struct ForeignService {
5806 pub directories: ServiceDirectories,
5808 pub root: PathBuf,
5810 pub owner: Option<String>,
5812 pub this_account: Option<String>,
5817 pub local_root: PathBuf,
5819 pub local_record: PathBuf,
5821 pub unused_local_record: Option<InstallRecord>,
5823 pub unreadable: Option<String>,
5826}
5827
5828impl ForeignService {
5829 #[must_use]
5831 pub fn where_to_look(&self) -> String {
5832 if let Some(reach) = &self.this_account {
5833 return reach.clone();
5834 }
5835 let elevated = if cfg!(windows) {
5836 "from an elevated terminal (Run as administrator)"
5837 } else {
5838 "with sudo"
5839 };
5840 match &self.owner {
5841 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5842 format!("{elevated}, or signed in as {owner}")
5843 }
5844 _ => elevated.to_owned(),
5845 }
5846 }
5847
5848 #[must_use]
5853 pub fn act_as(&self) -> String {
5854 if let Some(reach) = &self.this_account {
5855 return reach.clone();
5856 }
5857 match &self.owner {
5858 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5859 format!("signed in as {owner}")
5860 }
5861 _ => format!(
5862 "from the account whose data root {} is",
5863 self.root.display()
5864 ),
5865 }
5866 }
5867
5868 #[must_use]
5871 pub const fn is_unread(&self) -> bool {
5872 self.unreadable.is_some()
5873 }
5874
5875 #[must_use]
5877 pub fn installed_by(&self) -> String {
5878 match &self.owner {
5879 Some(owner) => format!("{owner}'s data root {}", self.root.display()),
5880 None => format!("the data root {}", self.root.display()),
5881 }
5882 }
5883
5884 #[must_use]
5886 pub fn label(&self) -> String {
5887 match &self.owner {
5888 Some(owner) => format!("{} ({owner})", self.root.display()),
5889 None => self.root.display().to_string(),
5890 }
5891 }
5892
5893 #[must_use]
5895 pub fn unused_local_note(&self) -> Option<String> {
5896 let record = self.unused_local_record.as_ref()?;
5897 Some(format!(
5898 "unused local configuration from an earlier install: {} records a {} registration \
5899 made by runner-manager {} on {}, and no service on this machine runs against it. \
5900 `runner-manager host forget-local` moves it aside; it never touches the service's \
5901 own directories or any secret.",
5902 self.local_record.display(),
5903 record.start_mode,
5904 record.installed_by_version,
5905 record.installed_at.format("%Y-%m-%d"),
5906 ))
5907 }
5908}
5909
5910#[derive(Debug, Clone, PartialEq, Eq)]
5916pub struct StatusProblem {
5917 pub subject: &'static str,
5919 pub detail: String,
5921}
5922
5923impl fmt::Display for StatusProblem {
5924 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5925 write!(f, "{}: {}", self.subject, self.detail)
5926 }
5927}
5928
5929#[derive(Debug, Clone)]
5938pub struct ServiceStatus {
5939 identity: ServiceIdentity,
5940 record: Option<InstallRecord>,
5941 registration: Option<Registration>,
5942 binary: Option<BinaryPath>,
5943 log_file: PathBuf,
5944 store: Option<crate::secrets::ActiveStore>,
5945 last_github_contact: Option<DateTime<Utc>>,
5946 runner_root: Option<(PathBuf, RootAccessReport)>,
5947 credential_rejected_since: Option<DateTime<Utc>>,
5948 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
5949 unattended_gap: Option<UnattendedGap>,
5950 foreign: Option<ForeignService>,
5951 problems: Vec<StatusProblem>,
5952 notes: Vec<String>,
5953}
5954
5955#[derive(Debug, Clone, PartialEq, Eq)]
5958pub struct UnattendedGap {
5959 pub reason: String,
5961 pub remedy: String,
5963}
5964
5965#[must_use]
5968pub fn service_account_name(account: &ServiceAccount) -> String {
5969 match account {
5970 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
5972 .ok()
5973 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
5974 .or_else(crate::unattended_login::current_account)
5975 .unwrap_or_else(|| "the operator".to_owned()),
5976 other => other.as_str().to_owned(),
5977 }
5978}
5979
5980fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
5983 registration
5984 .and_then(|registration| registration.account.as_deref())
5985 .unwrap_or(recorded)
5986}
5987
5988#[cfg(any(windows, test))]
5992fn windows_service_account(name: &str) -> String {
5993 if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
5994 ServiceAccount::LocalSystem.as_str().to_string()
5995 } else {
5996 name.to_string()
5997 }
5998}
5999
6000#[cfg(any(windows, test))]
6003fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
6004 user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
6005}
6006
6007#[must_use]
6016pub fn unattended_gap(
6017 mode: StartMode,
6018 account: &str,
6019 found: Option<&crate::unattended_login::UnattendedLogin>,
6020) -> Option<UnattendedGap> {
6021 let Some(found) = found else {
6022 return (mode == StartMode::Login).then(|| UnattendedGap {
6023 reason: "This registration starts at login, so the agent does not run until the \
6024 operator signs in; this host does not resume work after an unattended \
6025 reboot."
6026 .to_owned(),
6027 remedy: "For machine-on operation, move the service and credential to `--start-at \
6028 boot` from an elevated terminal."
6029 .to_owned(),
6030 });
6031 };
6032 let resume = crate::unattended_login::resume(found, mode, account);
6033 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6034 return None;
6037 }
6038 Some(UnattendedGap {
6039 reason: resume.explain(account)?,
6040 remedy: resume.remedy().unwrap_or_else(|| {
6041 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6042 FileVault can be read."
6043 .to_owned()
6044 }),
6045 })
6046}
6047
6048impl ServiceStatus {
6049 fn compose(
6054 identity: ServiceIdentity,
6055 record: Option<InstallRecord>,
6056 record_refused: Option<&ServiceError>,
6057 registration: Option<Registration>,
6058 last_github_contact: Option<DateTime<Utc>>,
6059 paths: &AppPaths,
6060 ) -> Self {
6061 let mut problems = Vec::new();
6062 let mut notes = Vec::new();
6063
6064 if let Some(refusal) = record_refused {
6065 problems.push(StatusProblem {
6070 subject: "install record",
6071 detail: refusal.to_string(),
6072 });
6073 }
6074
6075 let log_file = record.as_ref().map_or_else(
6076 || ServiceDirectories::of(paths).log_file(),
6077 |record| record.log_file.clone(),
6078 );
6079
6080 let binary = record.as_ref().map(|record| {
6081 inspect_binary(
6082 &record.binary,
6083 registration
6084 .as_ref()
6085 .and_then(Registration::binary)
6086 .as_deref(),
6087 )
6088 });
6089 if let Some(state) = &binary
6090 && state.is_error()
6091 {
6092 problems.push(StatusProblem {
6093 subject: "binary",
6094 detail: state.to_string(),
6095 });
6096 }
6097
6098 match (&record, ®istration) {
6099 (Some(_), None) => problems.push(StatusProblem {
6100 subject: "registration",
6101 detail: "this host has a service record but no service manager knows the \
6102 registration. Run `service install` again; it deletes nothing."
6103 .to_string(),
6104 }),
6105 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6110 subject: "record",
6111 detail: format!(
6112 "{} holds a registration for this service but there is no install record, so \
6113 the path it was installed from and the directories it was installed against \
6114 are unknown. Run `service uninstall` and `service install`.",
6115 found.manager
6116 ),
6117 }),
6118 (None, Some(_)) => {}
6119 (Some(record), Some(found)) => {
6120 if record.start_mode != found.start_mode {
6121 problems.push(StatusProblem {
6122 subject: "start mode",
6123 detail: format!(
6124 "the record says {} and {} holds a {} registration. Switch the start \
6125 mode again to make them agree.",
6126 record.start_mode, found.manager, found.start_mode
6127 ),
6128 });
6129 }
6130 if record.start_mode == StartMode::Boot && !found.starts_automatically {
6131 problems.push(StatusProblem {
6132 subject: "start mode",
6133 detail: format!(
6134 "{} holds the registration but will not start it by itself, so this \
6135 host does not resume work after a reboot.",
6136 found.manager
6137 ),
6138 });
6139 }
6140 if let Some(actual) = found.restart_delay {
6141 let expected = record.restart().effective_delay(found.manager);
6142 if actual != expected {
6143 problems.push(StatusProblem {
6144 subject: "restart policy",
6145 detail: format!(
6146 "the record says the service restarts after {}s and {} reports \
6147 {}s. Something has edited the registration since it was \
6148 installed.",
6149 expected.as_secs(),
6150 found.manager,
6151 actual.as_secs()
6152 ),
6153 });
6154 } else if expected != record.restart().delay() {
6155 notes.push(format!(
6160 "{} expresses the restart delay in whole minutes, so the {}s asked \
6161 for is enforced as {}s. The service therefore never restarts faster \
6162 than the configured bound.",
6163 found.manager,
6164 record.restart().delay().as_secs(),
6165 expected.as_secs()
6166 ));
6167 }
6168 }
6169 if found.starts_automatically && !found.running {
6170 problems.push(StatusProblem {
6171 subject: "runtime",
6172 detail: format!(
6173 "{} holds an automatic registration, but the daemon is stopped. Run \
6174 `runner-manager service install --start-at {}`; if the service \
6175 manager denies replacement, retry from an elevated terminal. If it \
6176 stops again, inspect {}.",
6177 found.manager,
6178 found.start_mode,
6179 log_file.display()
6180 ),
6181 });
6182 }
6183 }
6184 (None, None) => {}
6185 }
6186
6187 let store = record.as_ref().and_then(|record| {
6192 let registered_mode = registration
6193 .as_ref()
6194 .map_or(record.start_mode, |found| found.start_mode);
6195 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6196 .ok()
6197 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6198 });
6199 if let Some(store) = &store
6200 && !store.agrees_with_start_mode()
6201 {
6202 problems.push(StatusProblem {
6203 subject: "secret store",
6204 detail: format!(
6205 "{store}. Run `auth login` again so the token is stored where the registered \
6206 start mode can read it."
6207 ),
6208 });
6209 }
6210
6211 if last_github_contact.is_none() {
6212 notes.push(
6213 "GitHub has not been reached successfully since this host's state directory was \
6214 created."
6215 .to_string(),
6216 );
6217 }
6218
6219 let runner_root = crate::runner_root::default_runner_root(paths)
6226 .ok()
6227 .map(|root| {
6228 let path = root.as_path().to_path_buf();
6229 let report = crate::runner_root_access::report(&path);
6230 (path, report)
6231 });
6232 if let Some((
6242 path,
6243 RootAccessReport::Present {
6244 broad_write: true, ..
6245 },
6246 )) = &runner_root
6247 {
6248 notes.push(format!(
6249 "the platform default runner root {} can be written by ordinary local users, so \
6250 it is not a safe place to run jobs. `service install` refuses it rather than \
6251 tightening it, because the contents of a directory anybody could write cannot \
6252 be trusted: remove or empty it, or choose another root with `runner-manager \
6253 host set-runtime-root --path <PATH>`.",
6254 path.display()
6255 ));
6256 }
6257
6258 match runner_root_refusals(paths) {
6281 Ok(refusals) => {
6282 for refusal in refusals {
6283 notes.push(format!(
6284 "policy {} started no runner: its runner root {} refused the launch \
6285 ({}), last at {}. {} This clears when that policy next places a \
6286 runner.",
6287 refusal.policy,
6288 refusal.root,
6289 refusal.kind,
6290 refusal.at.to_rfc3339(),
6291 refusal.detail,
6292 ));
6293 }
6294 }
6295 Err(error) => notes.push(format!(
6296 "whether the agent could use its runner roots could not be read: {error}"
6297 )),
6298 }
6299
6300 Self {
6301 identity,
6302 record,
6303 registration,
6304 binary,
6305 log_file,
6306 store,
6307 last_github_contact,
6308 runner_root,
6309 credential_rejected_since: None,
6310 launches_blocked: None,
6311 unattended_gap: None,
6312 foreign: None,
6313 problems,
6314 notes,
6315 }
6316 }
6317
6318 fn with_foreign(mut self, foreign: ForeignService) -> Self {
6329 let act_as = foreign.act_as();
6330 for problem in &mut self.problems {
6331 problem.detail = format!(
6332 "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6333 own directories, not the service's.",
6334 diagnosis_only(&problem.detail)
6335 );
6336 }
6337 if let Some(note) = foreign.unused_local_note() {
6338 self.notes.push(note);
6339 }
6340 self.foreign = Some(foreign);
6341 self
6342 }
6343
6344 fn foreign_unread(
6352 identity: ServiceIdentity,
6353 registration: Registration,
6354 foreign: ForeignService,
6355 ) -> Self {
6356 let mut problems = Vec::new();
6357 let look = foreign.where_to_look();
6358 if registration.starts_automatically && !registration.running {
6359 problems.push(StatusProblem {
6360 subject: "runtime",
6361 detail: format!(
6362 "{} holds an automatic registration for the service installed from {}, but \
6363 it is stopped. Run `runner-manager service status` {look} to see why; do not \
6364 reinstall it from this account.",
6365 registration.manager,
6366 foreign.installed_by(),
6367 ),
6368 });
6369 }
6370 if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6371 problems.push(StatusProblem {
6372 subject: "start mode",
6373 detail: format!(
6374 "{} holds the boot registration installed from {} but will not start it by \
6375 itself. Run `runner-manager service status` {look}.",
6376 registration.manager,
6377 foreign.installed_by(),
6378 ),
6379 });
6380 }
6381 let mut notes = Vec::new();
6382 if let Some(reason) = &foreign.unreadable {
6383 notes.push(format!(
6384 "its health is not known here: {reason}. Run `runner-manager service status` \
6385 {look} to see it."
6386 ));
6387 }
6388 notes.extend(foreign.unused_local_note());
6389 Self {
6390 identity,
6391 record: None,
6392 log_file: foreign.directories.log_file(),
6393 registration: Some(registration),
6394 binary: None,
6395 store: None,
6396 last_github_contact: None,
6397 runner_root: None,
6398 credential_rejected_since: None,
6399 launches_blocked: None,
6400 unattended_gap: None,
6401 foreign: Some(foreign),
6402 problems,
6403 notes,
6404 }
6405 }
6406
6407 #[must_use]
6410 pub const fn foreign(&self) -> Option<&ForeignService> {
6411 self.foreign.as_ref()
6412 }
6413
6414 #[must_use]
6417 pub fn is_unread(&self) -> bool {
6418 self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6419 }
6420
6421 #[must_use]
6423 pub fn verdict(&self) -> String {
6424 if !self.is_healthy() {
6425 return "NOT healthy".to_owned();
6426 }
6427 match &self.foreign {
6428 Some(foreign) if foreign.is_unread() => format!(
6429 "{}; installed from {}, so its health is read {}",
6430 if self.is_running() {
6431 "running"
6432 } else {
6433 "not running"
6434 },
6435 foreign.installed_by(),
6436 foreign.where_to_look()
6437 ),
6438 _ => "healthy".to_owned(),
6439 }
6440 }
6441
6442 #[must_use]
6444 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6445 if let Some(drift) = drift {
6446 if drift.manager == DefinitionKind::LaunchdPlist {
6447 let restart = match drift.start_mode {
6451 StartMode::Boot => format!(
6452 "sudo launchctl kickstart -k system/{}",
6453 self.identity.launchd_label()
6454 ),
6455 StartMode::Login => format!(
6456 "launchctl kickstart -k gui/$(id -u)/{}",
6457 self.identity.launchd_label()
6458 ),
6459 };
6460 self.problems.push(StatusProblem {
6461 subject: "definition",
6462 detail: format!(
6463 "{} was written by runner-manager {} with a `ProcessType` other than \
6464 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6465 daemon starts, so their jobs run below normal priority. The daemon \
6466 rewrites it and reloads itself when it starts with no runner, which it \
6467 does after every upgrade; to do it now, restart the service while no \
6468 job is running: `{restart}`. Configuration and credentials are kept.",
6469 drift.path.display(),
6470 drift.installed_by_version,
6471 ),
6472 });
6473 return self;
6474 }
6475 let repair = match drift.start_mode {
6476 StartMode::Boot => format!(
6477 "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6478 `runner-manager wsl install --distribution <name>` from Windows)",
6479 sudo_command("service install --start-at boot")
6480 ),
6481 StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6482 service install --start-at login`"
6483 .to_owned(),
6484 };
6485 self.problems.push(StatusProblem {
6486 subject: "definition",
6487 detail: format!(
6488 "{} was written by runner-manager {} and differs from what {} renders, so \
6489 the daemon runs under outdated settings (for example a credential frozen \
6490 at service start). Re-register it with {repair}; configuration and \
6491 credentials are kept.",
6492 drift.path.display(),
6493 drift.installed_by_version,
6494 env!("CARGO_PKG_VERSION"),
6495 ),
6496 });
6497 }
6498 self
6499 }
6500
6501 #[must_use]
6503 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6504 if let Some(since) = since {
6505 self.credential_rejected_since = Some(since);
6506 self.problems.push(StatusProblem {
6507 subject: "github credential",
6508 detail: format!(
6509 "GitHub has rejected the service's credential since {}, so the service \
6510 cannot see queued jobs or start runners. Run `runner-manager auth login` \
6511 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6512 from Windows).",
6513 since.to_rfc3339()
6514 ),
6515 });
6516 }
6517 self
6518 }
6519
6520 #[must_use]
6522 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6523 self.credential_rejected_since
6524 }
6525
6526 #[must_use]
6529 pub fn with_launches_blocked(
6530 mut self,
6531 blocked: Option<crate::launch_health::LaunchesBlocked>,
6532 ) -> Self {
6533 if let Some(blocked) = &blocked {
6534 self.problems.push(StatusProblem {
6535 subject: LAUNCHES_BLOCKED_SUBJECT,
6536 detail: format!(
6537 "the service has started no runner {blocked}. Fix: {}.",
6538 blocked.remedy
6539 ),
6540 });
6541 }
6542 self.launches_blocked = blocked;
6543 self
6544 }
6545
6546 #[must_use]
6548 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6549 self.launches_blocked.as_ref()
6550 }
6551
6552 #[must_use]
6555 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6556 if let Some(since) = since {
6557 let mode = self.start_mode().unwrap_or_default();
6558 self.problems.push(StatusProblem {
6559 subject: CREDENTIAL_UNREADABLE_SUBJECT,
6560 detail: format!(
6561 "the service has been unable to read its stored GitHub credential since {}, \
6562 so it starts no runner; the diagnostic log has the store's own answer.{} \
6563 Sign in again: {}.",
6564 since.to_rfc3339(),
6565 if cfg!(target_os = "macos") {
6566 " That is what a keychain item written by a different build looks like."
6567 } else {
6568 ""
6569 },
6570 sign_in_instruction(mode)
6571 ),
6572 });
6573 }
6574 self
6575 }
6576
6577 #[must_use]
6585 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6586 self.runner_root
6587 .as_ref()
6588 .map(|(path, report)| (path.as_path(), report))
6589 }
6590
6591 #[must_use]
6593 pub const fn is_installed(&self) -> bool {
6594 self.registration.is_some() || self.record.is_some()
6595 }
6596
6597 #[must_use]
6599 pub fn is_running(&self) -> bool {
6600 self.registration
6601 .as_ref()
6602 .is_some_and(|registration| registration.running)
6603 }
6604
6605 #[must_use]
6607 pub fn is_healthy(&self) -> bool {
6608 self.problems.is_empty()
6609 }
6610
6611 #[must_use]
6613 pub fn problems(&self) -> &[StatusProblem] {
6614 &self.problems
6615 }
6616
6617 #[must_use]
6620 pub fn notes(&self) -> &[String] {
6621 &self.notes
6622 }
6623
6624 #[must_use]
6629 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6630 if let Some(unfit) = unfit {
6631 self.problems.push(StatusProblem {
6632 subject: HOST_UNFIT_SUBJECT,
6633 detail: format!(
6634 "the service has started no runner since {}, because a required host check \
6635 fails from its own session: {}",
6636 unfit.since.to_rfc3339(),
6637 unfit.describe()
6638 ),
6639 });
6640 }
6641 self
6642 }
6643
6644 #[must_use]
6648 pub fn with_stall(mut self, stall: Option<String>) -> Self {
6649 if let Some(detail) = stall {
6650 self.problems.push(StatusProblem {
6651 subject: DAEMON_STALLED_SUBJECT,
6652 detail,
6653 });
6654 }
6655 self
6656 }
6657
6658 #[must_use]
6661 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6662 if let Some(gap) = &gap {
6663 self.notes.push(gap.reason.clone());
6664 }
6665 self.unattended_gap = gap;
6666 self
6667 }
6668
6669 #[must_use]
6672 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6673 self.unattended_gap.as_ref()
6674 }
6675
6676 #[must_use]
6679 pub fn restart_command(&self) -> String {
6680 self.identity
6681 .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6682 }
6683
6684 #[must_use]
6688 pub fn start_mode(&self) -> Option<StartMode> {
6689 self.record
6690 .as_ref()
6691 .map(|record| record.start_mode)
6692 .or_else(|| {
6693 self.registration
6694 .as_ref()
6695 .filter(|_| self.is_unread())
6696 .map(|registration| registration.start_mode)
6697 })
6698 }
6699
6700 #[must_use]
6704 pub fn service_binary(&self) -> Option<PathBuf> {
6705 self.record
6706 .as_ref()
6707 .map(|record| record.binary.clone())
6708 .or_else(|| {
6709 self.registration
6710 .as_ref()
6711 .filter(|_| self.is_unread())
6712 .and_then(Registration::binary)
6713 })
6714 }
6715
6716 #[must_use]
6719 pub const fn binary(&self) -> Option<&BinaryPath> {
6720 self.binary.as_ref()
6721 }
6722
6723 #[must_use]
6725 pub fn log_file(&self) -> &Path {
6726 &self.log_file
6727 }
6728
6729 #[must_use]
6731 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6732 self.last_github_contact
6733 }
6734
6735 #[must_use]
6738 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6739 self.store.as_ref()
6740 }
6741
6742 #[must_use]
6744 pub const fn record(&self) -> Option<&InstallRecord> {
6745 self.record.as_ref()
6746 }
6747
6748 #[must_use]
6750 pub const fn registration(&self) -> Option<&Registration> {
6751 self.registration.as_ref()
6752 }
6753}
6754
6755impl fmt::Display for ServiceStatus {
6756 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6757 writeln!(f, "Service: {}", self.identity)?;
6758 match (&self.record, &self.registration) {
6759 (None, None) => {
6760 writeln!(
6761 f,
6762 " installed no. `service install` registers `{} {}`.",
6763 SERVICE_NAME,
6764 DAEMON_ARGUMENTS.join(" ")
6765 )?;
6766 }
6767 _ => {
6768 let manager = self
6769 .registration
6770 .as_ref()
6771 .map(|registration| registration.manager.manager());
6772 writeln!(
6773 f,
6774 " installed {}",
6775 manager.unwrap_or("yes, but no service manager knows it")
6776 )?;
6777 writeln!(
6778 f,
6779 " state {}",
6780 if self.is_running() {
6781 "running"
6782 } else {
6783 "not running"
6784 }
6785 )?;
6786 }
6787 }
6788 if let Some(foreign) = &self.foreign {
6789 writeln!(
6790 f,
6791 " installed from {} -- another installation's, not this account's ({})",
6792 foreign.installed_by(),
6793 foreign.local_root.display()
6794 )?;
6795 if foreign.is_unread()
6797 && let Some(registration) = &self.registration
6798 {
6799 writeln!(f, " start mode {}", registration.start_mode)?;
6800 if let Some(account) = ®istration.account {
6801 writeln!(f, " account {account}")?;
6802 }
6803 if let Some(binary) = self.service_binary() {
6804 writeln!(f, " binary {}", binary.display())?;
6805 }
6806 }
6807 }
6808 if let Some(record) = &self.record {
6809 writeln!(f, " start mode {}", record.start_mode)?;
6810 let recorded = record.account.to_string();
6811 let account = registered_account(self.registration.as_ref(), &recorded);
6812 writeln!(f, " account {account}")?;
6813 writeln!(f, " restart on failure {}", record.restart())?;
6814 writeln!(
6815 f,
6816 " arguments {}",
6817 record.arguments.join(" ")
6818 )?;
6819 }
6820 if let Some(binary) = &self.binary {
6821 writeln!(f, " binary {binary}")?;
6822 }
6823 writeln!(f, " diagnostic log {}", self.log_file.display())?;
6824 if let Some((path, report)) = &self.runner_root {
6825 writeln!(f, " default runner root {}", path.display())?;
6832 if *report != RootAccessReport::NotApplicable {
6833 writeln!(f, " default root access {report}")?;
6834 }
6835 }
6836 if let Some(store) = &self.store {
6837 writeln!(f, " secret store {store}")?;
6838 }
6839 if !self.is_unread() {
6842 writeln!(
6843 f,
6844 " last GitHub contact {}",
6845 match self.last_github_contact {
6846 Some(at) => at.to_rfc3339(),
6847 None => "never".to_string(),
6848 }
6849 )?;
6850 }
6851 for note in &self.notes {
6852 writeln!(f, " note {note}")?;
6853 }
6854 for problem in &self.problems {
6855 writeln!(f, " ERROR {problem}")?;
6856 }
6857 write!(f, " verdict {}", self.verdict())
6858 }
6859}
6860
6861#[derive(Debug, Clone, Default)]
6878pub struct RecordingControls {
6879 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6880}
6881
6882#[derive(Debug, Default)]
6883struct RecordingState {
6884 registrations: BTreeMap<(StartMode, String), Registration>,
6885 definitions: BTreeMap<String, ServiceDefinition>,
6886 calls: Vec<String>,
6887 #[cfg(test)]
6888 install_failures: BTreeMap<StartMode, String>,
6889 #[cfg(test)]
6890 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
6891}
6892
6893#[cfg(test)]
6894#[derive(Debug, Clone)]
6895enum TestInstallSideEffect {
6896 HideDirectory { directory: PathBuf, hidden: PathBuf },
6897}
6898
6899impl RecordingControls {
6900 #[must_use]
6902 pub fn new() -> Self {
6903 Self::default()
6904 }
6905
6906 #[must_use]
6909 pub fn calls(&self) -> Vec<String> {
6910 self.state.lock().expect("not poisoned").calls.clone()
6911 }
6912
6913 #[must_use]
6915 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
6916 self.state
6917 .lock()
6918 .expect("not poisoned")
6919 .registrations
6920 .iter()
6921 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
6922 .collect()
6923 }
6924
6925 #[must_use]
6927 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
6928 self.state
6929 .lock()
6930 .expect("not poisoned")
6931 .definitions
6932 .get(name)
6933 .cloned()
6934 }
6935
6936 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
6946 let mut state = self.state.lock().expect("not poisoned");
6947 if let Some((_, registration)) = state
6948 .registrations
6949 .iter_mut()
6950 .find(|((_, held), _)| held == name)
6951 {
6952 edit(registration);
6953 }
6954 }
6955
6956 #[cfg(test)]
6957 fn fail_next_install(&self, mode: StartMode, detail: &str) {
6958 self.state
6959 .lock()
6960 .expect("not poisoned")
6961 .install_failures
6962 .insert(mode, detail.to_string());
6963 }
6964
6965 #[cfg(test)]
6966 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
6967 self.state
6968 .lock()
6969 .expect("not poisoned")
6970 .after_install
6971 .insert(
6972 mode,
6973 TestInstallSideEffect::HideDirectory { directory, hidden },
6974 );
6975 }
6976}
6977
6978impl ControlFactory for RecordingControls {
6979 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6980 Ok(Box::new(RecordingControl {
6981 mode,
6982 state: std::sync::Arc::clone(&self.state),
6983 }))
6984 }
6985}
6986
6987#[derive(Debug)]
6988struct RecordingControl {
6989 mode: StartMode,
6990 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6991}
6992
6993impl RecordingControl {
6994 fn note(&self, operation: &str, name: &str) {
6995 self.state
6996 .lock()
6997 .expect("not poisoned")
6998 .calls
6999 .push(format!("{operation} {name} ({})", self.mode));
7000 }
7001}
7002
7003impl ServiceControl for RecordingControl {
7004 fn manager(&self) -> DefinitionKind {
7005 host_definition_kind(self.mode)
7009 }
7010
7011 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7012 self.note("install", plan.identity().name());
7013 #[cfg(test)]
7014 if let Some(detail) = self
7015 .state
7016 .lock()
7017 .expect("not poisoned")
7018 .install_failures
7019 .remove(&self.mode)
7020 {
7021 return Err(ServiceError::Control {
7022 operation: "install",
7023 name: plan.identity().name().to_string(),
7024 manager: "recording control",
7025 detail,
7026 });
7027 }
7028 let definition = ServiceDefinition::for_host(plan)?;
7033 let mut state = self.state.lock().expect("not poisoned");
7034 state.registrations.insert(
7035 (self.mode, plan.identity().name().to_string()),
7036 Registration {
7037 manager: host_definition_kind(self.mode),
7038 start_mode: self.mode,
7039 command_line: plan.command_line(),
7040 account: (*plan.account() != ServiceAccount::InvokingUser)
7041 .then(|| plan.account().as_str().to_string()),
7042 running: false,
7043 starts_automatically: true,
7044 restart_delay: Some(plan.restart().delay()),
7045 },
7046 );
7047 state
7048 .definitions
7049 .insert(plan.identity().name().to_string(), definition.clone());
7050 #[cfg(test)]
7051 let side_effect = state.after_install.remove(&self.mode);
7052 drop(state);
7053 #[cfg(test)]
7054 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7055 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7056 std::fs::write(&directory, b"blocks recreation")
7057 .expect("test fault can block record directory recreation");
7058 }
7059 Ok(definition)
7060 }
7061
7062 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7063 self.note("uninstall", identity.name());
7064 let mut state = self.state.lock().expect("not poisoned");
7065 state.definitions.remove(identity.name());
7066 Ok(state
7067 .registrations
7068 .remove(&(self.mode, identity.name().to_string()))
7069 .is_some())
7070 }
7071
7072 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7073 self.note("query", identity.name());
7074 Ok(self
7075 .state
7076 .lock()
7077 .expect("not poisoned")
7078 .registrations
7079 .get(&(self.mode, identity.name().to_string()))
7080 .cloned())
7081 }
7082
7083 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7084 self.note("start", identity.name());
7085 let mut state = self.state.lock().expect("not poisoned");
7086 match state
7087 .registrations
7088 .get_mut(&(self.mode, identity.name().to_string()))
7089 {
7090 Some(registration) => {
7091 registration.running = true;
7092 Ok(())
7093 }
7094 None => Err(ServiceError::NotInstalled {
7095 name: identity.name().to_string(),
7096 operation: "start",
7097 }),
7098 }
7099 }
7100
7101 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7102 self.note("stop", identity.name());
7103 let mut state = self.state.lock().expect("not poisoned");
7104 match state
7105 .registrations
7106 .get_mut(&(self.mode, identity.name().to_string()))
7107 {
7108 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7109 None => Err(ServiceError::NotInstalled {
7110 name: identity.name().to_string(),
7111 operation: "stop",
7112 }),
7113 }
7114 }
7115}
7116
7117impl ControlFactory for HostControls {
7118 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7119 sys::control(mode)
7120 }
7121
7122 fn installed_definition(&self, path: &Path) -> Option<String> {
7123 std::fs::read_to_string(path).ok()
7124 }
7125
7126 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7127 crate::unattended_login::probe()
7128 }
7129}
7130
7131#[must_use]
7138pub fn host_home() -> Option<PathBuf> {
7139 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7140}
7141
7142#[cfg(unix)]
7144fn home_directory() -> Option<PathBuf> {
7145 host_home()
7146}
7147
7148fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7157 let output = std::process::Command::new(program)
7158 .args(arguments)
7159 .output()?;
7160 Ok((
7161 output.status.success(),
7162 String::from_utf8_lossy(&output.stdout).into_owned(),
7163 String::from_utf8_lossy(&output.stderr).into_owned(),
7164 ))
7165}
7166
7167#[must_use]
7170pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7171 if cfg!(windows) {
7172 match mode {
7173 StartMode::Boot => DefinitionKind::WindowsService,
7174 StartMode::Login => DefinitionKind::WindowsScheduledTask,
7175 }
7176 } else if cfg!(target_os = "macos") {
7177 DefinitionKind::LaunchdPlist
7178 } else {
7179 DefinitionKind::SystemdUnit
7180 }
7181}
7182
7183#[cfg(any(target_os = "macos", test))]
7190fn enable_launchd_registration(
7191 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7192 domain: &str,
7193 service_target: &str,
7194 plist: &Path,
7195 name: &str,
7196 elevation_remedy: &'static str,
7197) -> Result<(), ServiceError> {
7198 let (enabled, cause) = launchctl(&[
7199 std::ffi::OsStr::new("enable"),
7200 std::ffi::OsStr::new(service_target),
7201 ]);
7202 if enabled {
7203 return Ok(());
7204 }
7205
7206 let (booted_out, bootout_detail) = launchctl(&[
7207 std::ffi::OsStr::new("bootout"),
7208 std::ffi::OsStr::new(service_target),
7209 ]);
7210 let removed = std::fs::remove_file(plist);
7211 if !booted_out || removed.is_err() {
7212 return Err(ServiceError::Rollback {
7213 operation: "enable launchd registration",
7214 name: name.to_string(),
7215 cause,
7216 rollback: format!(
7217 "launchctl bootout {domain}: {}; remove {}: {}",
7218 if booted_out {
7219 "succeeded".to_string()
7220 } else {
7221 bootout_detail
7222 },
7223 plist.display(),
7224 removed
7225 .err()
7226 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7227 ),
7228 });
7229 }
7230
7231 if cause.to_ascii_lowercase().contains("permission denied") {
7232 Err(ServiceError::NeedsElevation {
7233 operation: "enable",
7234 name: name.to_string(),
7235 detail: cause,
7236 remedy: elevation_remedy,
7237 })
7238 } else {
7239 Err(ServiceError::Control {
7240 operation: "enable",
7241 name: name.to_string(),
7242 manager: "launchd",
7243 detail: cause,
7244 })
7245 }
7246}
7247
7248#[derive(Debug, Clone)]
7257pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7258
7259impl ServiceShutdown {
7260 pub async fn wait(mut self) {
7262 if !*self.0.borrow() {
7263 let _ = self.0.changed().await;
7264 }
7265 }
7266}
7267
7268#[cfg(windows)]
7275pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7276where
7277 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7278{
7279 windows_host::run(Box::new(run))
7280}
7281
7282#[cfg(windows)]
7283mod windows_host {
7284 use std::ffi::OsString;
7285 use std::sync::{Arc, Mutex, OnceLock, mpsc};
7286 use std::time::Duration;
7287
7288 use windows_service::service::{
7289 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7290 ServiceType,
7291 };
7292 use windows_service::service_control_handler::{
7293 self, ServiceControlHandlerResult, ServiceStatusHandle,
7294 };
7295
7296 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7297
7298 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7299
7300 struct Invocation {
7301 run: Runner,
7302 result: mpsc::SyncSender<Result<u8, String>>,
7303 }
7304
7305 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7306
7307 windows_service::define_windows_service!(ffi_service_main, service_main);
7308
7309 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7310 let (result_tx, result_rx) = mpsc::sync_channel(1);
7311 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7312 let mut invocation = slot
7313 .lock()
7314 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7315 if invocation.is_some() {
7316 return Err(host_error(
7317 "prepare",
7318 "the service-host slot was already used",
7319 ));
7320 }
7321 *invocation = Some(Invocation {
7322 run,
7323 result: result_tx,
7324 });
7325 drop(invocation);
7326
7327 windows_service::service_dispatcher::start("", ffi_service_main)
7328 .map_err(|error| host_error("connect", &error.to_string()))?;
7329 result_rx
7330 .recv()
7331 .map_err(|error| host_error("finish", &error.to_string()))?
7332 .map_err(|detail| host_error("run", &detail))
7333 }
7334
7335 fn service_main(_arguments: Vec<OsString>) {
7336 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7337 return;
7338 };
7339 let result = run_service(invocation.run);
7340 let _ = invocation.result.send(result);
7341 }
7342
7343 fn run_service(run: Runner) -> Result<u8, String> {
7344 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7345 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7346 let handler_status = Arc::clone(&status);
7347 let handler = move |control| match control {
7348 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7349 ServiceControl::Stop | ServiceControl::Shutdown => {
7350 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7351 let _ = handle.set_service_status(service_status(
7352 ServiceState::StopPending,
7353 ServiceControlAccept::empty(),
7354 1,
7355 Duration::from_secs(300),
7356 0,
7357 ));
7358 }
7359 let _ = stop_tx.send(true);
7360 ServiceControlHandlerResult::NoError
7361 }
7362 _ => ServiceControlHandlerResult::NotImplemented,
7363 };
7364 let handle = service_control_handler::register("", handler)
7365 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7366 *status
7367 .lock()
7368 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7369 handle
7370 .set_service_status(service_status(
7371 ServiceState::Running,
7372 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7373 0,
7374 Duration::default(),
7375 0,
7376 ))
7377 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7378
7379 let exit = run(ServiceShutdown(stop_rx));
7380 handle
7381 .set_service_status(service_status(
7382 ServiceState::Stopped,
7383 ServiceControlAccept::empty(),
7384 0,
7385 Duration::default(),
7386 u32::from(exit),
7387 ))
7388 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7389 Ok(exit)
7390 }
7391
7392 fn service_status(
7393 state: ServiceState,
7394 accepted: ServiceControlAccept,
7395 checkpoint: u32,
7396 wait_hint: Duration,
7397 exit: u32,
7398 ) -> ServiceStatus {
7399 ServiceStatus {
7400 service_type: ServiceType::OWN_PROCESS,
7401 current_state: state,
7402 controls_accepted: accepted,
7403 exit_code: if exit == 0 {
7404 ServiceExitCode::Win32(0)
7405 } else {
7406 ServiceExitCode::ServiceSpecific(exit)
7407 },
7408 checkpoint,
7409 wait_hint,
7410 process_id: None,
7411 }
7412 }
7413
7414 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7415 ServiceError::Control {
7416 operation,
7417 name: SERVICE_NAME.to_string(),
7418 manager: "the Windows Service Control Manager",
7419 detail: detail.to_string(),
7420 }
7421 }
7422}
7423
7424#[cfg(windows)]
7425mod sys {
7426 use std::ffi::{OsStr, OsString};
7436 use std::time::{Duration, Instant};
7437
7438 use runner_manager_domain::model::StartMode;
7439 use windows_service::service::{
7440 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7441 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7442 ServiceState, ServiceType,
7443 };
7444 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7445
7446 use super::{
7447 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7448 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7449 };
7450
7451 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7454 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7458 const ACCESS_DENIED: i32 = 5;
7460 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7461 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7462
7463 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7464 choose \"Run as administrator\".";
7465
7466 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7467 Ok(match mode {
7468 StartMode::Boot => Box::new(ScmControl),
7469 StartMode::Login => Box::new(TaskControl),
7470 })
7471 }
7472
7473 #[derive(Debug)]
7476 struct ScmControl;
7477
7478 fn scm_error(
7482 operation: &'static str,
7483 name: &str,
7484 error: &windows_service::Error,
7485 ) -> ServiceError {
7486 let raw = match error {
7487 windows_service::Error::Winapi(io) => io.raw_os_error(),
7488 _ => None,
7489 };
7490 let detail = match error {
7491 windows_service::Error::Winapi(io) => io.to_string(),
7492 other => other.to_string(),
7493 };
7494 if raw == Some(ACCESS_DENIED) {
7495 return ServiceError::NeedsElevation {
7496 operation,
7497 name: name.to_string(),
7498 detail,
7499 remedy: ELEVATION_REMEDY,
7500 };
7501 }
7502 ServiceError::Control {
7503 operation,
7504 name: name.to_string(),
7505 manager: "the Windows Service Control Manager",
7506 detail,
7507 }
7508 }
7509
7510 fn open_manager(
7511 access: ServiceManagerAccess,
7512 operation: &'static str,
7513 name: &str,
7514 ) -> Result<ServiceManager, ServiceError> {
7515 ServiceManager::local_computer(None::<&OsStr>, access)
7516 .map_err(|error| scm_error(operation, name, &error))
7517 }
7518
7519 impl ServiceControl for ScmControl {
7520 fn manager(&self) -> DefinitionKind {
7521 DefinitionKind::WindowsService
7522 }
7523
7524 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7525 let spec = windows_service_spec(plan);
7526 let manager = open_manager(
7527 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7528 "install",
7529 &spec.name,
7530 )?;
7531 let info = ServiceInfo {
7532 name: OsString::from(&spec.name),
7533 display_name: OsString::from(&spec.display_name),
7534 service_type: ServiceType::OWN_PROCESS,
7536 start_type: if spec.automatic_start {
7537 ServiceStartType::AutoStart
7538 } else {
7539 ServiceStartType::OnDemand
7540 },
7541 error_control: ServiceErrorControl::Normal,
7542 executable_path: plan.binary().to_path_buf(),
7543 launch_arguments: plan.arguments().to_vec(),
7544 dependencies: Vec::new(),
7545 account_name: spec.account.as_ref().map(OsString::from),
7547 account_password: None,
7550 };
7551 let service = manager
7552 .create_service(
7553 &info,
7554 ServiceAccess::CHANGE_CONFIG
7555 | ServiceAccess::QUERY_CONFIG
7556 | ServiceAccess::QUERY_STATUS
7557 | ServiceAccess::START
7558 | ServiceAccess::STOP
7559 | ServiceAccess::DELETE,
7560 )
7561 .map_err(|error| scm_error("install", &spec.name, &error))?;
7562 service
7563 .set_description(&spec.description)
7564 .map_err(|error| scm_error("describe", &spec.name, &error))?;
7565 service
7566 .update_failure_actions(ServiceFailureActions {
7567 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7568 reboot_msg: None,
7569 command: None,
7570 actions: Some(vec![
7577 ServiceAction {
7578 action_type: ServiceActionType::Restart,
7579 delay: spec.restart.delay(),
7580 };
7581 3
7582 ]),
7583 })
7584 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7585 service
7588 .set_failure_actions_on_non_crash_failures(true)
7589 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7590 Ok(ServiceDefinition::windows_service(plan))
7591 }
7592
7593 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7594 let manager =
7595 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7596 let service = match manager.open_service(
7597 identity.name(),
7598 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7599 ) {
7600 Ok(service) => Some(service),
7601 Err(error) if is_missing(&error) => return Ok(false),
7602 Err(error) if is_marked_for_delete(&error) => None,
7607 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7608 };
7609 if let Some(service) = service {
7610 if let Ok(status) = service.query_status()
7614 && status.current_state != ServiceState::Stopped
7615 {
7616 let _ = service.stop();
7617 }
7618 match service.delete() {
7619 Ok(()) => {}
7620 Err(error) if is_marked_for_delete(&error) => {}
7624 Err(error) => {
7625 return Err(scm_error("uninstall", identity.name(), &error));
7626 }
7627 }
7628 drop(service);
7629 }
7630
7631 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7638 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7639 Ok(service) => {
7640 drop(service);
7641 Ok(false)
7642 }
7643 Err(error) if is_missing(&error) => Ok(true),
7644 Err(error) if is_marked_for_delete(&error) => Ok(false),
7645 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7646 }
7647 })?;
7648 if !absent {
7649 return Err(ServiceError::Control {
7650 operation: "verify uninstall of",
7651 name: identity.name().to_string(),
7652 manager: "the Windows Service Control Manager",
7653 detail: format!(
7654 "the registration was still visible {} seconds after DeleteService; \
7655 retry `service uninstall` from an elevated prompt",
7656 DELETE_TIMEOUT.as_secs()
7657 ),
7658 });
7659 }
7660 Ok(true)
7661 }
7662
7663 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7664 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7665 let service = match manager.open_service(
7666 identity.name(),
7667 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7668 ) {
7669 Ok(service) => service,
7670 Err(error) if is_missing(&error) => return Ok(None),
7671 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7672 };
7673 let config = service
7674 .query_config()
7675 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7676 let status = service
7677 .query_status()
7678 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7679 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7680 actions
7681 .actions
7682 .and_then(|actions| actions.into_iter().next())
7683 .filter(|action| action.action_type == ServiceActionType::Restart)
7684 .map(|action| action.delay)
7685 });
7686 Ok(Some(Registration {
7687 manager: DefinitionKind::WindowsService,
7688 start_mode: StartMode::Boot,
7689 command_line: config.executable_path.to_string_lossy().into_owned(),
7693 account: config
7694 .account_name
7695 .map(|account| super::windows_service_account(&account.to_string_lossy())),
7696 running: status.current_state == ServiceState::Running,
7697 starts_automatically: config.start_type == ServiceStartType::AutoStart,
7698 restart_delay,
7699 }))
7700 }
7701
7702 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7703 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7704 let service = manager
7705 .open_service(identity.name(), ServiceAccess::START)
7706 .map_err(|error| scm_error("start", identity.name(), &error))?;
7707 service
7708 .start::<&OsStr>(&[])
7709 .map_err(|error| scm_error("start", identity.name(), &error))
7710 }
7711
7712 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7713 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7714 let service = manager
7715 .open_service(
7716 identity.name(),
7717 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7718 )
7719 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7720 let status = service
7721 .query_status()
7722 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7723 if status.current_state == ServiceState::Stopped {
7724 return Ok(false);
7725 }
7726 service
7727 .stop()
7728 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7729 Ok(true)
7730 }
7731 }
7732
7733 fn is_missing(error: &windows_service::Error) -> bool {
7734 matches!(error, windows_service::Error::Winapi(io)
7735 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7736 }
7737
7738 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7739 matches!(error, windows_service::Error::Winapi(io)
7740 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7741 }
7742
7743 pub(super) fn wait_until_scm_absent(
7744 timeout: Duration,
7745 poll_interval: Duration,
7746 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7747 ) -> Result<bool, ServiceError> {
7748 let deadline = Instant::now() + timeout;
7749 loop {
7750 if probe_absent()? {
7751 return Ok(true);
7752 }
7753 if Instant::now() >= deadline {
7754 return Ok(false);
7755 }
7756 std::thread::sleep(poll_interval);
7757 }
7758 }
7759
7760 #[derive(Debug)]
7763 struct TaskControl;
7764
7765 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7766 if detail.to_ascii_lowercase().contains("access is denied") {
7767 return ServiceError::NeedsElevation {
7768 operation,
7769 name: name.to_string(),
7770 detail,
7771 remedy: ELEVATION_REMEDY,
7772 };
7773 }
7774 ServiceError::Control {
7775 operation,
7776 name: name.to_string(),
7777 manager: "Windows Task Scheduler",
7778 detail,
7779 }
7780 }
7781
7782 fn schtasks(
7783 operation: &'static str,
7784 name: &str,
7785 arguments: &[&OsStr],
7786 ) -> Result<(bool, String), ServiceError> {
7787 match run("schtasks.exe", arguments) {
7788 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7789 Err(error) => Err(task_error(operation, name, error.to_string())),
7790 }
7791 }
7792
7793 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7796 let mut bytes = vec![0xFF, 0xFE];
7797 for unit in text.encode_utf16() {
7798 bytes.extend_from_slice(&unit.to_le_bytes());
7799 }
7800 std::fs::write(path, bytes)
7801 }
7802
7803 impl ServiceControl for TaskControl {
7804 fn manager(&self) -> DefinitionKind {
7805 DefinitionKind::WindowsScheduledTask
7806 }
7807
7808 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7809 let name = plan.identity().name().to_string();
7810 let principal = TaskPrincipal::current()?;
7811 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7812 let directory = tempfile::tempdir()
7813 .map_err(|error| task_error("install", &name, error.to_string()))?;
7814 let document = directory.path().join("task.xml");
7815 write_utf16(&document, definition.text())
7816 .map_err(|error| task_error("install", &name, error.to_string()))?;
7817 let (ok, message) = schtasks(
7818 "install",
7819 &name,
7820 &[
7821 OsStr::new("/Create"),
7822 OsStr::new("/TN"),
7823 OsStr::new(&name),
7824 OsStr::new("/XML"),
7825 document.as_os_str(),
7826 ],
7827 )?;
7828 if !ok {
7829 return Err(task_error("install", &name, message));
7830 }
7831 Ok(definition)
7832 }
7833
7834 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7835 if self.query(identity)?.is_none() {
7836 return Ok(false);
7837 }
7838 let name = identity.name().to_string();
7839 let (ok, message) = schtasks(
7840 "uninstall",
7841 &name,
7842 &[
7843 OsStr::new("/Delete"),
7844 OsStr::new("/TN"),
7845 OsStr::new(&name),
7846 OsStr::new("/F"),
7847 ],
7848 )?;
7849 if !ok {
7850 return Err(task_error("uninstall", &name, message));
7851 }
7852 Ok(true)
7853 }
7854
7855 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7856 let name = identity.name().to_string();
7857 let (ok, document) = schtasks(
7858 "inspect",
7859 &name,
7860 &[
7861 OsStr::new("/Query"),
7862 OsStr::new("/TN"),
7863 OsStr::new(&name),
7864 OsStr::new("/XML"),
7865 OsStr::new("ONE"),
7866 ],
7867 )?;
7868 if !ok {
7869 return Ok(None);
7875 }
7876 let command = xml_value(&document, "Command").unwrap_or_default();
7877 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
7878 let command_line = if arguments.is_empty() {
7879 super::quote_argument(&command)
7880 } else {
7881 format!("{} {arguments}", super::quote_argument(&command))
7882 };
7883 Ok(Some(Registration {
7884 manager: DefinitionKind::WindowsScheduledTask,
7885 start_mode: StartMode::Login,
7886 command_line,
7887 account: super::scheduled_task_account(xml_value(&document, "UserId")),
7888 running: task_is_running(&name),
7889 starts_automatically: super::windows_login_task_starts_automatically(&document),
7890 restart_delay: xml_value(&document, "Interval")
7891 .as_deref()
7892 .and_then(parse_iso8601),
7893 }))
7894 }
7895
7896 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7897 let name = identity.name().to_string();
7898 let (ok, message) = schtasks(
7899 "start",
7900 &name,
7901 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
7902 )?;
7903 if ok {
7904 Ok(())
7905 } else {
7906 Err(task_error("start", &name, message))
7907 }
7908 }
7909
7910 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7911 let running = self
7912 .query(identity)?
7913 .is_some_and(|registration| registration.running);
7914 if !running {
7915 return Ok(false);
7916 }
7917 let name = identity.name().to_string();
7918 let (ok, message) = schtasks(
7919 "stop",
7920 &name,
7921 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
7922 )?;
7923 if ok {
7924 Ok(true)
7925 } else {
7926 Err(task_error("stop", &name, message))
7927 }
7928 }
7929 }
7930
7931 fn task_is_running(name: &str) -> bool {
7945 let Ok((true, stdout, _)) = run(
7946 "schtasks.exe",
7947 &[
7948 OsStr::new("/Query"),
7949 OsStr::new("/TN"),
7950 OsStr::new(name),
7951 OsStr::new("/FO"),
7952 OsStr::new("CSV"),
7953 OsStr::new("/NH"),
7954 ],
7955 ) else {
7956 return false;
7957 };
7958 stdout
7959 .lines()
7960 .filter_map(|line| line.rsplit(',').next())
7961 .any(|status| {
7962 status
7963 .trim()
7964 .trim_matches('"')
7965 .eq_ignore_ascii_case("running")
7966 })
7967 }
7968
7969 fn parse_iso8601(value: &str) -> Option<Duration> {
7973 let rest = value.strip_prefix("PT")?;
7974 if let Some(minutes) = rest.strip_suffix('M') {
7975 return minutes
7976 .parse::<u64>()
7977 .ok()
7978 .map(|minutes| Duration::from_secs(minutes * 60));
7979 }
7980 rest.strip_suffix('S')?
7981 .parse::<u64>()
7982 .ok()
7983 .map(Duration::from_secs)
7984 }
7985}
7986
7987#[cfg(unix)]
7999fn write_definition(
8000 operation: &'static str,
8001 name: &str,
8002 path: &Path,
8003 text: &str,
8004 remedy: &'static str,
8005) -> Result<(), ServiceError> {
8006 if let Some(parent) = path.parent()
8007 && let Err(error) = std::fs::create_dir_all(parent)
8008 && error.kind() != std::io::ErrorKind::AlreadyExists
8009 {
8010 return Err(definition_error(operation, name, error, remedy, parent));
8011 }
8012 std::fs::write(path, text)
8013 .map_err(|error| definition_error(operation, name, error, remedy, path))
8014}
8015
8016#[cfg(all(unix, any(target_os = "macos", test)))]
8023fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
8024 use std::io::Write as _;
8025
8026 let path = drift.path.as_path();
8027 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
8028 let failed = |error: std::io::Error, at: &Path| {
8029 definition_error("rewrite", name, error, SUDO_REMEDY, at)
8030 };
8031 let mut staged =
8032 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8033 staged
8034 .write_all(drift.rendered.as_bytes())
8035 .and_then(|()| staged.as_file().sync_all())
8036 .map_err(|error| failed(error, staged.path()))?;
8037 std::fs::set_permissions(
8038 staged.path(),
8039 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8040 )
8041 .map_err(|error| failed(error, path))?;
8042 staged
8043 .persist(path)
8044 .map(drop)
8045 .map_err(|error| failed(error.error, path))
8046}
8047
8048#[cfg(unix)]
8049fn definition_error(
8050 operation: &'static str,
8051 name: &str,
8052 error: std::io::Error,
8053 remedy: &'static str,
8054 path: &Path,
8055) -> ServiceError {
8056 let detail = format!("{}: {error}", path.display());
8057 if error.kind() == std::io::ErrorKind::PermissionDenied {
8058 ServiceError::NeedsElevation {
8059 operation,
8060 name: name.to_string(),
8061 detail,
8062 remedy,
8063 }
8064 } else {
8065 ServiceError::Control {
8066 operation,
8067 name: name.to_string(),
8068 manager: "the local service manager",
8069 detail,
8070 }
8071 }
8072}
8073
8074#[cfg(unix)]
8076const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8077 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8078 agent not running until you sign in.";
8079
8080#[cfg(target_os = "macos")]
8085mod sys {
8086 use std::ffi::OsStr;
8099 use std::path::PathBuf;
8100 use std::time::Duration;
8101
8102 use runner_manager_domain::model::StartMode;
8103
8104 use super::{
8105 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8106 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8107 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8108 write_definition, xml_value,
8109 };
8110
8111 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8112 Ok(Box::new(LaunchdControl { mode }))
8113 }
8114
8115 #[derive(Debug)]
8116 struct LaunchdControl {
8117 mode: StartMode,
8118 }
8119
8120 impl LaunchdControl {
8121 fn domain(&self) -> String {
8123 match self.mode {
8124 StartMode::Boot => "system".to_string(),
8125 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8128 }
8129 }
8130
8131 fn service_target(&self, identity: &ServiceIdentity) -> String {
8132 format!("{}/{}", self.domain(), identity.launchd_label())
8133 }
8134
8135 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8138 let file = format!("{}.plist", identity.launchd_label());
8139 match self.mode {
8140 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8141 StartMode::Login => {
8142 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8143 }
8144 }
8145 }
8146
8147 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8148 ServiceError::Control {
8149 operation,
8150 name: name.to_string(),
8151 manager: "launchd",
8152 detail,
8153 }
8154 }
8155
8156 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8157 match run("launchctl", arguments) {
8158 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8159 Err(error) => (false, error.to_string()),
8160 }
8161 }
8162 }
8163
8164 impl ServiceControl for LaunchdControl {
8165 fn manager(&self) -> DefinitionKind {
8166 DefinitionKind::LaunchdPlist
8167 }
8168
8169 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8170 let name = plan.identity().name().to_string();
8171 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8172 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8173 return Err(self.failed(
8174 "install",
8175 &name,
8176 "this account has no home directory, so there is nowhere to put a \
8177 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8178 /Library/LaunchDaemons."
8179 .to_string(),
8180 ));
8181 };
8182 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8183 let target = self.domain();
8184 let (ok, message) = self.launchctl(&[
8185 OsStr::new("bootstrap"),
8186 OsStr::new(&target),
8187 path.as_os_str(),
8188 ]);
8189 if !ok {
8190 let _ = std::fs::remove_file(&path);
8194 if message.to_ascii_lowercase().contains("permission denied") {
8195 return Err(ServiceError::NeedsElevation {
8196 operation: "install",
8197 name,
8198 detail: message,
8199 remedy: SUDO_REMEDY,
8200 });
8201 }
8202 return Err(self.failed("install", &name, message));
8203 }
8204 let service_target = self.service_target(plan.identity());
8207 enable_launchd_registration(
8208 |arguments| self.launchctl(arguments),
8209 &target,
8210 &service_target,
8211 &path,
8212 &name,
8213 SUDO_REMEDY,
8214 )?;
8215 Ok(definition)
8216 }
8217
8218 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8219 let Some(path) = self.plist_path(identity) else {
8220 return Ok(false);
8221 };
8222 if !path.exists() {
8223 return Ok(false);
8224 }
8225 let target = self.service_target(identity);
8226 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8227 if !ok
8228 && !message.to_ascii_lowercase().contains("no such process")
8229 && !message.contains("113")
8230 {
8231 if message.to_ascii_lowercase().contains("permission denied") {
8232 return Err(ServiceError::NeedsElevation {
8233 operation: "uninstall",
8234 name: identity.name().to_string(),
8235 detail: message,
8236 remedy: SUDO_REMEDY,
8237 });
8238 }
8239 return Err(self.failed("uninstall", identity.name(), message));
8240 }
8241 std::fs::remove_file(&path).map_err(|error| {
8244 super::definition_error(
8245 "uninstall",
8246 identity.name(),
8247 error,
8248 SUDO_REMEDY,
8249 path.as_path(),
8250 )
8251 })?;
8252 Ok(true)
8253 }
8254
8255 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8256 let Some(path) = self.plist_path(identity) else {
8257 return Ok(None);
8258 };
8259 let Ok(document) = std::fs::read_to_string(&path) else {
8260 return Ok(None);
8261 };
8262 let target = self.service_target(identity);
8263 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8264 Ok(Some(Registration {
8265 manager: DefinitionKind::LaunchdPlist,
8266 start_mode: self.mode,
8267 command_line: program_arguments(&document),
8268 account: plist_string_value(&document, "UserName").or_else(|| {
8271 (self.mode == StartMode::Boot)
8272 .then(|| super::ServiceAccount::Root.as_str().to_string())
8273 }),
8274 running: loaded && printed.contains("state = running"),
8275 starts_automatically: document.contains("<key>RunAtLoad</key>")
8276 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8277 restart_delay: xml_value(
8278 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8279 "integer",
8280 )
8281 .and_then(|value| value.parse::<u64>().ok())
8282 .map(Duration::from_secs),
8283 }))
8284 }
8285
8286 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8287 let target = self.service_target(identity);
8288 let (ok, message) = self.launchctl(&[
8289 OsStr::new("kickstart"),
8290 OsStr::new("-k"),
8291 OsStr::new(&target),
8292 ]);
8293 if ok {
8294 Ok(())
8295 } else {
8296 Err(self.failed("start", identity.name(), message))
8297 }
8298 }
8299
8300 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8301 let running = self
8302 .query(identity)?
8303 .is_some_and(|registration| registration.running);
8304 if !running {
8305 return Ok(false);
8306 }
8307 let target = self.service_target(identity);
8308 let (ok, message) = self.launchctl(&[
8309 OsStr::new("kill"),
8310 OsStr::new("SIGTERM"),
8311 OsStr::new(&target),
8312 ]);
8313 if ok {
8314 Ok(true)
8315 } else {
8316 Err(self.failed("stop", identity.name(), message))
8317 }
8318 }
8319 }
8320
8321 pub(super) fn reload_detached(
8336 identity: &ServiceIdentity,
8337 mode: StartMode,
8338 plist: &std::path::Path,
8339 ) -> Result<(), ServiceError> {
8340 use std::os::unix::process::CommandExt;
8341
8342 const SCRIPT: &str = r#"(
8343 sleep 1
8344 /bin/launchctl bootout "$1"
8345 tries=0
8346 until /bin/launchctl bootstrap "$2" "$3"; do
8347 tries=$((tries + 1))
8348 if [ "$tries" -ge 60 ]; then
8349 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8350 exit 1
8351 fi
8352 sleep 1
8353 done
8354) </dev/null &"#;
8355
8356 let control = LaunchdControl { mode };
8357 let reinstall = match mode {
8360 StartMode::Boot => "sudo runner-manager service install --start-at boot",
8361 StartMode::Login => "runner-manager service install --start-at login",
8362 };
8363 let mut command = std::process::Command::new("/bin/sh");
8364 command
8365 .args(["-c", SCRIPT, "runner-manager-reload"])
8366 .arg(control.service_target(identity))
8367 .arg(control.domain())
8368 .arg(plist)
8369 .arg(reinstall)
8370 .stdin(std::process::Stdio::null());
8371 unsafe {
8374 command.pre_exec(|| {
8375 if libc::setsid() == -1 {
8376 return Err(std::io::Error::last_os_error());
8377 }
8378 Ok(())
8379 });
8380 }
8381 let status = command.status().map_err(|error| {
8382 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8383 })?;
8384 if status.success() {
8385 Ok(())
8386 } else {
8387 Err(control.failed(
8388 "reload",
8389 identity.name(),
8390 format!("the reload helper exited with {status}"),
8391 ))
8392 }
8393 }
8394
8395 fn program_arguments(document: &str) -> String {
8397 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8398 return String::new();
8399 };
8400 let Some(end) = rest.find("</array>") else {
8401 return String::new();
8402 };
8403 let mut out = Vec::new();
8404 let mut cursor = &rest[..end];
8405 while let Some(open) = cursor.find("<string>") {
8406 let after = &cursor[open + "<string>".len()..];
8407 let Some(close) = after.find("</string>") else {
8408 break;
8409 };
8410 out.push(quote_argument(&super::xml_unescape(&after[..close])));
8411 cursor = &after[close..];
8412 }
8413 out.join(" ")
8414 }
8415}
8416
8417#[cfg(all(unix, not(target_os = "macos")))]
8422mod sys {
8423 use std::ffi::OsStr;
8433 use std::path::PathBuf;
8434
8435 use runner_manager_domain::model::StartMode;
8436
8437 use super::{
8438 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8439 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8440 home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8441 write_definition,
8442 };
8443
8444 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8445 Ok(Box::new(SystemdControl { mode }))
8446 }
8447
8448 #[derive(Debug)]
8449 struct SystemdControl {
8450 mode: StartMode,
8451 }
8452
8453 impl SystemdControl {
8454 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8455 let file = identity.systemd_unit();
8456 match self.mode {
8457 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8458 StartMode::Login => {
8459 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8460 }
8461 }
8462 }
8463
8464 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8466 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8467 if self.mode == StartMode::Login {
8468 all.push(OsStr::new("--user"));
8469 }
8470 all.extend(arguments.iter().map(OsStr::new));
8471 match run("systemctl", &all) {
8472 Ok((ok, stdout, stderr)) => (
8473 ok,
8474 if stdout.trim().is_empty() {
8475 stderr
8476 } else {
8477 stdout
8478 },
8479 ),
8480 Err(error) => (false, error.to_string()),
8481 }
8482 }
8483
8484 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8485 ServiceError::Control {
8486 operation,
8487 name: name.to_string(),
8488 manager: "systemd",
8489 detail,
8490 }
8491 }
8492 }
8493
8494 impl ServiceControl for SystemdControl {
8495 fn manager(&self) -> DefinitionKind {
8496 DefinitionKind::SystemdUnit
8497 }
8498
8499 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8500 use std::os::unix::fs::MetadataExt as _;
8501
8502 let name = plan.identity().name().to_string();
8503 if self.mode == StartMode::Boot {
8504 let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8507 .iter()
8508 .filter_map(|path| {
8509 let path = std::path::Path::new(path);
8510 let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8511 super::boot_directory_refusal(path, owner)
8512 })
8513 .collect::<Vec<_>>();
8514 if !refusals.is_empty() {
8515 return Err(self.failed(
8516 "install",
8517 &name,
8518 format!(
8519 "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8520 directory there that root does not own is handed back with \
8521 `sudo chown -R root:root <directory>`",
8522 refusals.join("; "),
8523 super::sudo_command("service install --start-at boot"),
8524 crate::paths::SYSTEM_DATA_ROOT,
8525 crate::paths::SYSTEM_LOGS_DIR,
8526 ),
8527 ));
8528 }
8529 }
8530 if self.mode == StartMode::Boot
8534 && let Err(detail) = crate::runner_account::RunnerAccount::ensure()
8535 {
8536 return Err(self.failed("install", &name, detail));
8537 }
8538 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8539 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8540 return Err(self.failed(
8541 "install",
8542 &name,
8543 "this account has no home directory, so there is nowhere to put a systemd \
8544 user unit. Use --start-at boot, which installs a system unit under \
8545 /etc/systemd/system."
8546 .to_string(),
8547 ));
8548 };
8549 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8550 let unit = plan.identity().systemd_unit();
8551 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8552 if !reloaded {
8553 let _ = std::fs::remove_file(&path);
8554 return Err(self.failed("install", &name, message));
8555 }
8556 let (enabled, message) = self.systemctl(&["enable", &unit]);
8557 if !enabled {
8558 let _ = std::fs::remove_file(&path);
8559 let _ = self.systemctl(&["daemon-reload"]);
8560 return Err(self.failed("install", &name, message));
8561 }
8562 Ok(definition)
8563 }
8564
8565 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8566 let Some(path) = self.unit_path(identity) else {
8567 return Ok(false);
8568 };
8569 if !path.exists() {
8570 return Ok(false);
8571 }
8572 let unit = identity.systemd_unit();
8573 let _ = self.systemctl(&["disable", "--now", &unit]);
8578 std::fs::remove_file(&path).map_err(|error| {
8579 super::definition_error(
8580 "uninstall",
8581 identity.name(),
8582 error,
8583 SUDO_REMEDY,
8584 path.as_path(),
8585 )
8586 })?;
8587 let _ = self.systemctl(&["daemon-reload"]);
8588 Ok(true)
8589 }
8590
8591 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8592 let Some(path) = self.unit_path(identity) else {
8593 return Ok(None);
8594 };
8595 if !path.exists() {
8596 return Ok(None);
8597 }
8598 let unit = identity.systemd_unit();
8599 let effective = match self.systemctl(&["cat", &unit]) {
8603 (true, text) => text,
8604 _ => match std::fs::read_to_string(&path) {
8605 Ok(document) => document,
8606 Err(_) => return Ok(None),
8607 },
8608 };
8609 let directives = ini_directives(&effective, "Service");
8610 let (_, shown) = self.systemctl(&[
8612 "show",
8613 "--property=ActiveState",
8614 "--property=UnitFileState",
8615 &unit,
8616 ]);
8617 let state = |key: &str| {
8618 shown
8619 .lines()
8620 .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8621 .map(str::trim)
8622 };
8623 Ok(Some(Registration {
8624 manager: DefinitionKind::SystemdUnit,
8625 start_mode: self.mode,
8626 command_line: directives
8627 .get("ExecStart")
8628 .map(|value| systemd_exec_command_line(value))
8629 .unwrap_or_default(),
8630 account: directives
8633 .get("User")
8634 .filter(|user| !user.is_empty())
8635 .cloned()
8636 .or_else(|| {
8637 (self.mode == StartMode::Boot)
8638 .then(|| super::ServiceAccount::Root.as_str().to_string())
8639 }),
8640 running: state("ActiveState") == Some("active"),
8641 starts_automatically: state("UnitFileState") == Some("enabled"),
8642 restart_delay: directives
8643 .get("RestartSec")
8644 .and_then(|value| systemd_timespan(value)),
8645 }))
8646 }
8647
8648 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8649 let unit = identity.systemd_unit();
8650 let (ok, message) = self.systemctl(&["start", &unit]);
8651 if ok {
8652 Ok(())
8653 } else {
8654 Err(self.failed("start", identity.name(), message))
8655 }
8656 }
8657
8658 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8659 let running = self
8660 .query(identity)?
8661 .is_some_and(|registration| registration.running);
8662 if !running {
8663 return Ok(false);
8664 }
8665 let unit = identity.systemd_unit();
8666 let (ok, message) = self.systemctl(&["stop", &unit]);
8667 if ok {
8668 Ok(true)
8669 } else {
8670 Err(self.failed("stop", identity.name(), message))
8671 }
8672 }
8673 }
8674}
8675
8676#[cfg(all(test, target_os = "macos"))]
8678pub(crate) mod launchd_fixture {
8679 use std::path::{Path, PathBuf};
8680
8681 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8685
8686 pub(crate) fn enabled() -> bool {
8689 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8690 if !enabled {
8691 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8692 }
8693 enabled
8694 }
8695
8696 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8704 let binary = directory.join("launch-agent-job");
8705 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8706 .expect("test binary copied");
8707 binary
8708 }
8709
8710 pub(crate) struct LoadedAgent(String);
8712
8713 impl LoadedAgent {
8714 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8717 let domain = format!("gui/{}", unsafe { libc::getuid() });
8720 let loaded = Self(format!("{domain}/{label}"));
8722 let bootstrap = std::process::Command::new("launchctl")
8723 .arg("bootstrap")
8724 .arg(&domain)
8725 .arg(plist)
8726 .output()
8727 .expect("launchctl runs");
8728 assert!(
8729 bootstrap.status.success(),
8730 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8731 String::from_utf8_lossy(&bootstrap.stderr)
8732 );
8733 loaded
8734 }
8735
8736 pub(crate) fn target(&self) -> &str {
8738 &self.0
8739 }
8740 }
8741
8742 impl Drop for LoadedAgent {
8743 fn drop(&mut self) {
8744 let _ = std::process::Command::new("launchctl")
8745 .args(["bootout", &self.0])
8746 .output();
8747 }
8748 }
8749}
8750
8751#[cfg(test)]
8752mod tests {
8753 use super::*;
8754
8755 use std::collections::BTreeMap;
8756
8757 #[test]
8758 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8759 assert!(windows_login_task_starts_automatically(
8760 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8761 ));
8762 assert!(windows_login_task_starts_automatically(
8763 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8764 ));
8765 assert!(!windows_login_task_starts_automatically(
8766 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8767 ));
8768 assert!(!windows_login_task_starts_automatically(
8769 "<Task><BootTrigger></BootTrigger></Task>"
8770 ));
8771 }
8772
8773 fn linux_plan(mode: StartMode) -> InstallPlan {
8780 InstallPlan::unchecked(
8781 ServiceIdentity::product(),
8782 mode,
8783 "/opt/runner-manager/bin/runner-manager",
8784 ServiceDirectories {
8785 config: PathBuf::from("/var/lib/runner-manager/config"),
8786 state: PathBuf::from("/var/lib/runner-manager/state"),
8787 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8788 logs: PathBuf::from("/var/log/runner-manager"),
8789 },
8790 )
8791 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8792 }
8793
8794 fn windows_plan(mode: StartMode) -> InstallPlan {
8795 InstallPlan::unchecked(
8796 ServiceIdentity::product(),
8797 mode,
8798 "C:\\Program Files\\runner-manager\\runner-manager.exe",
8799 ServiceDirectories {
8800 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8801 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8802 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8803 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8804 },
8805 )
8806 }
8807
8808 fn edited(text: &str, from: &str, to: &str) -> String {
8816 assert!(
8817 text.contains(from),
8818 "the rendered definition does not contain `{from}`, so this test would assert \
8819 nothing about a widened one"
8820 );
8821 text.replace(from, to)
8822 }
8823
8824 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8826 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8827 let Ok(entries) = std::fs::read_dir(directory) else {
8828 return;
8829 };
8830 for entry in entries.flatten() {
8831 let path = entry.path();
8832 if path.is_dir() {
8833 walk(&path, out);
8834 } else if let Ok(bytes) = std::fs::read(&path) {
8835 out.insert(path, bytes);
8836 }
8837 }
8838 }
8839 let mut out = BTreeMap::new();
8840 for root in roots {
8841 walk(root, &mut out);
8842 }
8843 out
8844 }
8845
8846 struct Host {
8847 _root: tempfile::TempDir,
8848 paths: AppPaths,
8849 binary: PathBuf,
8850 runner_root: LocalAbsolutePath,
8859 controls: RecordingControls,
8860 }
8861
8862 impl Host {
8863 fn new() -> Self {
8864 let root = tempfile::tempdir().expect("a temporary directory");
8865 let paths = AppPaths::rooted_at(root.path());
8866 paths.create_all().expect("the four directories");
8867 let binary = root.path().join(if cfg!(windows) {
8868 "runner-manager.exe"
8869 } else {
8870 "runner-manager"
8871 });
8872 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
8873 let runner_root = LocalAbsolutePath::new(
8874 root.path()
8875 .join("runner-root")
8876 .to_str()
8877 .expect("a unicode temporary path"),
8878 )
8879 .expect("a local absolute path");
8880 Self {
8881 _root: root,
8882 paths,
8883 binary,
8884 runner_root,
8885 controls: RecordingControls::new(),
8886 }
8887 }
8888
8889 fn operations(&self) -> ServiceOperations {
8890 ServiceOperations::with_controls(
8891 self.paths.clone(),
8892 ServiceIdentity::product(),
8893 std::sync::Arc::new(self.controls.clone()),
8894 )
8895 .with_runner_root(self.runner_root.clone())
8896 }
8897
8898 fn request(&self, mode: StartMode) -> InstallRequest {
8899 InstallRequest::new(mode).for_binary(&self.binary)
8900 }
8901 }
8902
8903 #[cfg(windows)]
8904 #[test]
8905 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
8906 let probes = std::cell::Cell::new(0);
8907 let absent =
8908 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
8909 let next = probes.get() + 1;
8910 probes.set(next);
8911 Ok(next == 3)
8912 })
8913 .expect("the simulated SCM probe succeeds");
8914
8915 assert!(absent);
8916 assert_eq!(
8917 probes.get(),
8918 3,
8919 "uninstall must recheck after transient presence instead of treating it as a leak"
8920 );
8921 }
8922
8923 #[cfg(windows)]
8924 #[test]
8925 fn windows_recognises_an_already_pending_service_deletion() {
8926 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
8927
8928 assert!(super::sys::is_marked_for_delete(&error));
8929 }
8930
8931 #[test]
8932 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
8933 let root = tempfile::tempdir().expect("a temporary directory");
8934 let plist = root.path().join("fixture.plist");
8935 std::fs::write(&plist, b"fixture").expect("a plist fixture");
8936 let calls = std::cell::RefCell::new(Vec::new());
8937
8938 let error = enable_launchd_registration(
8939 |arguments| {
8940 let call = arguments
8941 .iter()
8942 .map(|argument| argument.to_string_lossy().into_owned())
8943 .collect::<Vec<_>>();
8944 let operation = call[0].clone();
8945 calls.borrow_mut().push(call);
8946 if operation == "enable" {
8947 (false, "label remains disabled".to_string())
8948 } else {
8949 (true, String::new())
8950 }
8951 },
8952 "system",
8953 "system/com.openai.runner-manager-selftest",
8954 &plist,
8955 "runner-manager-selftest",
8956 "rerun with administrative rights",
8957 )
8958 .expect_err("enable failure must fail the install");
8959
8960 assert!(
8961 matches!(
8962 error,
8963 ServiceError::Control {
8964 operation: "enable",
8965 ..
8966 }
8967 ),
8968 "{error}"
8969 );
8970 assert_eq!(calls.borrow().len(), 2);
8971 assert_eq!(calls.borrow()[0][0], "enable");
8972 assert_eq!(calls.borrow()[1][0], "bootout");
8973 assert!(!plist.exists(), "rollback must remove the plist");
8974 }
8975
8976 #[test]
8981 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
8982 let plan = windows_plan(StartMode::Boot);
8983 let command_line = plan.command_line();
8984 assert!(
8985 command_line.starts_with('"'),
8986 "a path with a space must be quoted, got {command_line}"
8987 );
8988 assert_eq!(
8989 executable_from_command_line(&command_line).as_deref(),
8990 Some(plan.binary())
8991 );
8992 }
8993
8994 #[test]
8995 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
8996 let plan = linux_plan(StartMode::Boot);
8997 let command_line = plan.command_line();
8998 assert!(!command_line.starts_with('"'), "got {command_line}");
8999 assert_eq!(
9000 executable_from_command_line(&command_line).as_deref(),
9001 Some(plan.binary())
9002 );
9003 }
9004
9005 #[test]
9006 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
9007 let awkward = r#"C:\odd "name"\rm.exe"#;
9010 let quoted = quote_argument(awkward);
9011 assert_eq!(
9012 executable_from_command_line(&format!("{quoted} daemon run"))
9013 .as_deref()
9014 .map(Path::to_string_lossy)
9015 .as_deref(),
9016 Some(awkward)
9017 );
9018 }
9019
9020 #[test]
9021 fn an_empty_command_line_has_no_executable() {
9022 assert_eq!(executable_from_command_line(" "), None);
9023 assert_eq!(executable_from_command_line(""), None);
9024 }
9025
9026 #[test]
9027 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
9028 let awkward = r#"DOMAIN\R&D <team> "ops""#;
9029 assert_eq!(
9030 xml_escape(awkward),
9031 "DOMAIN\\R&D <team> "ops""
9032 );
9033 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
9034 }
9035
9036 #[test]
9041 fn a_restart_delay_under_the_floor_is_refused() {
9042 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9043 .expect_err("half a second is under the one-second floor");
9044 assert!(
9045 matches!(error, ServiceError::RestartDelay { .. }),
9046 "{error}"
9047 );
9048 }
9049
9050 #[test]
9051 fn a_restart_delay_over_the_ceiling_is_refused() {
9052 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9053 .expect_err("an hour is over the five-minute ceiling");
9054 assert!(
9055 matches!(error, ServiceError::RestartDelay { .. }),
9056 "{error}"
9057 );
9058 }
9059
9060 #[test]
9061 fn a_reset_window_no_longer_than_the_delay_is_refused() {
9062 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9063 .expect_err("a window equal to the delay can never elapse between restarts");
9064 assert!(
9065 matches!(error, ServiceError::RestartResetWindow { .. }),
9066 "{error}"
9067 );
9068 }
9069
9070 #[test]
9071 fn a_delay_inside_the_bound_is_accepted() {
9072 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9073 .expect("twenty seconds is inside the bound");
9074 assert_eq!(policy.delay(), Duration::from_secs(20));
9075 assert_eq!(policy.reset_after(), Duration::from_secs(300));
9076 }
9077
9078 #[test]
9083 fn a_fixture_identity_can_never_be_the_product_identity() {
9084 let fixture = ServiceIdentity::fixture("abc123");
9085 assert!(fixture.is_fixture());
9086 assert!(!ServiceIdentity::product().is_fixture());
9087 assert_ne!(fixture.name(), ServiceIdentity::product().name());
9088 assert_ne!(
9089 fixture.launchd_label(),
9090 ServiceIdentity::product().launchd_label()
9091 );
9092 assert_ne!(
9093 fixture.systemd_unit(),
9094 ServiceIdentity::product().systemd_unit()
9095 );
9096 }
9097
9098 #[test]
9099 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9100 let fixture = ServiceIdentity::fixture("A b/c:\\d");
9101 assert!(
9102 fixture
9103 .name()
9104 .chars()
9105 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9106 "got {}",
9107 fixture.name()
9108 );
9109 }
9110
9111 #[test]
9116 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9117 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9118 assert!(unit.contains("KillMode=process\n"), "{unit}");
9119 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9120 assert!(unit.contains("RestartSec=15\n"), "{unit}");
9121 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9122 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9123 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9124 }
9125
9126 #[test]
9127 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9128 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9129 assert!(
9130 !unit.contains("LoadCredential="),
9131 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9132 );
9133 }
9134
9135 #[test]
9136 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9137 let unit = systemd_unit(&linux_plan(StartMode::Login));
9138 assert!(
9139 !unit.contains("LoadCredential="),
9140 "a user unit must not name a root-owned credential file, got:\n{unit}"
9141 );
9142 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9143 }
9144
9145 #[test]
9146 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9147 let plan = linux_plan(StartMode::Boot);
9148 let unit = systemd_unit(&plan);
9149 let directives = ini_directives(&unit, "Service");
9150 let listed = split_quoted(
9151 directives
9152 .get("ReadWritePaths")
9153 .expect("the unit names its writable paths"),
9154 );
9155 assert_eq!(listed.len(), 5, "{listed:?}");
9156 for path in plan.directories().all() {
9157 assert!(
9158 listed.iter().any(|entry| entry == &path.to_string_lossy()),
9159 "{} is missing from {listed:?}",
9160 path.display()
9161 );
9162 }
9163 assert!(
9164 listed
9165 .iter()
9166 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9167 "atomic credential replacement needs its parent directory in {listed:?}"
9168 );
9169 }
9170
9171 #[test]
9172 fn the_unit_records_the_absolute_binary_path() {
9173 let plan = linux_plan(StartMode::Boot);
9174 let unit = systemd_unit(&plan);
9175 assert!(
9176 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9177 "{unit}"
9178 );
9179 }
9180
9181 #[test]
9186 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9187 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9188 assert!(
9191 plist.contains(
9192 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
9193 ),
9194 "{plist}"
9195 );
9196 assert!(
9197 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
9198 "{plist}"
9199 );
9200 }
9201
9202 #[test]
9203 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9204 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9205 assert_eq!(
9206 plist_string_value(&daemon, "UserName").as_deref(),
9207 Some("root")
9208 );
9209 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9210
9211 let agent = launchd_plist(&linux_plan(StartMode::Login));
9212 assert_eq!(
9213 plist_string_value(&agent, "UserName"),
9214 None,
9215 "a LaunchAgent already runs as the operator:\n{agent}"
9216 );
9217 }
9218
9219 #[test]
9220 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9221 for mode in [StartMode::Boot, StartMode::Login] {
9222 let plan = linux_plan(mode);
9223 let plist = launchd_plist(&plan);
9224 assert_eq!(
9225 plist_string_value(&plist, "ProcessType").as_deref(),
9226 Some("Interactive"),
9227 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9228 );
9229 let review = review_least_privilege(
9230 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9231 &plan,
9232 );
9233 assert!(
9234 review
9235 .controls()
9236 .iter()
9237 .any(|control| control == "ProcessType=Interactive"),
9238 "{review}"
9239 );
9240 assert!(
9241 !review
9242 .findings()
9243 .iter()
9244 .any(|finding| finding.subject == "ProcessType"),
9245 "{review}"
9246 );
9247 }
9248 }
9249
9250 #[test]
9254 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9255 let plan = linux_plan(StartMode::Login);
9256 let rendered = launchd_plist(&plan);
9257 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
9258 for (label, throttled) in [
9259 (
9260 "Background",
9261 edited(
9262 &rendered,
9263 interactive,
9264 "<key>ProcessType</key>\n <string>Background</string>\n",
9265 ),
9266 ),
9267 ("absent", edited(&rendered, interactive, "")),
9268 ] {
9269 let review = review_least_privilege(
9270 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9271 &plan,
9272 );
9273 assert!(review.is_least_privilege(), "{label}: {review}");
9274 assert!(
9275 review.findings().iter().any(|finding| {
9276 finding.kind == FindingKind::Shortfall
9277 && finding.subject == "ProcessType"
9278 && finding.detail.contains(label)
9279 }),
9280 "{label}: {review}"
9281 );
9282 }
9283 }
9284
9285 #[test]
9286 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9287 let host = Host::new();
9288 let identity = ServiceIdentity::product();
9289 let plan = InstallPlan::resolve(
9290 identity.clone(),
9291 &host.request(StartMode::Login),
9292 ServiceDirectories::of(&host.paths),
9293 )
9294 .expect("a resolvable plan");
9295 let installed = launchd_plist(&plan);
9296 let definition =
9297 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9298 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9299 record.installed_by_version = "0.4.31".into();
9300 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9301
9302 assert_eq!(
9303 launchd_definition_drift(&identity, &record, path.clone(), &installed),
9304 None
9305 );
9306 let edited_by_operator = edited(
9307 &installed,
9308 "</dict>\n</plist>\n",
9309 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
9310 );
9311 assert_eq!(
9312 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9313 None,
9314 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9315 );
9316
9317 let written_by_0_4_31 = edited(
9318 &installed,
9319 "<string>Interactive</string>",
9320 "<string>Background</string>",
9321 );
9322 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9323 .expect("a Background plist is drift");
9324 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9325 assert_eq!(drift.start_mode, StartMode::Login);
9326 assert_eq!(
9327 drift.rendered, installed,
9328 "the repair must write exactly what `service install` writes"
9329 );
9330
9331 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9332 .with_definition_drift(Some(drift));
9333 let problem = status
9334 .problems()
9335 .iter()
9336 .find(|problem| problem.subject == "definition")
9337 .expect("the drift is reported");
9338 assert!(
9341 problem.detail.contains("0.4.31")
9342 && problem.detail.contains(
9343 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9344 )
9345 && !problem.detail.contains("service install")
9346 && !problem.detail.contains("systemctl")
9347 && !problem.detail.contains("sudo"),
9348 "{}",
9349 problem.detail
9350 );
9351 }
9352
9353 #[cfg(unix)]
9354 #[test]
9355 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9356 use std::os::unix::fs::PermissionsExt as _;
9357
9358 let root = tempfile::tempdir().expect("a temporary directory");
9359 let path = root
9360 .path()
9361 .join("io.github.IvanMurzak.runner-manager.plist");
9362 std::fs::write(&path, "<string>Background</string>").expect("old plist");
9363 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9364 let drift = DefinitionDrift {
9365 path: path.clone(),
9366 installed_by_version: "0.4.31".into(),
9367 rendered: launchd_plist(&linux_plan(StartMode::Login)),
9368 start_mode: StartMode::Login,
9369 manager: DefinitionKind::LaunchdPlist,
9370 };
9371
9372 replace_definition("runner-manager", &drift).expect("replaced");
9373
9374 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9375 assert_eq!(
9376 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9377 0o644
9378 );
9379 assert_eq!(
9380 std::fs::read_dir(root.path()).unwrap().count(),
9381 1,
9382 "no staging file is left beside the plist"
9383 );
9384 }
9385
9386 #[cfg(target_os = "macos")]
9395 #[test]
9396 #[ignore = "run only by launchd, as the job of the detached-reload test"]
9397 fn detached_reload_job() {
9398 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9399 let tag = label
9400 .split_once("-selftest-")
9401 .map(|(_, tag)| tag.to_string())
9402 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9403 let identity = ServiceIdentity::fixture(&tag);
9404 assert_eq!(identity.launchd_label(), label);
9405 let path = std::env::current_dir()
9406 .expect("working directory")
9407 .join(format!("{label}.plist"));
9408 let installed = std::fs::read_to_string(&path).expect("own plist");
9409 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9410 let drift = DefinitionDrift {
9411 path: path.clone(),
9412 installed_by_version: "0.4.31".into(),
9413 rendered: installed.replace(
9414 "<string>Background</string>",
9415 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9416 ),
9417 start_mode: StartMode::Login,
9418 manager: DefinitionKind::LaunchdPlist,
9419 };
9420 replace_definition(identity.name(), &drift).expect("plist replaced");
9421 super::sys::reload_detached(&identity, StartMode::Login, &path)
9422 .expect("helper started");
9423 }
9424 std::thread::sleep(Duration::from_secs(120));
9425 }
9426
9427 #[cfg(target_os = "macos")]
9437 #[test]
9438 fn a_job_reloads_itself_from_its_rewritten_plist() {
9439 if !super::launchd_fixture::enabled() {
9440 return;
9441 }
9442 let root = tempfile::tempdir().expect("a temporary directory");
9443 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9444 let label = identity.launchd_label();
9445 let path = root.path().join(format!("{label}.plist"));
9446 let binary = super::launchd_fixture::job_binary(root.path());
9447 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9448 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9449 plist.push_str(&plist_string("Label", &label));
9450 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
9451 for argument in [
9452 binary.to_string_lossy().as_ref(),
9453 "--ignored",
9454 "--exact",
9455 "service::tests::detached_reload_job",
9456 "--test-threads=1",
9457 ] {
9458 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
9459 }
9460 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
9461 plist.push_str(&plist_string(
9462 "WorkingDirectory",
9463 &root.path().to_string_lossy(),
9464 ));
9465 plist.push_str(&plist_string("ProcessType", "Background"));
9466 plist.push_str("</dict>\n</plist>\n");
9467 std::fs::write(&path, plist).expect("plist written");
9468
9469 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9470 let spawn_type = || {
9471 let printed = std::process::Command::new("launchctl")
9472 .args(["print", loaded.target()])
9473 .output()
9474 .expect("launchctl runs");
9475 String::from_utf8_lossy(&printed.stdout)
9476 .lines()
9477 .find_map(|line| {
9478 line.trim()
9479 .strip_prefix("spawn type = ")
9480 .map(str::to_string)
9481 })
9482 };
9483 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9484
9485 let deadline = std::time::Instant::now() + Duration::from_secs(90);
9486 while spawn_type().as_deref() != Some("interactive (4)") {
9487 assert!(
9488 std::time::Instant::now() < deadline,
9489 "the job did not come back under its rewritten plist; launchd reports {:?}",
9490 spawn_type()
9491 );
9492 std::thread::sleep(Duration::from_millis(250));
9493 }
9494 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9495 assert_eq!(
9496 plist_string_value(&rewritten, "ProcessType").as_deref(),
9497 Some(LAUNCHD_PROCESS_TYPE)
9498 );
9499 }
9500
9501 #[test]
9502 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9503 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9504 assert!(
9505 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9506 "{plist}"
9507 );
9508 assert!(plist.contains("<string>daemon</string>"), "{plist}");
9509 assert!(plist.contains("<string>run</string>"), "{plist}");
9510 }
9511
9512 #[test]
9513 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9514 assert_eq!(
9515 ServiceIdentity::product().launchd_label(),
9516 "io.github.IvanMurzak.runner-manager"
9517 );
9518 }
9519
9520 #[test]
9525 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9526 let xml = windows_scheduled_task_xml(
9527 &windows_plan(StartMode::Login),
9528 &TaskPrincipal::named("HOST\\operator"),
9529 );
9530 assert!(xml.contains("<LogonTrigger>"), "{xml}");
9531 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9532 assert!(
9533 xml.contains("<LogonType>InteractiveToken</LogonType>"),
9534 "{xml}"
9535 );
9536 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9537 assert!(
9538 xml.contains("<Interval>PT1M</Interval>"),
9539 "Task Scheduler takes whole minutes only, and rejects the registration outright \
9540 for anything finer:\n{xml}"
9541 );
9542 }
9543
9544 #[test]
9545 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9546 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9551 let policy =
9552 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9553 .expect("inside the supported range");
9554 assert_eq!(
9555 policy
9556 .effective_delay(DefinitionKind::WindowsScheduledTask)
9557 .as_secs(),
9558 enforced,
9559 "a {asked}s delay must be enforced as {enforced}s"
9560 );
9561 assert!(
9562 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9563 "rounding must never shorten the bound"
9564 );
9565 }
9566 }
9567
9568 #[test]
9569 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9570 let policy = RestartPolicy::default();
9571 for kind in [
9572 DefinitionKind::WindowsService,
9573 DefinitionKind::LaunchdPlist,
9574 DefinitionKind::SystemdUnit,
9575 ] {
9576 assert_eq!(
9577 policy.effective_delay(kind),
9578 policy.delay(),
9579 "{kind:?} takes seconds and enforces exactly what it is given"
9580 );
9581 }
9582 }
9583
9584 #[test]
9585 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9586 let host = Host::new();
9587 let operations = host.operations();
9588 operations
9589 .install(&host.request(StartMode::Login))
9590 .expect("an install at login");
9591 host.controls.edit("runner-manager", |registration| {
9593 registration.manager = DefinitionKind::WindowsScheduledTask;
9594 registration.restart_delay = Some(Duration::from_secs(60));
9595 });
9596
9597 let status = operations.status().expect("a status");
9598 assert!(
9599 status.is_healthy(),
9600 "minute granularity is the manager's, not a mis-registration: {status}"
9601 );
9602 assert!(
9603 status
9604 .notes()
9605 .iter()
9606 .any(|note| note.contains("whole minutes")),
9607 "but the operator must be told why 15 became 60: {status}"
9608 );
9609
9610 host.controls.edit("runner-manager", |registration| {
9613 registration.restart_delay = Some(Duration::from_secs(1));
9614 });
9615 assert!(
9616 !operations.status().expect("a status").is_healthy(),
9617 "a one-second delay is not what any manager was asked for"
9618 );
9619 }
9620
9621 #[test]
9622 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9623 let plan = windows_plan(StartMode::Login);
9624 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9625 assert_eq!(
9626 xml_value(&xml, "Command").as_deref(),
9627 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9628 "{xml}"
9629 );
9630 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9631 }
9632
9633 #[test]
9634 fn an_account_name_holding_xml_punctuation_is_escaped() {
9635 let xml = windows_scheduled_task_xml(
9636 &windows_plan(StartMode::Login),
9637 &TaskPrincipal::named("R&D\\ops"),
9638 );
9639 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
9640 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9641 }
9642
9643 #[test]
9648 fn the_service_starts_automatically_under_the_account_the_store_admits() {
9649 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9650 let directives = ini_directives(&text, "windows-service");
9651 assert_eq!(
9652 directives.get("StartType").map(String::as_str),
9653 Some("AutoStart")
9654 );
9655 assert_eq!(
9656 directives.get("Account").map(String::as_str),
9657 Some("NT AUTHORITY\\SYSTEM")
9658 );
9659 assert_eq!(
9660 directives.get("ServiceType").map(String::as_str),
9661 Some("OWN_PROCESS")
9662 );
9663 assert_eq!(
9664 directives
9665 .get("FailureActionRestartDelaySecs")
9666 .map(String::as_str),
9667 Some("15")
9668 );
9669 assert_eq!(
9670 directives
9671 .get("FailureActionsOnNonCrashFailures")
9672 .map(String::as_str),
9673 Some("true"),
9674 "without this flag a non-zero exit is not a failure the manager restarts"
9675 );
9676 }
9677
9678 #[test]
9679 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9680 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9681 assert_eq!(spec.account, None);
9682 assert!(spec.automatic_start);
9683 assert!(
9684 spec.command_line.contains("daemon run"),
9685 "{}",
9686 spec.command_line
9687 );
9688 }
9689
9690 #[test]
9695 fn each_definition_goes_where_its_platform_expects_it() {
9696 let home = PathBuf::from("/home/op");
9697 assert_eq!(
9698 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9699 Some(Path::new(
9700 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9701 ))
9702 );
9703 assert_eq!(
9704 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9705 Some(Path::new(
9706 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9707 ))
9708 );
9709 assert_eq!(
9710 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9711 Some(Path::new("/etc/systemd/system/runner-manager.service"))
9712 );
9713 assert_eq!(
9714 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9715 Some(Path::new(
9716 "/home/op/.config/systemd/user/runner-manager.service"
9717 ))
9718 );
9719 assert_eq!(
9720 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9721 None,
9722 "the Service Control Manager has no file"
9723 );
9724 }
9725
9726 #[test]
9727 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9728 assert_eq!(
9729 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9730 None
9731 );
9732 assert_eq!(
9733 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9734 None
9735 );
9736 }
9737
9738 #[test]
9743 fn the_rendered_definitions_are_all_least_privilege() {
9744 let linux = linux_plan(StartMode::Boot);
9745 let windows = windows_plan(StartMode::Boot);
9746 for (definition, plan) in [
9747 (ServiceDefinition::systemd(&linux, None), &linux),
9748 (ServiceDefinition::launchd(&linux, None), &linux),
9749 (ServiceDefinition::windows_service(&windows), &windows),
9750 ] {
9751 let review = review_least_privilege(&definition, plan);
9752 assert!(
9753 review.is_least_privilege(),
9754 "{:?} should be least privilege, got:\n{review}",
9755 definition.kind()
9756 );
9757 assert!(
9758 !review.controls().is_empty(),
9759 "a review that confirms nothing proves nothing: {:?}",
9760 definition.kind()
9761 );
9762 }
9763 }
9764
9765 #[test]
9766 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9767 let plan = windows_plan(StartMode::Login);
9768 let definition =
9769 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9770 let review = review_least_privilege(&definition, &plan);
9771 assert!(review.is_least_privilege(), "{review}");
9772 assert!(
9773 review
9774 .controls()
9775 .iter()
9776 .any(|control| control.contains("LeastPrivilege")),
9777 "{review}"
9778 );
9779 }
9780
9781 #[test]
9782 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9783 let plan = linux_plan(StartMode::Boot);
9784 let rendered = systemd_unit(&plan);
9785 let widened = edited(
9786 &rendered,
9787 "ReadWritePaths=/var/lib/runner-manager/config",
9788 "ReadWritePaths=/etc /var/lib/runner-manager/config",
9789 );
9790 let review = review_least_privilege(
9791 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9792 &plan,
9793 );
9794 assert!(!review.is_least_privilege(), "{review}");
9795 assert!(
9796 review
9797 .excesses()
9798 .iter()
9799 .any(|finding| finding.detail.contains("/etc")),
9800 "the review must name the directory it objects to: {review}"
9801 );
9802 }
9803
9804 #[test]
9805 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9806 let plan = linux_plan(StartMode::Boot);
9807 let rendered = systemd_unit(&plan);
9808 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9809 let review = review_least_privilege(
9810 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9811 &plan,
9812 );
9813 assert!(!review.is_least_privilege(), "{review}");
9814 assert!(
9815 review
9816 .excesses()
9817 .iter()
9818 .any(|finding| finding.subject == "NoNewPrivileges"),
9819 "{review}"
9820 );
9821 }
9822
9823 fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
9825 review_least_privilege(
9826 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
9827 plan,
9828 )
9829 .findings()
9830 .iter()
9831 .any(|finding| finding.subject == subject && finding.kind == kind)
9832 }
9833
9834 #[test]
9835 fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
9836 for mode in [StartMode::Boot, StartMode::Login] {
9837 let unit = systemd_unit(&linux_plan(mode));
9838 assert!(
9839 !unit.contains("RestrictSUIDSGID"),
9840 "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
9841 );
9842 }
9843 }
9844
9845 #[test]
9846 fn only_a_boot_daemon_keeps_capabilities_and_boot_units_hide_home_trees() {
9847 let boot = systemd_unit(&linux_plan(StartMode::Boot));
9848 let directives = ini_directives(&boot, "Service");
9849 assert_eq!(directives["CapabilityBoundingSet"], BOOT_CAPABILITIES);
9850 assert_eq!(directives["AmbientCapabilities"], "");
9851 assert_eq!(directives["NoNewPrivileges"], "yes");
9852 assert_eq!(directives["ProtectHome"], "tmpfs");
9853 assert!(!directives.contains_key("BindPaths"));
9854
9855 let login = systemd_unit(&linux_plan(StartMode::Login));
9856 let directives = ini_directives(&login, "Service");
9857 assert_eq!(directives["CapabilityBoundingSet"], "");
9858 assert!(!directives.contains_key("ProtectHome"));
9859
9860 let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
9861 assert!(reports(
9862 &unprotected,
9863 &linux_plan(StartMode::Boot),
9864 "ProtectHome",
9865 FindingKind::Excess
9866 ));
9867 let boot_set = format!("CapabilityBoundingSet={BOOT_CAPABILITIES}\n");
9868 let inverted = boot.replace(&boot_set, "CapabilityBoundingSet=~CAP_SYS_ADMIN\n");
9869 assert!(reports(
9870 &inverted,
9871 &linux_plan(StartMode::Boot),
9872 "CapabilityBoundingSet",
9873 FindingKind::Excess
9874 ));
9875 let widened = boot.replace(
9876 &boot_set,
9877 &format!("CapabilityBoundingSet={BOOT_CAPABILITIES} CAP_SYS_ADMIN\n"),
9878 );
9879 assert!(reports(
9880 &widened,
9881 &linux_plan(StartMode::Boot),
9882 "CapabilityBoundingSet",
9883 FindingKind::Excess
9884 ));
9885 let narrowed = boot.replace(&boot_set, "CapabilityBoundingSet=CAP_SETUID\n");
9886 assert!(reports(
9887 &narrowed,
9888 &linux_plan(StartMode::Boot),
9889 "CapabilityBoundingSet",
9890 FindingKind::Shortfall
9891 ));
9892 }
9893
9894 #[test]
9895 fn status_reports_the_registered_account_and_falls_back_to_the_record() {
9896 let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
9897 for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
9898 let host = Host::new();
9899 let operations = host.operations();
9900 operations.install(&host.request(StartMode::Boot)).unwrap();
9901 host.controls.edit("runner-manager", |registration| {
9902 registration.account = registered.map(str::to_string);
9903 });
9904 let status = operations.status().unwrap().to_string();
9905 assert!(
9906 status.contains(&format!("account {shown}\n")),
9907 "{status}"
9908 );
9909 }
9910 }
9911
9912 #[cfg(target_os = "linux")]
9913 #[test]
9914 fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
9915 let host = Host::new();
9916 let operations = host.operations();
9917 operations.install(&host.request(StartMode::Boot)).unwrap();
9918 host.controls.edit("runner-manager", |registration| {
9919 registration.account = Some("ivan".to_string());
9920 });
9921 let refused = operations
9922 .install(&host.request(StartMode::Boot))
9923 .expect_err("a unit running as ivan cannot use root's tree");
9924 let message = refused.to_string();
9925 assert!(message.contains("run as ivan"), "{message}");
9926 assert!(message.contains("Remove `User=`"), "{message}");
9927 }
9928
9929 #[test]
9930 fn manager_account_spellings_match_the_record() {
9931 assert_eq!(
9932 windows_service_account("LocalSystem"),
9933 ServiceAccount::LocalSystem.as_str()
9934 );
9935 assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
9936 assert_eq!(
9937 scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
9938 None
9939 );
9940 assert_eq!(
9941 scheduled_task_account(Some("HOST\\op".into())),
9942 Some("HOST\\op".to_string())
9943 );
9944 }
9945
9946 #[test]
9947 fn exec_start_prefixes_are_not_part_of_the_binary() {
9948 for (value, expected) in [
9949 ("/opt/rm daemon run", "/opt/rm daemon run"),
9950 ("-/opt/rm daemon run", "/opt/rm daemon run"),
9951 ("+!/opt/rm daemon run", "/opt/rm daemon run"),
9952 ("!!/opt/rm daemon run", "/opt/rm daemon run"),
9953 ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
9954 ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
9955 ] {
9956 assert_eq!(systemd_exec_command_line(value), expected, "{value}");
9957 }
9958 assert_eq!(
9959 executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
9960 Some(PathBuf::from("/opt/rm"))
9961 );
9962 }
9963
9964 #[test]
9965 fn restart_sec_accepts_systemd_time_spans() {
9966 for (value, expected) in [
9967 ("15", Duration::from_secs(15)),
9968 ("15s", Duration::from_secs(15)),
9969 ("1min", Duration::from_secs(60)),
9970 ("1min 30s", Duration::from_secs(90)),
9971 ("1min30s", Duration::from_secs(90)),
9972 ("2h", Duration::from_secs(7_200)),
9973 ("500ms", Duration::from_millis(500)),
9974 ("1.5s", Duration::from_millis(1_500)),
9975 ] {
9976 assert_eq!(systemd_timespan(value), Some(expected), "{value}");
9977 }
9978 for value in ["", "infinity", "soon", "5 parsecs", "s"] {
9979 assert_eq!(systemd_timespan(value), None, "{value}");
9980 }
9981 }
9982
9983 #[cfg(unix)]
9984 #[test]
9985 fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
9986 let system = ServiceDirectories::of(&AppPaths::system());
9987 assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
9988 assert_eq!(
9989 data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
9990 Some("root")
9991 );
9992 }
9993
9994 #[test]
9995 fn a_boot_service_refuses_directories_it_could_not_open() {
9996 let var = Path::new("/var/lib/runner-manager/state");
9997 assert_eq!(boot_directory_refusal(var, Some(0)), None);
9998 let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
9999 assert!(refused.contains("uid 1000"), "{refused}");
10000 assert!(boot_directory_refusal(var, None).is_some());
10001 for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
10002 let refused =
10003 boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
10004 assert!(refused.contains("cannot see"), "{refused}");
10005 }
10006 }
10007
10008 #[cfg(unix)]
10009 #[test]
10010 fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
10011 use std::os::unix::fs::PermissionsExt as _;
10012
10013 let from = tempfile::tempdir().unwrap();
10014 let to = tempfile::tempdir().unwrap();
10015 let target = to.path().join("state");
10016 std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
10017 std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
10018 std::fs::write(from.path().join("secret"), b"x").unwrap();
10019 std::fs::set_permissions(
10020 from.path().join("secret"),
10021 std::fs::Permissions::from_mode(0o600),
10022 )
10023 .unwrap();
10024 std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
10025 std::fs::create_dir(from.path().join("bin")).unwrap();
10026
10027 copy_tree(from.path(), &target, Some("bin")).unwrap();
10028
10029 assert_eq!(
10030 std::fs::read(target.join("journal/2026/attempt")).unwrap(),
10031 b"entry"
10032 );
10033 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
10034 assert_eq!(mode(&target.join("secret")), 0o600);
10035 assert_eq!(mode(&target.join("journal")), 0o700);
10036 assert_eq!(
10037 std::fs::read_link(target.join("latest")).unwrap(),
10038 Path::new("journal")
10039 );
10040 assert!(!target.join("bin").exists());
10041
10042 std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
10044 set_aside(&target, "20261110T000000Z").unwrap();
10045 assert!(!target.exists());
10046 let aside = to.path().join("state.before-move-20261110T000000Z");
10047 assert_eq!(
10048 std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
10049 b"stale"
10050 );
10051 copy_tree(from.path(), &target, Some("bin")).unwrap();
10052 assert!(!target.join("stale.sqlite3-wal").exists());
10053 set_aside(&to.path().join("absent"), "x").unwrap();
10054 }
10055
10056 #[test]
10057 fn systemd_drop_ins_replace_base_service_directives() {
10058 let base = systemd_unit(&linux_plan(StartMode::Boot));
10059 let combined = format!(
10060 "# /etc/systemd/system/runner-manager.service\n{base}\n\
10061 # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10062 [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10063 ExecStart=/opt/runner-manager daemon run\n"
10064 );
10065 let directives = ini_directives(&combined, "Service");
10066 assert_eq!(directives["User"], "ivan");
10067 assert_eq!(directives["RestartSec"], "30");
10068 assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10069 assert_eq!(directives["ProtectSystem"], "strict");
10070 }
10071
10072 #[test]
10073 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10074 let plan = linux_plan(StartMode::Login);
10075 let rendered = systemd_unit(&plan);
10076 let widened = edited(
10077 &rendered,
10078 "CapabilityBoundingSet=\n",
10079 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10080 );
10081 let review = review_least_privilege(
10082 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10083 &plan,
10084 );
10085 assert!(!review.is_least_privilege(), "{review}");
10086 assert!(
10087 review
10088 .excesses()
10089 .iter()
10090 .any(|finding| finding.subject == "CapabilityBoundingSet"),
10091 "{review}"
10092 );
10093 }
10094
10095 #[test]
10096 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10097 let plan = linux_plan(StartMode::Boot);
10098 let rendered = systemd_unit(&plan);
10099 let widened = edited(
10100 &rendered,
10101 "[Install]",
10102 "ListenStream=127.0.0.1:9000\n\n[Install]",
10103 );
10104 let review = review_least_privilege(
10105 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10106 &plan,
10107 );
10108 assert!(
10109 !review.is_least_privilege(),
10110 "07-security.md rule 2 forbids any inbound surface: {review}"
10111 );
10112 }
10113
10114 #[test]
10115 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10116 let plan = linux_plan(StartMode::Boot);
10117 let rendered = systemd_unit(&plan);
10118 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10119 let review = review_least_privilege(
10120 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10121 &plan,
10122 );
10123 assert!(
10124 review.is_least_privilege(),
10125 "too little authority is not an excess: {review}"
10126 );
10127 assert!(
10128 review
10129 .findings()
10130 .iter()
10131 .any(|finding| finding.kind == FindingKind::Shortfall
10132 && finding.detail.contains("runtime")),
10133 "{review}"
10134 );
10135 }
10136
10137 #[test]
10138 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10139 let plan = linux_plan(StartMode::Login);
10140 let rendered = launchd_plist(&plan);
10141 let widened = edited(
10142 &rendered,
10143 "<key>ProcessType</key>",
10144 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
10145 );
10146 let review = review_least_privilege(
10147 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10148 &plan,
10149 );
10150 assert!(!review.is_least_privilege(), "{review}");
10151 assert!(
10152 review
10153 .excesses()
10154 .iter()
10155 .any(|finding| finding.subject == "UserName"),
10156 "{review}"
10157 );
10158 }
10159
10160 #[test]
10161 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10162 let plan = linux_plan(StartMode::Boot);
10163 let rendered = launchd_plist(&plan);
10164 let widened = edited(
10165 &rendered,
10166 "<key>SessionCreate</key>\n <false/>",
10167 "<key>SessionCreate</key>\n <true/>",
10168 );
10169 let review = review_least_privilege(
10170 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10171 &plan,
10172 );
10173 assert!(!review.is_least_privilege(), "{review}");
10174 assert!(
10175 review
10176 .excesses()
10177 .iter()
10178 .any(|finding| finding.subject == "SessionCreate"),
10179 "{review}"
10180 );
10181 }
10182
10183 #[test]
10184 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10185 let plan = linux_plan(StartMode::Boot);
10186 let rendered = launchd_plist(&plan);
10187 let widened = edited(
10188 &rendered,
10189 "<key>ProcessType</key>",
10190 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
10191 );
10192 let review = review_least_privilege(
10193 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10194 &plan,
10195 );
10196 assert!(!review.is_least_privilege(), "{review}");
10197 }
10198
10199 #[test]
10200 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10201 let plan = windows_plan(StartMode::Login);
10202 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10203 let widened = edited(
10204 &rendered,
10205 "<RunLevel>LeastPrivilege</RunLevel>",
10206 "<RunLevel>HighestAvailable</RunLevel>",
10207 );
10208 let review = review_least_privilege(
10209 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10210 &plan,
10211 );
10212 assert!(!review.is_least_privilege(), "{review}");
10213 assert!(
10214 review
10215 .excesses()
10216 .iter()
10217 .any(|finding| finding.subject == "RunLevel"),
10218 "{review}"
10219 );
10220 }
10221
10222 #[test]
10223 fn a_task_that_would_store_a_password_is_not_least_privilege() {
10224 let plan = windows_plan(StartMode::Login);
10225 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10226 let widened = edited(
10227 &rendered,
10228 "<LogonType>InteractiveToken</LogonType>",
10229 "<LogonType>Password</LogonType>",
10230 );
10231 let review = review_least_privilege(
10232 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10233 &plan,
10234 );
10235 assert!(!review.is_least_privilege(), "{review}");
10236 }
10237
10238 #[test]
10239 fn an_interactive_windows_service_is_not_least_privilege() {
10240 let plan = windows_plan(StartMode::Boot);
10241 let rendered = windows_service_descriptor(&plan);
10242 let widened = edited(
10243 &rendered,
10244 "ServiceType=OWN_PROCESS",
10245 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10246 );
10247 let review = review_least_privilege(
10248 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10249 &plan,
10250 );
10251 assert!(!review.is_least_privilege(), "{review}");
10252 assert!(
10253 review
10254 .excesses()
10255 .iter()
10256 .any(|finding| finding.subject == "ServiceType"),
10257 "{review}"
10258 );
10259 }
10260
10261 #[test]
10262 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10263 let plan = windows_plan(StartMode::Boot);
10264 let rendered = windows_service_descriptor(&plan);
10265 let changed = edited(
10266 &rendered,
10267 "Account=NT AUTHORITY\\SYSTEM",
10268 "Account=NT AUTHORITY\\LocalService",
10269 );
10270 let review = review_least_privilege(
10271 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10272 &plan,
10273 );
10274 assert!(review.is_least_privilege(), "{review}");
10280 assert!(
10281 review.findings().iter().any(|finding| {
10282 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10283 }),
10284 "{review}"
10285 );
10286 }
10287
10288 #[test]
10293 fn a_recorded_path_that_is_still_there_is_current() {
10294 let host = Host::new();
10295 let state = inspect_binary(&host.binary, Some(&host.binary));
10296 assert!(!state.is_error(), "{state}");
10297 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10298 }
10299
10300 #[test]
10301 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10302 let host = Host::new();
10303 let recorded = host.binary.clone();
10307 let healthy = inspect_binary(&recorded, Some(&recorded));
10308 assert!(
10309 !healthy.is_error(),
10310 "the discriminator: before the binary moves, this must be healthy"
10311 );
10312
10313 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10314
10315 let state = inspect_binary(&recorded, Some(&recorded));
10316 assert!(state.is_error(), "{state}");
10317 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10318 assert!(
10319 state.to_string().contains("npm"),
10320 "the message must name the cause an operator will not otherwise connect: {state}"
10321 );
10322 }
10323
10324 #[test]
10325 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10326 let root = tempfile::tempdir().expect("a temporary directory");
10327 let state = inspect_binary(root.path(), None);
10328 assert!(state.is_error(), "{state}");
10329 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10330 }
10331
10332 #[test]
10333 fn a_registration_naming_a_different_binary_is_a_divergence() {
10334 let host = Host::new();
10335 let other = host.binary.with_file_name("something-else");
10336 let state = inspect_binary(&host.binary, Some(&other));
10337 assert!(state.is_error(), "{state}");
10338 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10339 }
10340
10341 #[test]
10342 fn absence_is_reported_before_divergence() {
10343 let host = Host::new();
10344 let recorded = host.binary.clone();
10345 std::fs::remove_file(&recorded).expect("removable");
10346 let other = recorded.with_file_name("something-else");
10347 assert!(matches!(
10351 inspect_binary(&recorded, Some(&other)),
10352 BinaryPath::Missing { .. }
10353 ));
10354 }
10355
10356 #[test]
10361 fn the_record_round_trips_through_toml() {
10362 let host = Host::new();
10363 let plan = InstallPlan::resolve(
10364 ServiceIdentity::product(),
10365 &host.request(StartMode::Boot),
10366 ServiceDirectories::of(&host.paths),
10367 )
10368 .expect("a resolvable plan");
10369 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10370 let record = InstallRecord::of(&plan, &definition, Utc::now());
10371 record.write(&host.paths).expect("a writable record");
10372 let read = InstallRecord::read(&host.paths)
10373 .expect("a readable record")
10374 .expect("a record is there");
10375 assert_eq!(read, record);
10376 assert_eq!(read.binary, host.binary);
10377 assert!(read.binary.is_absolute());
10378 }
10379
10380 #[test]
10381 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10382 let host = Host::new();
10383 let identity = ServiceIdentity::product();
10384 let plan = InstallPlan::resolve(
10385 identity.clone(),
10386 &host.request(StartMode::Boot),
10387 ServiceDirectories::of(&host.paths),
10388 )
10389 .expect("a resolvable plan");
10390 let installed = systemd_unit(&plan);
10391 let definition =
10392 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10393 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10394 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10395
10396 assert_eq!(
10397 systemd_definition_drift(&identity, &record, path.clone(), &installed),
10398 None,
10399 "the record must rebuild exactly the unit `service install` wrote"
10400 );
10401
10402 record.installed_by_version = "0.4.7".into();
10403 let frozen = installed.replace(
10404 "[Service]\n",
10405 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10406 );
10407 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10408 .expect("an outdated unit is drift");
10409 assert_eq!(drift.installed_by_version, "0.4.7");
10410 assert_eq!(drift.rendered, installed);
10411
10412 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10413 .with_definition_drift(Some(drift));
10414 assert!(!status.is_healthy());
10415 assert!(
10416 status
10417 .problems()
10418 .iter()
10419 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10420 "{status}"
10421 );
10422 }
10423
10424 #[test]
10425 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10426 let host = Host::new();
10427 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10428 let first = Utc::now() - chrono::Duration::hours(9);
10429 record_github_credential_rejected(&host.paths, first).unwrap();
10430 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10431 let since = github_credential_rejected_since(&host.paths).unwrap();
10432 assert_eq!(since, Some(first));
10433
10434 let status = ServiceStatus::compose(
10435 ServiceIdentity::product(),
10436 None,
10437 None,
10438 None,
10439 None,
10440 &host.paths,
10441 )
10442 .with_credential_rejection(since);
10443 assert!(!status.is_healthy());
10444 assert_eq!(status.credential_rejected_since(), Some(first));
10445 assert!(status.to_string().contains("auth login"), "{status}");
10446
10447 clear_github_credential_rejection(&host.paths).unwrap();
10448 clear_github_credential_rejection(&host.paths).unwrap();
10449 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10450 }
10451
10452 #[test]
10454 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10455 use crate::launch_health::{
10456 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10457 record_launches_blocked,
10458 };
10459
10460 let host = Host::new();
10461 let compose = || {
10462 ServiceStatus::compose(
10463 ServiceIdentity::product(),
10464 None,
10465 None,
10466 None,
10467 None,
10468 &host.paths,
10469 )
10470 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10471 };
10472 assert!(
10473 !compose()
10474 .problems()
10475 .iter()
10476 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10477 );
10478 let blocked = BlockCause::FenceHeld {
10479 directory: PathBuf::from("launch-fence"),
10480 owner: None,
10481 }
10482 .blocked_since(Utc::now(), Viewpoint::Guest);
10483 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10484
10485 let status = compose();
10486 assert!(!status.is_healthy());
10487 assert_eq!(status.launches_blocked(), Some(&blocked));
10488 let problem = status
10489 .problems()
10490 .iter()
10491 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10492 .expect("reported under its own subject");
10493 assert!(problem.detail.contains("systemctl restart"), "{status}");
10494
10495 clear_launches_blocked(&host.paths).unwrap();
10496 assert_eq!(compose().launches_blocked(), None);
10497 }
10498
10499 #[test]
10502 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10503 let host = Host::new();
10504 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10505 let first = Utc::now() - chrono::Duration::minutes(40);
10506 record_credential_unreadable(&host.paths, first).unwrap();
10507 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10508 let since = credential_unreadable_since(&host.paths).unwrap();
10509 assert_eq!(since, Some(first), "a restart loop must not move the start");
10510
10511 let status = ServiceStatus::compose(
10512 ServiceIdentity::product(),
10513 None,
10514 None,
10515 None,
10516 None,
10517 &host.paths,
10518 )
10519 .with_credential_unreadable(since);
10520 assert!(!status.is_healthy());
10521 let problem = status
10522 .problems()
10523 .iter()
10524 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10525 .expect("reported under its own subject");
10526 assert!(
10528 problem
10529 .detail
10530 .contains(&sign_in_instruction(StartMode::default())),
10531 "{status}"
10532 );
10533
10534 clear_credential_unreadable(&host.paths).unwrap();
10535 clear_credential_unreadable(&host.paths).unwrap();
10536 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10537 let healthy = ServiceStatus::compose(
10538 ServiceIdentity::product(),
10539 None,
10540 None,
10541 None,
10542 None,
10543 &host.paths,
10544 )
10545 .with_credential_unreadable(None);
10546 assert!(
10547 !healthy
10548 .problems()
10549 .iter()
10550 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10551 "{healthy}"
10552 );
10553 }
10554
10555 #[test]
10556 fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10557 assert_eq!(sudo_program(None), "runner-manager");
10558 assert_eq!(
10559 sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10560 "runner-manager"
10561 );
10562 assert_eq!(
10563 sudo_program(Some(Path::new(
10564 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10565 runner-manager-linux-x64/bin/runner-manager"
10566 ))),
10567 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10568 runner-manager-linux-x64/bin/runner-manager"
10569 );
10570 assert_eq!(
10571 sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10572 "'/home/o'\\''p/my bin/runner-manager'"
10573 );
10574 assert_eq!(
10575 sudo_command("service status"),
10576 "sudo runner-manager service status"
10577 );
10578 }
10579
10580 #[test]
10581 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
10582 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
10583 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
10584 assert_eq!(
10585 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
10586 cfg!(unix),
10587 "a machine-scoped store needs root on Unix"
10588 );
10589 assert_eq!(
10590 sign_in_instruction(StartMode::Login).contains("SSH"),
10591 cfg!(target_os = "macos"),
10592 "only the macOS login keychain is out of an SSH session's reach"
10593 );
10594 }
10595
10596 #[cfg(unix)]
10605 #[test]
10606 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
10607 use std::os::unix::fs::PermissionsExt as _;
10608
10609 let host = Host::new();
10610 let plan = InstallPlan::resolve(
10611 ServiceIdentity::product(),
10612 &host.request(StartMode::Boot),
10613 ServiceDirectories::of(&host.paths),
10614 )
10615 .expect("a resolvable plan");
10616 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10617 InstallRecord::of(&plan, &definition, Utc::now())
10618 .write(&host.paths)
10619 .expect("a writable record");
10620
10621 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
10622 .expect("the record is there")
10623 .permissions()
10624 .mode()
10625 & 0o777;
10626 assert_eq!(
10627 mode, 0o644,
10628 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
10629 service install` wrote, and `service status` fails on their own host. It holds no \
10630 credential and sits in a 0700 directory, so 0644 discloses nothing"
10631 );
10632 }
10633
10634 #[test]
10638 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
10639 let host = Host::new();
10640 let path = InstallRecord::path(&host.paths);
10641 std::fs::write(
10642 &path,
10643 format!(
10644 "schema_version = {RECORD_SCHEMA_VERSION}
10645service_name = \"runner-manager\"
10646 manager = \"systemd\"
10647start_mode = \"boot\"
10648account = \"root\"
10649 binary = \"/x\"
10650arguments = []
10651restart_delay_secs = 15
10652 restart_reset_secs = 600
10653log_file = \"/x\"
10654 installed_at = \"2026-01-01T00:00:00Z\"
10655installed_by_version = \"0.1.0\"
10656 [directories]
10657config = \"/a\"
10658state = \"/b\"
10659runtime = \"/c\"
10660logs = \"/d\"
10661"
10662 ),
10663 )
10664 .expect("a writable record");
10665 let read = InstallRecord::read(&host.paths)
10666 .expect("a record missing an optional field is still readable")
10667 .expect("a record is there");
10668 assert_eq!(
10669 read.source_binary, None,
10670 "the legacy layout has no source, and must not invent one"
10671 );
10672 }
10673
10674 #[test]
10676 fn a_registration_remembers_the_file_it_was_copied_from() {
10677 let host = Host::new();
10678 let source = host.binary.with_file_name("npm-installed-runner-manager");
10679 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
10680 let plan = InstallPlan::resolve(
10681 ServiceIdentity::product(),
10682 &host.request(StartMode::Boot).copied_from(&source),
10683 ServiceDirectories::of(&host.paths),
10684 )
10685 .expect("a resolvable plan");
10686 let definition = ServiceDefinition::from_text(
10687 DefinitionKind::SystemdUnit,
10688 "[Service]
10689",
10690 );
10691 let record = InstallRecord::of(&plan, &definition, Utc::now());
10692 record.write(&host.paths).expect("a writable record");
10693
10694 let read = InstallRecord::read(&host.paths)
10695 .expect("a readable record")
10696 .expect("a record is there");
10697 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
10698 assert_ne!(
10699 read.source_binary.as_deref(),
10700 Some(read.binary.as_path()),
10701 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
10702 );
10703 }
10704
10705 #[test]
10706 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
10707 let host = Host::new();
10708 let path = InstallRecord::path(&host.paths);
10709 std::fs::write(
10710 &path,
10711 format!(
10712 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
10713 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
10714 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
10715 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
10716 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
10717 RECORD_SCHEMA_VERSION + 1
10718 ),
10719 )
10720 .expect("a writable record");
10721 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
10722 assert!(
10723 matches!(error, ServiceError::RecordUnreadable { .. }),
10724 "{error}"
10725 );
10726 assert!(
10727 error.to_string().contains("service uninstall"),
10728 "the message must say how to recover: {error}"
10729 );
10730 }
10731
10732 #[test]
10733 fn no_record_is_not_an_error() {
10734 let host = Host::new();
10735 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
10736 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
10737 }
10738
10739 #[test]
10744 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
10745 let host = Host::new();
10746 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
10747 }
10748
10749 #[test]
10750 fn the_heartbeat_round_trips_to_the_second() {
10751 let host = Host::new();
10752 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
10753 .expect("a valid timestamp")
10754 .with_timezone(&Utc);
10755 record_github_contact(&host.paths, at).expect("a writable heartbeat");
10756 assert_eq!(
10757 last_github_contact(&host.paths).expect("readable"),
10758 Some(at)
10759 );
10760 }
10761
10762 #[test]
10763 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
10764 let host = Host::new();
10765 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
10766 let error = last_github_contact(&host.paths)
10767 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
10768 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10769 }
10770
10771 #[test]
10777 fn a_runner_root_refusal_round_trips_for_service_status() {
10778 let host = Host::new();
10779 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10780
10781 assert!(
10782 runner_root_refusals(&host.paths)
10783 .expect("readable")
10784 .is_empty(),
10785 "no record means every policy is placing runners"
10786 );
10787
10788 record_runner_root_refusal(
10789 &host.paths,
10790 "policy-a",
10791 at,
10792 "denied_by_privacy_policy",
10793 "/Volumes/NVME/runners",
10794 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
10795 )
10796 .expect("a writable record");
10797
10798 let refusals = runner_root_refusals(&host.paths).expect("readable");
10799 assert_eq!(refusals.len(), 1);
10800 assert_eq!(refusals[0].policy, "policy-a");
10801 assert_eq!(refusals[0].at, at);
10802 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
10803 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
10804 assert!(
10805 refusals[0].detail.contains("/Volumes/NVME/runners")
10806 && refusals[0].detail.contains("Full Disk Access"),
10807 "the path and the remediation are the whole point of this file: {refusals:?}"
10808 );
10809 }
10810
10811 #[test]
10819 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
10820 let host = Host::new();
10821 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10822 record_runner_root_refusal(
10823 &host.paths,
10824 "broken",
10825 at,
10826 "denied_by_privacy_policy",
10827 "/Volumes/NVME/runners",
10828 "detail",
10829 )
10830 .expect("a writable record");
10831 record_runner_root_refusal(
10832 &host.paths,
10833 "also-broken",
10834 at,
10835 "not_writable",
10836 "/srv/other",
10837 "detail",
10838 )
10839 .expect("a writable record");
10840
10841 clear_runner_root_refusal(&host.paths, "healthy")
10843 .expect("clearing an absent policy is fine");
10844 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
10845
10846 let refusals = runner_root_refusals(&host.paths).expect("readable");
10847 assert_eq!(
10848 refusals
10849 .iter()
10850 .map(|r| r.policy.as_str())
10851 .collect::<Vec<_>>(),
10852 vec!["broken"],
10853 "the policy that is still refused must keep its record"
10854 );
10855 }
10856
10857 #[test]
10860 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
10861 let host = Host::new();
10862 record_runner_root_refusal(
10863 &host.paths,
10864 "p",
10865 Utc::now(),
10866 "not_writable",
10867 "/srv/x",
10868 "detail",
10869 )
10870 .expect("a writable record");
10871
10872 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
10873 assert!(
10874 !root_refusal_path(&host.paths).exists(),
10875 "a host with nothing refused leaves nothing behind"
10876 );
10877 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
10878 }
10879
10880 #[test]
10883 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
10884 let host = Host::new();
10885 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
10886 let error = runner_root_refusals(&host.paths)
10887 .expect_err("an unparseable record is not the same as no record");
10888 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10889 }
10890
10891 #[test]
10901 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
10902 let host = Host::new();
10903 record_runner_root_refusal(
10904 &host.paths,
10905 "policy-a",
10906 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
10907 "denied_by_privacy_policy",
10908 "/Volumes/NVME/runners",
10909 "Grant Full Disk Access to the program that runs the service",
10910 )
10911 .expect("a writable record");
10912
10913 let status = host.operations().status().expect("a readable status");
10914 let notes = status.notes().join("\n");
10915
10916 assert!(
10917 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
10918 "the directory and the remediation the log had to scrub must appear here: {notes}"
10919 );
10920 assert!(
10921 notes.contains("policy-a"),
10922 "the operator has to know which target placed no runner: {notes}"
10923 );
10924 assert!(
10925 !status
10926 .problems()
10927 .iter()
10928 .any(|problem| problem.subject == "runner root"),
10929 "a record nothing an operator types can clear must not drive the exit code"
10930 );
10931 }
10932
10933 #[test]
10939 fn install_records_the_absolute_binary_path_and_the_four_directories() {
10940 let host = Host::new();
10941 let installed = host
10942 .operations()
10943 .install(&host.request(StartMode::Boot))
10944 .expect("an install against the recording controls");
10945
10946 assert_eq!(installed.record.binary, host.binary);
10947 assert!(installed.record.binary.is_absolute());
10948 assert_eq!(installed.record.start_mode, StartMode::Boot);
10949 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
10950 assert_eq!(
10951 installed.record.directories,
10952 ServiceDirectories::of(&host.paths)
10953 );
10954 assert_eq!(
10955 installed.record.log_file,
10956 host.paths.logs_dir().join(LOG_FILE_STEM)
10957 );
10958 assert_eq!(installed.record.restart_delay_secs, 15);
10959
10960 let registrations = host.controls.registrations();
10961 assert_eq!(registrations.len(), 1);
10962 assert_eq!(registrations[0].0, StartMode::Boot);
10963 assert_eq!(registrations[0].1, "runner-manager");
10964 }
10965
10966 #[test]
10967 fn install_is_refused_while_the_single_instance_lock_is_held() {
10968 let host = Host::new();
10969 {
10971 let operations = host.operations();
10972 operations
10973 .install(&host.request(StartMode::Boot))
10974 .expect("an install with the lock free");
10975 operations.uninstall().expect("a clean slate");
10976 }
10977
10978 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
10979 .expect("this process takes the lock first");
10980
10981 let error = host
10982 .operations()
10983 .install(&host.request(StartMode::Boot))
10984 .expect_err("a second agent must not be registered while one is running");
10985 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
10986 assert!(
10987 error.to_string().contains("already running"),
10988 "the message must be actionable: {error}"
10989 );
10990 assert!(
10991 host.controls.registrations().is_empty(),
10992 "a refused install must register nothing"
10993 );
10994 assert_eq!(
10995 InstallRecord::read(&host.paths).expect("readable"),
10996 None,
10997 "a refused install must write no record"
10998 );
10999 }
11000
11001 #[test]
11009 fn installing_over_the_same_start_mode_replaces_the_registration() {
11010 let host = Host::new();
11011 let operations = host.operations();
11012 operations
11013 .install(&host.request(StartMode::Boot))
11014 .expect("the first install");
11015
11016 let again = operations
11017 .install(&host.request(StartMode::Boot))
11018 .expect("an install over the same mode replaces rather than refusing");
11019
11020 assert!(
11021 again.replaced_existing,
11022 "the operator is told this replaced something rather than made it"
11023 );
11024 assert_eq!(
11025 host.controls.registrations().len(),
11026 1,
11027 "replacing must not leave two registrations behind"
11028 );
11029 assert_eq!(
11030 InstallRecord::read(&host.paths)
11031 .expect("readable")
11032 .expect("a record")
11033 .start_mode,
11034 StartMode::Boot
11035 );
11036 }
11037
11038 #[test]
11042 fn installing_over_the_other_start_mode_is_refused() {
11043 let host = Host::new();
11044 let operations = host.operations();
11045 operations
11046 .install(&host.request(StartMode::Boot))
11047 .expect("the first install");
11048
11049 let error = operations
11050 .install(&host.request(StartMode::Login))
11051 .expect_err("a mode change is not an install");
11052 assert!(
11053 matches!(
11054 error,
11055 ServiceError::AlreadyInstalled {
11056 existing: StartMode::Boot,
11057 requested: StartMode::Login,
11058 ..
11059 }
11060 ),
11061 "{error}"
11062 );
11063 assert!(
11064 !error
11065 .to_string()
11066 .contains("switch the start mode in place,"),
11067 "the old remedy named a capability no command offers; the terminal UI is where \
11068 the start mode moves: {error}"
11069 );
11070 assert_eq!(host.controls.registrations().len(), 1);
11071 }
11072
11073 #[test]
11074 fn install_rolls_back_the_registration_when_record_persistence_fails() {
11075 let host = Host::new();
11076 let record_path = InstallRecord::path(&host.paths);
11077 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11078
11079 let error = host
11080 .operations()
11081 .install(&host.request(StartMode::Boot))
11082 .expect_err("record persistence must fail");
11083
11084 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11085 assert!(
11086 host.controls.registrations().is_empty(),
11087 "a failed install must not leave a live unrecorded registration"
11088 );
11089 assert!(
11090 host.controls
11091 .calls()
11092 .iter()
11093 .any(|call| call == "uninstall runner-manager (boot)"),
11094 "the registration must be explicitly rolled back: {:?}",
11095 host.controls.calls()
11096 );
11097 assert!(
11098 !host.runner_root.as_path().exists(),
11099 "the rollback must take the runner root this install created with it; a directory \
11100 prepared for a registration that does not exist is litter, and on Windows it is \
11101 litter with a security descriptor"
11102 );
11103 }
11104
11105 #[test]
11110 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11111 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11112
11113 assert_eq!(
11117 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11118 ServiceAccount::LocalSystem
11119 );
11120 assert_eq!(
11121 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11122 ServiceAccount::InvokingUser
11123 );
11124
11125 let boot = default_root_sddl(&RootAdmission::LocalSystem);
11126 assert!(!grants_broad_write(&boot), "{boot}");
11127 assert!(
11128 !boot.contains("S-1-5-21"),
11129 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11130 );
11131
11132 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11137 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11138 assert!(!grants_broad_write(&login), "{login}");
11139 }
11140
11141 #[test]
11142 fn an_install_reports_the_runner_root_it_prepared() {
11143 let host = Host::new();
11144 let installed = host
11145 .operations()
11146 .install(&host.request(StartMode::Boot))
11147 .expect("an install");
11148 let rendered = installed.runner_root.to_string();
11149 assert!(
11150 !rendered.contains("S-1-5-21"),
11151 "the report must add no identity to the output: {rendered}"
11152 );
11153 if cfg!(windows) {
11154 assert_eq!(
11155 installed.runner_root.path(),
11156 Some(host.runner_root.as_path())
11157 );
11158 assert!(
11159 host.runner_root.as_path().is_dir(),
11160 "the directory jobs would run in has to exist once the service is registered"
11161 );
11162 } else {
11163 assert_eq!(
11164 installed.runner_root,
11165 crate::runner_root_access::RootAccessSummary::NotApplicable,
11166 "macOS and Linux keep the runtime directory they have always used"
11167 );
11168 }
11169 }
11170
11171 #[test]
11172 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11173 let host = Host::new();
11174 let operations = host.operations();
11175 operations
11176 .install(&host.request(StartMode::Boot))
11177 .expect("an install at boot");
11178
11179 let change = operations
11180 .set_start_mode(StartMode::Login)
11181 .expect("a switch to login");
11182
11183 assert!(change.changed);
11184 if cfg!(windows) {
11185 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11186 assert!(
11187 host.runner_root.as_path().is_dir(),
11188 "the switch must not remove the directory it reconciled"
11189 );
11190 }
11191 assert!(
11194 change.to_string().contains("runner root"),
11195 "{}",
11196 change.to_string()
11197 );
11198 }
11199
11200 #[test]
11201 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11202 let host = Host::new();
11203 let operations = host.operations();
11204 operations
11205 .install(&host.request(StartMode::Boot))
11206 .expect("an install at boot");
11207
11208 let change = operations
11209 .set_start_mode(StartMode::Boot)
11210 .expect("a switch to the mode already in force");
11211
11212 assert!(!change.changed);
11213 assert_eq!(
11214 change.runner_root,
11215 crate::runner_root_access::RootAccessSummary::NotApplicable,
11216 "nothing moves, so nothing about the root's access control has to; reconciling here \
11217 would turn a no-op command into one that can fail on a permission it does not need"
11218 );
11219 }
11220
11221 #[cfg(windows)]
11222 #[test]
11223 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11224 let host = Host::new();
11225 host.controls
11226 .fail_next_install(StartMode::Boot, "injected registration failure");
11227
11228 let error = host
11229 .operations()
11230 .install(&host.request(StartMode::Boot))
11231 .expect_err("the manager refuses the registration");
11232
11233 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11234 assert!(
11235 !host.runner_root.as_path().exists(),
11236 "the directory was created for a registration that does not exist"
11237 );
11238 }
11239
11240 #[cfg(windows)]
11241 #[test]
11242 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11243 let host = Host::new();
11244 crate::runner_root_access::create_with_descriptor_for_tests(
11248 host.runner_root.as_path(),
11249 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11250 )
11251 .expect("a deliberately open runner root");
11252 let before = crate::runner_root_access::report(host.runner_root.as_path());
11253
11254 let error = host
11255 .operations()
11256 .install(&host.request(StartMode::Boot))
11257 .expect_err("an open runner root is refused");
11258
11259 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11260 assert!(
11261 error.to_string().contains("nothing was registered"),
11262 "{error}"
11263 );
11264 assert!(
11265 host.controls.registrations().is_empty(),
11266 "the refusal has to come before the platform is asked to register anything: {:?}",
11267 host.controls.calls()
11268 );
11269 assert_eq!(
11270 crate::runner_root_access::report(host.runner_root.as_path()),
11271 before,
11272 "an open directory is refused rather than tightened: its contents cannot be trusted, \
11273 so adopting it would be worse than declining it"
11274 );
11275 }
11276
11277 #[cfg(windows)]
11278 #[test]
11279 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11280 let host = Host::new();
11281 let operations = host.operations();
11282 operations
11283 .install(&host.request(StartMode::Boot))
11284 .expect("an install");
11285 assert!(host.runner_root.as_path().is_dir());
11286
11287 operations.uninstall().expect("an uninstall");
11288
11289 assert!(
11290 host.runner_root.as_path().is_dir(),
11291 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11292 runner root may hold an operator's retained workspaces."
11293 );
11294 }
11295
11296 #[test]
11297 fn install_reviews_what_it_registered() {
11298 let host = Host::new();
11299 let installed = host
11300 .operations()
11301 .install(&host.request(StartMode::Boot))
11302 .expect("an install");
11303 assert!(
11304 installed.review.is_least_privilege(),
11305 "{}",
11306 installed.review
11307 );
11308 assert!(
11309 !installed.review.controls().is_empty(),
11310 "a review that confirms nothing proves nothing: {}",
11311 installed.review
11312 );
11313 assert_eq!(
11314 installed.review.kind(),
11315 host_definition_kind(StartMode::Boot),
11316 "the review must be of the definition this host's manager was given"
11317 );
11318 assert!(
11319 !installed.review.account().justification().is_empty(),
11320 "a privileged account with no stated reason is an unreviewed one"
11321 );
11322 }
11323
11324 #[test]
11329 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11330 let host = Host::new();
11331 let operations = host.operations();
11332 operations
11333 .install(&host.request(StartMode::Boot))
11334 .expect("an install");
11335
11336 let config = host.paths.config_dir();
11340 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11341 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11342 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11343 std::fs::write(
11344 host.paths
11345 .state_dir()
11346 .join("packages/2.330.0/runner.tar.gz"),
11347 b"cached package",
11348 )
11349 .expect("writable");
11350 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11351 std::fs::write(
11352 host.paths.state_dir().join("secrets/user-access-token"),
11353 b"a stand-in for the stored credential",
11354 )
11355 .expect("writable");
11356 std::fs::write(
11357 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11358 b"diagnostics",
11359 )
11360 .expect("writable");
11361
11362 let roots: Vec<PathBuf> = host
11363 .paths
11364 .all()
11365 .iter()
11366 .map(|(_, path)| (*path).to_path_buf())
11367 .collect();
11368 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11369 let before = snapshot(&roots);
11370
11371 assert!(
11374 before.len() >= 6,
11375 "the fixture must actually contain the files this test is about, got {before:#?}"
11376 );
11377 let record_path = InstallRecord::path(&host.paths);
11378 assert!(
11379 before.contains_key(&record_path),
11380 "the install record must be present before uninstall"
11381 );
11382
11383 let uninstalled = operations.uninstall().expect("an uninstall");
11384 assert!(uninstalled.removed_registration);
11385 assert!(uninstalled.removed_record);
11386
11387 let after = snapshot(&roots);
11388
11389 let mut expected = before.clone();
11391 expected.remove(&record_path);
11392 assert_eq!(
11393 after, expected,
11394 "uninstall must remove its own record and nothing else"
11395 );
11396 assert!(
11397 !record_path.exists(),
11398 "the record itself must go, or `uninstall` did nothing at all"
11399 );
11400 assert!(
11401 uninstalled
11402 .preserved
11403 .iter()
11404 .all(|path| roots.contains(&path.as_path())),
11405 "the preserved list must name the four directories: {uninstalled}"
11406 );
11407 }
11408
11409 #[test]
11410 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11411 let host = Host::new();
11412 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11413 assert!(!uninstalled.removed_registration);
11414 assert!(!uninstalled.removed_record);
11415 }
11416
11417 #[test]
11418 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11419 let host = Host::new();
11420 let operations = host.operations();
11421 operations
11422 .install(&host.request(StartMode::Boot))
11423 .expect("an install");
11424 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11425
11426 let uninstalled = operations.uninstall().expect("an uninstall");
11427 assert!(
11428 uninstalled.removed_registration,
11429 "a lost record must not strand a registration"
11430 );
11431 assert!(host.controls.registrations().is_empty());
11432 }
11433
11434 #[test]
11439 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11440 let host = Host::new();
11441 let operations = host.operations();
11442 operations
11443 .install(&host.request(StartMode::Boot))
11444 .expect("an install at boot");
11445
11446 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11450
11451 let change = operations
11452 .set_start_mode(StartMode::Login)
11453 .expect("a switch that does not reinstall the product");
11454 assert!(change.changed);
11455 assert_eq!(change.from, StartMode::Boot);
11456 assert_eq!(change.to, StartMode::Login);
11457 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11458
11459 let record = InstallRecord::read(&host.paths)
11460 .expect("readable")
11461 .expect("a record");
11462 assert_eq!(record.start_mode, StartMode::Login);
11463 assert_eq!(
11464 record.binary, host.binary,
11465 "the recorded path must survive the switch untouched"
11466 );
11467
11468 let registrations = host.controls.registrations();
11469 assert_eq!(registrations.len(), 1, "{registrations:?}");
11470 assert_eq!(registrations[0].0, StartMode::Login);
11471 assert!(
11472 registrations[0]
11473 .2
11474 .command_line
11475 .contains(&host.binary.to_string_lossy().into_owned()),
11476 "{:?}",
11477 registrations[0].2
11478 );
11479 }
11480
11481 #[test]
11482 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11483 let host = Host::new();
11484 let operations = host.operations();
11485 operations
11486 .install(&host.request(StartMode::Boot))
11487 .expect("an install at boot");
11488 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11489 host.controls
11490 .fail_next_install(StartMode::Login, "injected target failure");
11491
11492 let error = operations
11493 .set_start_mode(StartMode::Login)
11494 .expect_err("the target manager refuses the install");
11495
11496 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11497 assert_eq!(
11498 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11499 record_before
11500 );
11501 let registrations = host.controls.registrations();
11502 assert_eq!(registrations.len(), 1, "{registrations:?}");
11503 assert_eq!(registrations[0].0, StartMode::Boot);
11504 }
11505
11506 #[test]
11507 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11508 let host = Host::new();
11509 let operations = host.operations();
11510 operations
11511 .install(&host.request(StartMode::Boot))
11512 .expect("an install at boot");
11513 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11514 let config = host.paths.config_dir().to_path_buf();
11515 let hidden = config.with_file_name("config-hidden-by-fault");
11516 host.controls.hide_directory_after_install(
11517 StartMode::Login,
11518 config.clone(),
11519 hidden.clone(),
11520 );
11521
11522 let error = operations
11523 .set_start_mode(StartMode::Login)
11524 .expect_err("the injected filesystem fault prevents persistence");
11525
11526 std::fs::remove_file(&config).expect("remove the injected blocker");
11527 std::fs::rename(&hidden, &config).expect("restore the record directory");
11528 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11529 assert_eq!(
11530 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11531 record_before
11532 );
11533 let registrations = host.controls.registrations();
11534 assert_eq!(registrations.len(), 1, "{registrations:?}");
11535 assert_eq!(registrations[0].0, StartMode::Boot);
11536 assert!(
11537 host.controls
11538 .calls()
11539 .iter()
11540 .any(|call| call == "uninstall runner-manager (login)"),
11541 "the target must be rolled back: {:?}",
11542 host.controls.calls()
11543 );
11544 }
11545
11546 #[test]
11547 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11548 let host = Host::new();
11549 let operations = host.operations();
11550 operations
11551 .install(&host.request(StartMode::Boot))
11552 .expect("an install");
11553 let before = host.controls.calls().len();
11554
11555 let change = operations
11556 .set_start_mode(StartMode::Boot)
11557 .expect("a no-op switch");
11558 assert!(!change.changed);
11559 assert_eq!(
11560 host.controls.calls().len(),
11561 before,
11562 "a no-op switch must not touch the service manager"
11563 );
11564 }
11565
11566 #[test]
11567 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11568 let host = Host::new();
11569 let error = host
11570 .operations()
11571 .set_start_mode(StartMode::Login)
11572 .expect_err("there is nothing to switch");
11573 assert!(
11574 matches!(error, ServiceError::NotInstalled { .. }),
11575 "{error}"
11576 );
11577 }
11578
11579 #[test]
11584 fn status_reports_the_four_facts_journey_five_asks_for() {
11585 let host = Host::new();
11586 let operations = host.operations();
11587 operations
11588 .install(&host.request(StartMode::Boot))
11589 .expect("an install");
11590 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
11591 .expect("a valid timestamp")
11592 .with_timezone(&Utc);
11593 record_github_contact(&host.paths, at).expect("a heartbeat");
11594
11595 let status = operations.status().expect("a status");
11596 assert_eq!(status.start_mode(), Some(StartMode::Boot));
11597 assert_eq!(
11598 status.binary().map(BinaryPath::recorded),
11599 Some(host.binary.as_path())
11600 );
11601 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
11602 assert_eq!(status.last_github_contact(), Some(at));
11603 assert!(status.is_installed());
11604 assert!(status.is_healthy(), "{status}");
11605
11606 let printed = status.to_string();
11607 for fragment in [
11608 "start mode",
11609 "diagnostic log",
11610 "last GitHub contact",
11611 "binary",
11612 ] {
11613 assert!(printed.contains(fragment), "{printed}");
11614 }
11615 }
11616
11617 #[cfg(unix)]
11629 #[test]
11630 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
11631 use std::os::unix::fs::PermissionsExt as _;
11632
11633 if unsafe { libc::geteuid() } == 0 {
11637 return;
11638 }
11639
11640 let host = Host::new();
11641 let operations = host.operations();
11642 operations
11643 .install(&host.request(StartMode::Boot))
11644 .expect("an install");
11645 assert!(
11646 operations.status().expect("a status").is_healthy(),
11647 "the discriminator: healthy before the record is made unreadable"
11648 );
11649
11650 let record = InstallRecord::path(&host.paths);
11651 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
11652 .expect("the mode is applied");
11653
11654 let status = operations
11655 .status()
11656 .expect("a record this account may not read is reported, not thrown");
11657 assert!(status.is_installed(), "{status}");
11658 assert!(!status.is_healthy(), "{status}");
11659
11660 let printed = status.to_string();
11661 assert!(
11662 printed.contains("this account may not read it"),
11663 "the operator is told which of the two states this is: {printed}"
11664 );
11665 assert!(
11666 !printed.contains("there is no install record"),
11667 "a record that is there and unreadable is not a record that is missing, and the \
11668 missing one's remedy starts with `service uninstall`: {printed}"
11669 );
11670
11671 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
11674 .expect("the mode is restored");
11675 }
11676
11677 #[test]
11678 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
11679 let host = Host::new();
11680 let operations = host.operations();
11681 operations
11682 .install(&host.request(StartMode::Boot))
11683 .expect("an install");
11684
11685 assert!(
11688 operations.status().expect("a status").is_healthy(),
11689 "the freshly installed host must be healthy"
11690 );
11691
11692 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
11693
11694 let status = operations.status().expect("a status");
11695 assert!(!status.is_healthy(), "{status}");
11696 assert!(
11697 status
11698 .problems()
11699 .iter()
11700 .any(|problem| problem.subject == "binary"),
11701 "{status}"
11702 );
11703 assert!(status.to_string().contains("STALE"), "{status}");
11704 }
11705
11706 #[test]
11707 fn status_reports_a_registration_that_would_not_start_at_boot() {
11708 let host = Host::new();
11709 let operations = host.operations();
11710 operations
11711 .install(&host.request(StartMode::Boot))
11712 .expect("an install");
11713 assert!(operations.status().expect("a status").is_healthy());
11714
11715 host.controls.edit("runner-manager", |registration| {
11716 registration.starts_automatically = false;
11717 });
11718
11719 let status = operations.status().expect("a status");
11720 assert!(!status.is_healthy(), "{status}");
11721 assert!(
11722 status
11723 .problems()
11724 .iter()
11725 .any(|problem| problem.detail.contains("after a reboot")),
11726 "{status}"
11727 );
11728 }
11729
11730 #[test]
11731 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
11732 let host = Host::new();
11733 let operations = host.operations();
11734 operations
11735 .install(&host.request(StartMode::Login))
11736 .expect("install and immediate start");
11737
11738 assert!(
11739 operations.status().expect("running status").is_running(),
11740 "install must not wait for the next login trigger"
11741 );
11742 operations.stop().expect("stop the registration");
11743 let status = operations.status().expect("stopped status");
11744 assert!(!status.is_healthy(), "{status}");
11745 assert!(
11746 status
11747 .problems()
11748 .iter()
11749 .any(|problem| problem.subject == "runtime"),
11750 "{status}"
11751 );
11752 let rendered = status.to_string();
11753 assert!(
11754 rendered.contains("runner-manager service install --start-at login"),
11755 "{rendered}"
11756 );
11757 assert!(
11758 !rendered.contains("runner-manager service start"),
11759 "{rendered}"
11760 );
11761 }
11762
11763 #[test]
11764 fn status_reports_a_restart_policy_something_else_edited() {
11765 let host = Host::new();
11766 let operations = host.operations();
11767 operations
11768 .install(&host.request(StartMode::Boot))
11769 .expect("an install");
11770 assert!(operations.status().expect("a status").is_healthy());
11771
11772 host.controls.edit("runner-manager", |registration| {
11773 registration.restart_delay = Some(Duration::from_secs(1));
11774 });
11775
11776 let status = operations.status().expect("a status");
11777 assert!(!status.is_healthy(), "{status}");
11778 assert!(
11779 status
11780 .problems()
11781 .iter()
11782 .any(|problem| problem.subject == "restart policy"),
11783 "{status}"
11784 );
11785 }
11786
11787 #[test]
11788 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
11789 let host = Host::new();
11790 let operations = host.operations();
11791 operations
11792 .install(&host.request(StartMode::Boot))
11793 .expect("an install");
11794 let other = host.binary.with_file_name("someone-elses.exe");
11795 std::fs::write(&other, b"x").expect("writable");
11796
11797 host.controls.edit("runner-manager", |registration| {
11798 registration.command_line = quote_argument(&other.to_string_lossy());
11799 });
11800
11801 let status = operations.status().expect("a status");
11802 assert!(!status.is_healthy(), "{status}");
11803 assert!(
11804 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11805 "{status}"
11806 );
11807 }
11808
11809 #[test]
11810 fn status_reports_a_record_no_service_manager_knows_about() {
11811 let host = Host::new();
11812 let operations = host.operations();
11813 operations
11814 .install(&host.request(StartMode::Boot))
11815 .expect("an install");
11816 for mode in [StartMode::Boot, StartMode::Login] {
11818 host.controls
11819 .control(mode)
11820 .expect("a control")
11821 .uninstall(&ServiceIdentity::product())
11822 .expect("removed");
11823 }
11824
11825 let status = operations.status().expect("a status");
11826 assert!(!status.is_healthy(), "{status}");
11827 assert!(
11828 status
11829 .problems()
11830 .iter()
11831 .any(|problem| problem.subject == "registration"),
11832 "{status}"
11833 );
11834 }
11835
11836 #[test]
11837 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
11838 let host = Host::new();
11839 let status = host.operations().status().expect("a status");
11840 assert!(!status.is_installed());
11841 assert!(
11842 status.is_healthy(),
11843 "a host that never installed the service has no fault to report: {status}"
11844 );
11845 assert!(status.to_string().contains("installed"), "{status}");
11846 }
11847
11848 #[test]
11849 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
11850 let host = Host::new();
11851 let operations = host.operations();
11852 operations
11853 .install(&host.request(StartMode::Login))
11854 .expect("an install at login");
11855 let status = operations.status().expect("a status");
11856 assert!(
11857 status
11858 .notes()
11859 .iter()
11860 .any(|note| note.contains("does not run until the operator signs in")),
11861 "05-infrastructure.md requires `service status` to say so: {status}"
11862 );
11863 }
11864
11865 #[test]
11868 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
11869 use crate::unattended_login::{AutoLogin, UnattendedLogin};
11870 let automatic = UnattendedLogin {
11871 auto_login: AutoLogin::As("ivan".into()),
11872 filevault_on: Some(false),
11873 };
11874 assert_eq!(
11875 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
11876 None
11877 );
11878
11879 let off = UnattendedLogin {
11880 auto_login: AutoLogin::Off,
11881 filevault_on: Some(false),
11882 };
11883 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
11884 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
11885 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
11886
11887 let locked = UnattendedLogin {
11888 auto_login: AutoLogin::As("ivan".into()),
11889 filevault_on: Some(true),
11890 };
11891 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
11892 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
11893
11894 let unread = UnattendedLogin {
11897 auto_login: AutoLogin::As("ivan".into()),
11898 filevault_on: None,
11899 };
11900 assert_eq!(
11901 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
11902 None
11903 );
11904
11905 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
11907 assert!(
11908 gap.reason
11909 .contains("does not run until the operator signs in")
11910 );
11911 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
11912 }
11913
11914 #[test]
11917 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
11918 let host = Host::new();
11919 let operations = host.operations();
11920 operations
11921 .install(&host.request(StartMode::Login))
11922 .expect("an install at login");
11923 let stalled_problem = |operations: &ServiceOperations| {
11924 operations
11925 .status()
11926 .expect("a status")
11927 .problems()
11928 .iter()
11929 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
11930 .map(|problem| problem.detail.clone())
11931 };
11932 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
11933 assert_eq!(stalled_problem(&operations), None, "beating");
11934
11935 crate::daemon_heartbeat::beat(
11936 &operations.paths,
11937 Utc::now() - chrono::Duration::minutes(20),
11938 )
11939 .unwrap();
11940 let detail = stalled_problem(&operations).expect("a stall is a problem");
11941 assert!(detail.contains("has made no progress since"), "{detail}");
11942 let restart = ServiceIdentity::product().restart_command(StartMode::Login);
11945 assert!(detail.contains(&restart), "{detail}");
11946 assert!(!detail.contains("runner-manager service stop"), "{detail}");
11947 assert!(!detail.contains("runner-manager service start"), "{detail}");
11948 }
11949
11950 #[test]
11954 fn the_restart_command_is_the_service_managers_own() {
11955 let identity = ServiceIdentity::product();
11956 let boot = identity.restart_command(StartMode::Boot);
11957 let login = identity.restart_command(StartMode::Login);
11958 for command in [&boot, &login] {
11959 assert!(!command.contains("runner-manager service"), "{command}");
11960 assert!(command.contains(identity.name()), "{command}");
11961 }
11962 assert_ne!(boot, login);
11963 if cfg!(windows) {
11964 assert!(
11965 boot.starts_with("Restart-Service -Name runner-manager"),
11966 "{boot}"
11967 );
11968 assert!(
11969 login.contains("schtasks /Run /TN runner-manager"),
11970 "{login}"
11971 );
11972 } else if cfg!(target_os = "macos") {
11973 assert!(
11974 boot.starts_with("sudo launchctl kickstart -k system/"),
11975 "{boot}"
11976 );
11977 assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
11978 } else {
11979 assert_eq!(boot, "sudo systemctl restart runner-manager.service");
11980 assert_eq!(login, "systemctl --user restart runner-manager.service");
11981 }
11982 }
11983
11984 #[test]
11986 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
11987 let host = Host::new();
11988 let operations = host.operations();
11989 operations
11990 .install(&host.request(StartMode::Login))
11991 .expect("an install at login");
11992 let paths = operations.paths.clone();
11993 crate::host_fitness::record_host_unfit(
11994 &paths,
11995 &[crate::host_fitness::UnfitFinding {
11996 id: "host.runner_root_responsive".into(),
11997 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
11998 remedy: Some("click Allow".into()),
11999 }],
12000 Utc::now(),
12001 )
12002 .unwrap();
12003 let status = operations.status().expect("a status");
12004 assert!(!status.is_healthy(), "{status}");
12005 let problem = status
12006 .problems()
12007 .iter()
12008 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
12009 .expect("the refusal is a problem");
12010 assert!(
12011 problem.detail.contains("/Volumes/NVME/runners"),
12012 "{problem:?}"
12013 );
12014 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
12015
12016 crate::host_fitness::clear_host_unfit(&paths).unwrap();
12019 crate::host_fitness::record_host_unfit(
12020 &paths,
12021 &[],
12022 Utc::now() - chrono::Duration::hours(2),
12023 )
12024 .unwrap();
12025 let status = operations.status().expect("a status");
12026 assert!(
12027 !status
12028 .problems()
12029 .iter()
12030 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
12031 "{status}"
12032 );
12033 }
12034
12035 #[test]
12040 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
12041 let host = Host::new();
12042 let operations = host.operations();
12043 operations
12044 .install(&host.request(StartMode::Login))
12045 .expect("an install at login");
12046 operations.start().expect("a start");
12047 assert!(operations.status().expect("a status").is_running());
12048 assert!(operations.stop().expect("a stop"));
12049 assert!(!operations.status().expect("a status").is_running());
12050 }
12051
12052 #[test]
12053 fn starting_a_host_with_no_registration_is_refused() {
12054 let host = Host::new();
12055 let error = host.operations().start().expect_err("nothing to start");
12056 assert!(
12057 matches!(error, ServiceError::NotInstalled { .. }),
12058 "{error}"
12059 );
12060 }
12061
12062 #[cfg(windows)]
12078 #[test]
12079 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12080 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12081
12082 let root = tempfile::tempdir().expect("a temporary directory");
12083 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12084 .expect("a rooted machine-scoped store");
12085 store
12086 .store(&secrecy::SecretString::from("a stand-in for the token"))
12087 .expect("the store accepts a value");
12088 let protection = store.protection().expect("the DACL can be read back");
12089
12090 assert!(
12091 protection.description().contains(";;;SY)"),
12092 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12093 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12094 protection.description()
12095 );
12096 assert_eq!(
12097 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12098 ServiceAccount::LocalSystem,
12099 "and that is the account this installer registers, which is why SY is what matters"
12100 );
12101 assert!(
12102 !protection.readable_by_other_local_users(),
12103 "the same DACL must still exclude ordinary local users: {}",
12104 protection.description()
12105 );
12106
12107 for rejected in [";;;LS)", ";;;NS)"] {
12111 assert!(
12112 !protection.description().contains(rejected),
12113 "if the store ever admitted {rejected}, the least-privilege analysis in \
12114 docs/service-account.md would need redoing: {}",
12115 protection.description()
12116 );
12117 }
12118 }
12119
12120 fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12125 let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12126 arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12127 host.request(mode).with_arguments(arguments)
12128 }
12129
12130 fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12133 ServiceOperations::with_controls(
12134 account.paths.clone(),
12135 ServiceIdentity::product(),
12136 std::sync::Arc::new(machine.controls.clone()),
12137 )
12138 .with_runner_root(account.runner_root.clone())
12139 }
12140
12141 fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12145 let owner = Host::new();
12146 owner
12147 .operations()
12148 .install(&request_with_directories(&owner, StartMode::Boot))
12149 .expect("the owner's boot install");
12150 let earlier = Host::new();
12151 earlier
12152 .operations()
12153 .install(&request_with_directories(&earlier, StartMode::Login))
12154 .expect("the second account's earlier login install");
12155 assert!(
12156 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12157 "the earlier install left its record behind"
12158 );
12159 (owner, earlier)
12160 }
12161
12162 const DESTRUCTIVE_ADVICE: [&str; 4] = [
12165 "service uninstall",
12166 "service install`",
12167 "service install --",
12168 "auth login",
12169 ];
12170
12171 #[test]
12172 fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12173 let (owner, earlier) = a_machine_with_a_stale_second_account();
12174
12175 let status = second_account(&earlier, &owner).status().expect("a status");
12176
12177 let foreign = status.foreign().expect("the service is the owner's");
12178 assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12179 assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12180 assert!(foreign.unreadable.is_none(), "{status}");
12181 assert!(
12182 status.is_healthy(),
12183 "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12184 {status}"
12185 );
12186 for subject in [
12187 "binary",
12188 "start mode",
12189 "secret store",
12190 "record",
12191 "registration",
12192 ] {
12193 assert!(
12194 !status
12195 .problems()
12196 .iter()
12197 .any(|problem| problem.subject == subject),
12198 "{subject} compared the second account's record against the owner's \
12199 registration: {status}"
12200 );
12201 }
12202 let rendered = status.to_string();
12203 for advice in DESTRUCTIVE_ADVICE {
12204 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12205 }
12206 assert!(rendered.contains("another installation's"), "{rendered}");
12207 assert!(
12208 status
12209 .notes()
12210 .iter()
12211 .any(|note| note.contains("unused local configuration")
12212 && note.contains("host forget-local")),
12213 "{status}"
12214 );
12215 assert_eq!(status.start_mode(), Some(StartMode::Boot));
12217 }
12218
12219 #[test]
12220 fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12221 let (owner, earlier) = a_machine_with_a_stale_second_account();
12222 InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12225
12226 let operations = second_account(&earlier, &owner);
12227 let status = operations.status().expect("a status");
12228 let foreign = status.foreign().expect("the service is the owner's");
12229 assert!(foreign.unreadable.is_some(), "{status}");
12230 assert!(
12231 status.is_healthy(),
12232 "running, and nothing it could judge: {status}"
12233 );
12234 let rendered = status.to_string();
12235 for advice in DESTRUCTIVE_ADVICE {
12236 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12237 }
12238 assert!(rendered.contains("running; installed from"), "{rendered}");
12239 assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12240
12241 owner.operations().stop().expect("stopped");
12243 let stopped = operations.status().expect("a status");
12244 assert!(!stopped.is_healthy(), "{stopped}");
12245 let rendered = stopped.to_string();
12246 for advice in DESTRUCTIVE_ADVICE {
12247 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12248 }
12249 }
12250
12251 #[test]
12252 fn this_accounts_own_registration_still_reports_every_mismatch() {
12253 let host = Host::new();
12254 let operations = host.operations();
12255 operations
12256 .install(&request_with_directories(&host, StartMode::Boot))
12257 .expect("an install");
12258 let other = host.binary.with_file_name("someone-elses");
12259 std::fs::write(&other, b"x").expect("writable");
12260 host.controls.edit("runner-manager", |registration| {
12262 let mut arguments = split_command_line(®istration.command_line);
12263 arguments[0] = other.to_string_lossy().into_owned();
12264 registration.command_line = arguments
12265 .iter()
12266 .map(|argument| quote_argument(argument))
12267 .collect::<Vec<_>>()
12268 .join(" ");
12269 registration.start_mode = StartMode::Login;
12270 });
12271
12272 let status = operations.status().expect("a status");
12273 assert!(status.foreign().is_none(), "{status}");
12274 assert!(
12275 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12276 "{status}"
12277 );
12278 assert!(
12279 status
12280 .problems()
12281 .iter()
12282 .any(|problem| problem.subject == "start mode"),
12283 "{status}"
12284 );
12285 assert!(status.to_string().contains("service uninstall"), "{status}");
12286 }
12287
12288 #[test]
12289 fn directories_read_back_from_a_command_line_with_spaces() {
12290 let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12291 let directories = ServiceDirectories {
12292 config: root.join("config"),
12293 state: root.join("data").join("state"),
12294 runtime: root.join("data").join("runtime"),
12295 logs: root.join("data").join("logs"),
12296 };
12297 let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12298 for argument in ["daemon", "run"]
12299 .map(OsString::from)
12300 .into_iter()
12301 .chain(directories.arguments())
12302 {
12303 command_line.push(' ');
12304 command_line.push_str("e_argument(&argument.to_string_lossy()));
12305 }
12306 assert_eq!(
12307 ServiceDirectories::from_command_line(&command_line),
12308 Some(directories.clone())
12309 );
12310 assert_eq!(directories.root(), root);
12311 assert_eq!(
12312 ServiceDirectories::from_command_line("runner-manager daemon run"),
12313 None,
12314 "a registration that names no directories cannot be judged foreign"
12315 );
12316 }
12317
12318 #[test]
12319 fn the_owner_of_a_data_root_is_read_from_its_path() {
12320 let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12321 let home = users.join("ivand");
12322 assert_eq!(
12323 data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12324 Some("yuriv")
12325 );
12326 assert_eq!(
12327 data_root_owner(
12328 Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12329 Some(&home)
12330 ),
12331 None
12332 );
12333 if cfg!(windows) {
12334 assert_eq!(
12335 data_root_owner(
12336 Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12337 Some(&home)
12338 )
12339 .as_deref(),
12340 Some("LocalSystem")
12341 );
12342 } else {
12343 assert_eq!(
12344 data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12345 Some("root")
12346 );
12347 }
12348 }
12349
12350 #[test]
12351 fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12352 let (owner, earlier) = a_machine_with_a_stale_second_account();
12353 let owner_before = snapshot(&[owner._root.path()]);
12354 let config = earlier.paths.config_dir().to_path_buf();
12355 let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12356
12357 let forgotten = second_account(&earlier, &owner)
12358 .forget_local(Utc::now())
12359 .expect("archived");
12360
12361 assert_eq!(
12362 snapshot(&[owner._root.path()]),
12363 owner_before,
12364 "the owner's directories must not change by one byte"
12365 );
12366 let archive = forgotten.archive.expect("something was archived");
12367 assert!(archive.starts_with(&config), "{}", archive.display());
12368 assert_eq!(
12369 std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12370 record,
12371 "moved, not rewritten or deleted"
12372 );
12373 assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12374 assert_eq!(
12375 forgotten.service_root,
12376 Some(ServiceDirectories::of(&owner.paths).root())
12377 );
12378 let status = second_account(&earlier, &owner).status().expect("status");
12380 assert!(
12381 !status
12382 .notes()
12383 .iter()
12384 .any(|note| note.contains("unused local configuration")),
12385 "{status}"
12386 );
12387 }
12388
12389 #[test]
12390 fn forget_local_refuses_a_configuration_a_service_runs_against() {
12391 let (owner, earlier) = a_machine_with_a_stale_second_account();
12392 let before = snapshot(&[owner._root.path()]);
12393 let refused = owner.operations().forget_local(Utc::now());
12394 assert!(
12395 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12396 "{refused:?}"
12397 );
12398 assert_eq!(snapshot(&[owner._root.path()]), before);
12399
12400 let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12402 .expect("the agent's lock");
12403 let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12404 assert!(
12405 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12406 "{refused:?}"
12407 );
12408 assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12409 }
12410
12411 #[test]
12412 fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12413 let (owner, earlier) = a_machine_with_a_stale_second_account();
12414 owner.operations().stop().expect("stopped");
12417 record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12418 record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12419
12420 let status = second_account(&earlier, &owner).status().expect("a status");
12421 assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12422 let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12423 for subject in [
12424 "runtime",
12425 "github credential",
12426 CREDENTIAL_UNREADABLE_SUBJECT,
12427 ] {
12428 assert!(subjects.contains(&subject), "{subject}: {status}");
12429 }
12430 let rendered = status.to_string();
12431 for advice in DESTRUCTIVE_ADVICE {
12432 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12433 }
12434 for problem in status.problems() {
12435 assert!(problem.detail.contains("Act on it"), "{problem}");
12436 }
12437 }
12438
12439 #[test]
12440 fn no_command_of_this_account_changes_another_installations_service() {
12441 let (owner, earlier) = a_machine_with_a_stale_second_account();
12442 let operations = second_account(&earlier, &owner);
12443 let foreign = |result: Result<(), ServiceError>| {
12444 assert!(
12445 matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12446 "{result:?}"
12447 );
12448 };
12449 foreign(
12450 operations
12451 .install(&request_with_directories(&earlier, StartMode::Boot))
12452 .map(drop),
12453 );
12454 foreign(operations.uninstall().map(drop));
12455 foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12456 foreign(operations.start());
12457 foreign(operations.stop().map(drop));
12458
12459 let registration = owner
12460 .controls
12461 .control(StartMode::Boot)
12462 .unwrap()
12463 .query(&ServiceIdentity::product())
12464 .unwrap()
12465 .expect("the owner's registration is still there");
12466 assert_eq!(
12467 ServiceDirectories::from_command_line(®istration.command_line),
12468 Some(ServiceDirectories::of(&owner.paths)),
12469 "and still runs against the owner's directories"
12470 );
12471 assert!(registration.running, "and was never stopped");
12472 assert!(
12473 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12474 "the refused uninstall kept this account's record"
12475 );
12476 }
12477
12478 #[test]
12479 fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12480 let root = tempfile::tempdir().unwrap();
12483 let config = root.path().join("Application Support");
12484 let paths = AppPaths::from_directories(
12485 &config,
12486 config.join("state"),
12487 config.join("runtime"),
12488 config.join("logs"),
12489 );
12490 paths.create_all().unwrap();
12491 std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12492 std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12493 let operations = ServiceOperations::with_controls(
12494 paths.clone(),
12495 ServiceIdentity::product(),
12496 std::sync::Arc::new(RecordingControls::new()),
12497 );
12498
12499 let forgotten = operations.forget_local(Utc::now()).expect("archived");
12500
12501 assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12502 for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12503 assert!(kept.is_dir(), "{} was moved", kept.display());
12504 }
12505 assert!(paths.logs_dir().join("runner-manager.log").is_file());
12506 }
12507
12508 #[test]
12509 fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12510 for (detail, diagnosis) in [
12511 (
12512 "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12513 service install --start-at boot`; if the service manager denies replacement",
12514 "x holds an automatic registration, but the daemon is stopped",
12515 ),
12516 (
12517 "store -- MISMATCH. Run `auth login` again so the token is stored",
12518 "store -- MISMATCH",
12519 ),
12520 (
12521 "unit differs, so the daemon runs under outdated settings. Re-register it with \
12522 `sudo runner-manager service install --start-at boot`",
12523 "unit differs, so the daemon runs under outdated settings",
12524 ),
12525 (
12526 "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12527 "it starts no runner; the log has it",
12528 ),
12529 (
12530 "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12531 install`",
12532 "C:\\a\\runner-manager.exe -- STALE: moved",
12533 ),
12534 (
12535 "a new builder: run `runner-manager service install` here, or not",
12536 "the installation's own records report this",
12537 ),
12538 ] {
12539 assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12540 }
12541 }
12542}