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; 13] = [
1766 "NoNewPrivileges=yes",
1767 "CapabilityBoundingSet=",
1768 "AmbientCapabilities=",
1769 "PrivateTmp=yes",
1770 "PrivateDevices=yes",
1771 "ProtectSystem=strict",
1772 "ProtectKernelTunables=yes",
1773 "ProtectKernelModules=yes",
1774 "ProtectControlGroups=yes",
1775 "RestrictNamespaces=yes",
1776 "RestrictRealtime=yes",
1777 "RestrictSUIDSGID=yes",
1778 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1779];
1780
1781fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1789 let mut directives = SYSTEMD_HARDENING.to_vec();
1790 if mode == StartMode::Boot {
1791 directives.push("ProtectHome=tmpfs");
1792 }
1793 directives
1794}
1795
1796#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1799const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1800
1801#[cfg(any(all(test, unix), target_os = "linux"))]
1804fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1805 let occupied = match std::fs::read_dir(path) {
1806 Ok(mut entries) => entries.next().is_some(),
1807 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1808 Err(error) => return Err(error),
1809 };
1810 if occupied {
1811 let name = path.file_name().map_or_else(
1812 || std::borrow::Cow::Borrowed("directory"),
1813 |name| name.to_string_lossy(),
1814 );
1815 std::fs::rename(
1816 path,
1817 path.with_file_name(format!("{name}.before-move-{stamp}")),
1818 )?;
1819 }
1820 Ok(())
1821}
1822
1823#[cfg(any(all(test, unix), target_os = "linux"))]
1827fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
1828 use std::os::unix::fs::DirBuilderExt as _;
1829
1830 std::fs::DirBuilder::new()
1831 .recursive(true)
1832 .mode(0o700)
1833 .create(to)?;
1834 for entry in std::fs::read_dir(from)? {
1835 let entry = entry?;
1836 if skip.is_some_and(|skip| entry.file_name() == skip) {
1837 continue;
1838 }
1839 let source = entry.path();
1840 let target = to.join(entry.file_name());
1841 let kind = entry.file_type()?;
1842 if kind.is_symlink() {
1843 std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
1844 } else if kind.is_dir() {
1845 copy_tree(&source, &target, None)?;
1846 } else {
1847 std::fs::copy(&source, &target)?;
1848 }
1849 }
1850 Ok(())
1851}
1852
1853#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1860fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
1861 if let Some(root) = SYSTEMD_HOME_ROOTS
1862 .iter()
1863 .find(|root| path.starts_with(root))
1864 {
1865 return Some(format!(
1866 "{} is under {root}, which a boot service cannot see",
1867 path.display()
1868 ));
1869 }
1870 match owner {
1871 Some(0) => None,
1872 Some(uid) => Some(format!(
1873 "{} belongs to uid {uid}, and a boot service runs as root with no capability \
1874 to open another account's directory",
1875 path.display()
1876 )),
1877 None => Some(format!("{} cannot be inspected", path.display())),
1878 }
1879}
1880
1881pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1905
1906#[must_use]
1913pub fn launchd_plist(plan: &InstallPlan) -> String {
1914 let identity = plan.identity();
1915 let directories = plan.directories();
1916 let mut out = String::new();
1917 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1918 out.push_str(
1919 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1920 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1921 );
1922 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1923 out.push_str(&plist_string("Label", &identity.launchd_label()));
1924
1925 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1926 out.push_str(&format!(
1927 " <string>{}</string>\n",
1928 xml_escape(&plan.binary().to_string_lossy())
1929 ));
1930 for argument in plan.arguments() {
1931 out.push_str(&format!(
1932 " <string>{}</string>\n",
1933 xml_escape(&argument.to_string_lossy())
1934 ));
1935 }
1936 out.push_str(" </array>\n");
1937
1938 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1939 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1940 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1941 out.push_str(" </dict>\n");
1942 out.push_str(&format!(
1943 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1944 plan.restart().delay().as_secs()
1945 ));
1946 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1947 out.push_str(&plist_string(
1948 "WorkingDirectory",
1949 &directories.state.to_string_lossy(),
1950 ));
1951 out.push_str(&plist_string(
1952 "StandardOutPath",
1953 &directories
1954 .logs
1955 .join("runner-manager.launchd.out.log")
1956 .to_string_lossy(),
1957 ));
1958 out.push_str(&plist_string(
1959 "StandardErrorPath",
1960 &directories
1961 .logs
1962 .join("runner-manager.launchd.err.log")
1963 .to_string_lossy(),
1964 ));
1965
1966 match plan.start_mode() {
1967 StartMode::Boot => {
1968 out.push_str(&plist_string(
1971 "UserName",
1972 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1973 .as_str(),
1974 ));
1975 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1977 }
1978 StartMode::Login => {
1979 }
1983 }
1984
1985 out.push_str("</dict>\n</plist>\n");
1986 out
1987}
1988
1989fn plist_string(key: &str, value: &str) -> String {
1991 format!(
1992 " <key>{}</key>\n <string>{}</string>\n",
1993 xml_escape(key),
1994 xml_escape(value)
1995 )
1996}
1997
1998#[derive(Debug, Clone, PartialEq, Eq)]
2006pub struct TaskPrincipal {
2007 user_id: String,
2008}
2009
2010impl TaskPrincipal {
2011 pub fn current() -> Result<Self, ServiceError> {
2019 let user = std::env::var("USERNAME")
2020 .ok()
2021 .filter(|value| !value.trim().is_empty());
2022 let Some(user) = user else {
2023 return Err(ServiceError::Control {
2024 operation: "identify the account for",
2025 name: SERVICE_NAME.to_string(),
2026 manager: "Windows Task Scheduler",
2027 detail: "this session reports no %USERNAME%, so there is no principal to \
2028 register a logon-triggered task for"
2029 .to_string(),
2030 });
2031 };
2032 let domain = std::env::var("USERDOMAIN")
2033 .ok()
2034 .filter(|value| !value.trim().is_empty());
2035 Ok(Self {
2036 user_id: match domain {
2037 Some(domain) => format!("{domain}\\{user}"),
2038 None => user,
2039 },
2040 })
2041 }
2042
2043 #[must_use]
2045 pub fn named(user_id: impl Into<String>) -> Self {
2046 Self {
2047 user_id: user_id.into(),
2048 }
2049 }
2050
2051 #[must_use]
2053 pub fn user_id(&self) -> &str {
2054 &self.user_id
2055 }
2056}
2057
2058#[must_use]
2065pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2066 let identity = plan.identity();
2067 let user = xml_escape(principal.user_id());
2068 let arguments = plan
2069 .arguments()
2070 .iter()
2071 .map(|argument| quote_argument(&argument.to_string_lossy()))
2072 .collect::<Vec<_>>()
2073 .join(" ");
2074 let mut out = String::new();
2075 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2076 out.push_str(
2077 "<Task version=\"1.4\" \
2078 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2079 );
2080 out.push_str(" <RegistrationInfo>\n");
2081 out.push_str(&format!(
2082 " <Description>{}</Description>\n",
2083 xml_escape(identity.description())
2084 ));
2085 out.push_str(&format!(
2086 " <URI>\\{}</URI>\n",
2087 xml_escape(identity.name())
2088 ));
2089 out.push_str(" </RegistrationInfo>\n");
2090
2091 out.push_str(" <Triggers>\n <LogonTrigger>\n");
2092 out.push_str(" <Enabled>true</Enabled>\n");
2093 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2094 out.push_str(" </LogonTrigger>\n </Triggers>\n");
2095
2096 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
2097 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2098 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
2099 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
2100 out.push_str(" </Principal>\n </Principals>\n");
2101
2102 out.push_str(" <Settings>\n");
2103 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2107 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2108 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2109 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
2110 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
2111 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2112 out.push_str(" <IdleSettings>\n");
2113 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2114 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
2115 out.push_str(" </IdleSettings>\n");
2116 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2117 out.push_str(" <Enabled>true</Enabled>\n");
2118 out.push_str(" <Hidden>false</Hidden>\n");
2119 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2120 out.push_str(" <WakeToRun>false</WakeToRun>\n");
2121 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2123 out.push_str(" <Priority>7</Priority>\n");
2124 out.push_str(" <RestartOnFailure>\n");
2125 out.push_str(&format!(
2126 " <Interval>{}</Interval>\n",
2127 iso8601_minutes(
2128 plan.restart()
2129 .effective_delay(DefinitionKind::WindowsScheduledTask)
2130 )
2131 ));
2132 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
2133 out.push_str(" </RestartOnFailure>\n");
2134 out.push_str(" </Settings>\n");
2135
2136 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
2137 out.push_str(&format!(
2138 " <Command>{}</Command>\n",
2139 xml_escape(&plan.binary().to_string_lossy())
2140 ));
2141 if !arguments.is_empty() {
2142 out.push_str(&format!(
2143 " <Arguments>{}</Arguments>\n",
2144 xml_escape(&arguments)
2145 ));
2146 }
2147 out.push_str(&format!(
2148 " <WorkingDirectory>{}</WorkingDirectory>\n",
2149 xml_escape(&plan.directories().state.to_string_lossy())
2150 ));
2151 out.push_str(" </Exec>\n </Actions>\n");
2152 out.push_str("</Task>\n");
2153 out
2154}
2155
2156fn iso8601_minutes(duration: Duration) -> String {
2159 format!("PT{}M", duration.as_secs() / 60)
2160}
2161
2162pub(crate) fn xml_escape(value: &str) -> String {
2170 let mut out = String::with_capacity(value.len());
2171 for c in value.chars() {
2172 match c {
2173 '&' => out.push_str("&"),
2174 '<' => out.push_str("<"),
2175 '>' => out.push_str(">"),
2176 '"' => out.push_str("""),
2177 '\'' => out.push_str("'"),
2178 other => out.push(other),
2179 }
2180 }
2181 out
2182}
2183
2184fn xml_unescape(value: &str) -> String {
2190 value
2191 .replace("<", "<")
2192 .replace(">", ">")
2193 .replace(""", "\"")
2194 .replace("'", "'")
2195 .replace("&", "&")
2196}
2197
2198#[derive(Debug, Clone, PartialEq, Eq)]
2207pub struct WindowsServiceSpec {
2208 pub name: String,
2210 pub display_name: String,
2212 pub description: String,
2214 pub automatic_start: bool,
2216 pub account: Option<String>,
2219 pub command_line: String,
2221 pub restart: RestartPolicy,
2223}
2224
2225#[must_use]
2227pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2228 WindowsServiceSpec {
2229 name: plan.identity().name().to_string(),
2230 display_name: plan.identity().display_name().to_string(),
2231 description: plan.identity().description().to_string(),
2232 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2237 account: match ServiceAccount::for_definition(
2238 DefinitionKind::WindowsService,
2239 plan.start_mode(),
2240 ) {
2241 ServiceAccount::LocalSystem => None,
2244 other => Some(other.as_str().to_string()),
2245 },
2246 command_line: plan.command_line(),
2247 restart: plan.restart(),
2248 }
2249}
2250
2251#[must_use]
2254fn windows_service_descriptor(plan: &InstallPlan) -> String {
2255 let spec = windows_service_spec(plan);
2256 let mut out = String::new();
2257 out.push_str("[windows-service]\n");
2258 out.push_str(&format!("Name={}\n", spec.name));
2259 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2260 out.push_str(&format!("Description={}\n", spec.description));
2261 out.push_str("ServiceType=OWN_PROCESS\n");
2265 out.push_str(&format!(
2266 "StartType={}\n",
2267 if spec.automatic_start {
2268 "AutoStart"
2269 } else {
2270 "OnDemand"
2271 }
2272 ));
2273 out.push_str("ErrorControl=Normal\n");
2274 out.push_str(&format!(
2275 "Account={}\n",
2276 spec.account
2277 .as_deref()
2278 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2279 ));
2280 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2281 out.push_str(&format!(
2282 "FailureActionRestartDelaySecs={}\n",
2283 spec.restart.delay().as_secs()
2284 ));
2285 out.push_str(&format!(
2286 "FailureActionsResetPeriodSecs={}\n",
2287 spec.restart.reset_after().as_secs()
2288 ));
2289 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2293 out.push_str(&format!(
2294 "ReadWritePaths={}\n",
2295 plan.directories()
2296 .all()
2297 .iter()
2298 .map(|path| quote_argument(&path.to_string_lossy()))
2299 .collect::<Vec<_>>()
2300 .join(" ")
2301 ));
2302 out
2303}
2304
2305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2311pub enum FindingKind {
2312 Excess,
2315 Shortfall,
2318}
2319
2320impl fmt::Display for FindingKind {
2321 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2322 f.write_str(match self {
2323 Self::Excess => "excess",
2324 Self::Shortfall => "shortfall",
2325 })
2326 }
2327}
2328
2329#[derive(Debug, Clone, PartialEq, Eq)]
2331pub struct PrivilegeFinding {
2332 pub kind: FindingKind,
2334 pub subject: String,
2336 pub detail: String,
2338}
2339
2340impl fmt::Display for PrivilegeFinding {
2341 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2342 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2343 }
2344}
2345
2346#[derive(Debug, Clone, PartialEq, Eq)]
2360pub struct PrivilegeReview {
2361 kind: DefinitionKind,
2362 account: ServiceAccount,
2363 controls: Vec<String>,
2364 findings: Vec<PrivilegeFinding>,
2365}
2366
2367impl PrivilegeReview {
2368 #[must_use]
2370 pub fn is_least_privilege(&self) -> bool {
2371 !self
2372 .findings
2373 .iter()
2374 .any(|finding| finding.kind == FindingKind::Excess)
2375 }
2376
2377 #[must_use]
2379 pub fn findings(&self) -> &[PrivilegeFinding] {
2380 &self.findings
2381 }
2382
2383 #[must_use]
2386 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2387 self.findings
2388 .iter()
2389 .filter(|finding| finding.kind == FindingKind::Excess)
2390 .collect()
2391 }
2392
2393 #[must_use]
2399 pub fn controls(&self) -> &[String] {
2400 &self.controls
2401 }
2402
2403 #[must_use]
2405 pub const fn account(&self) -> &ServiceAccount {
2406 &self.account
2407 }
2408
2409 #[must_use]
2411 pub const fn kind(&self) -> DefinitionKind {
2412 self.kind
2413 }
2414}
2415
2416impl fmt::Display for PrivilegeReview {
2417 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2418 writeln!(
2419 f,
2420 "{} runs as {} ({})",
2421 self.kind,
2422 self.account,
2423 self.account.justification()
2424 )?;
2425 for control in &self.controls {
2426 writeln!(f, " confirmed {control}")?;
2427 }
2428 for finding in &self.findings {
2429 writeln!(f, " {finding}")?;
2430 }
2431 if self.is_least_privilege() {
2432 write!(f, " verdict least privilege")
2433 } else {
2434 write!(
2435 f,
2436 " verdict NOT least privilege: {} excess(es)",
2437 self.excesses().len()
2438 )
2439 }
2440 }
2441}
2442
2443#[must_use]
2449pub fn review_least_privilege(
2450 definition: &ServiceDefinition,
2451 plan: &InstallPlan,
2452) -> PrivilegeReview {
2453 let mut controls = Vec::new();
2454 let mut findings = Vec::new();
2455 match definition.kind() {
2456 DefinitionKind::SystemdUnit => {
2457 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2458 }
2459 DefinitionKind::LaunchdPlist => {
2460 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2461 }
2462 DefinitionKind::WindowsScheduledTask => {
2463 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2464 }
2465 DefinitionKind::WindowsService => {
2466 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2467 }
2468 }
2469 PrivilegeReview {
2470 kind: definition.kind(),
2471 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2476 controls,
2477 findings,
2478 }
2479}
2480
2481fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2483 let mut permitted = plan
2484 .directories()
2485 .all()
2486 .iter()
2487 .map(|path| path.to_string_lossy().into_owned())
2488 .collect::<Vec<_>>();
2489 if kind == DefinitionKind::SystemdUnit
2490 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2491 {
2492 permitted.push(secret_directory.to_string_lossy().into_owned());
2493 }
2494 permitted
2495}
2496
2497fn same_path_text(left: &str, right: &str) -> bool {
2504 if cfg!(windows) {
2505 left.eq_ignore_ascii_case(right)
2506 } else {
2507 left == right
2508 }
2509}
2510
2511fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2514 match kind {
2515 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2516 left.eq_ignore_ascii_case(right)
2517 }
2518 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2519 }
2520}
2521
2522fn review_writable_paths(
2524 kind: DefinitionKind,
2525 subject: &str,
2526 listed: &[String],
2527 plan: &InstallPlan,
2528 controls: &mut Vec<String>,
2529 findings: &mut Vec<PrivilegeFinding>,
2530) {
2531 let permitted = permitted_paths(kind, plan);
2532 for entry in listed {
2533 if !permitted
2534 .iter()
2535 .any(|allowed| same_path_for(kind, allowed, entry))
2536 {
2537 findings.push(PrivilegeFinding {
2538 kind: FindingKind::Excess,
2539 subject: subject.to_string(),
2540 detail: format!(
2541 "{entry} is writable but is not one of this registration's required paths"
2542 ),
2543 });
2544 }
2545 }
2546 for allowed in &permitted {
2547 if !listed
2548 .iter()
2549 .any(|entry| same_path_for(kind, allowed, entry))
2550 {
2551 findings.push(PrivilegeFinding {
2552 kind: FindingKind::Shortfall,
2553 subject: subject.to_string(),
2554 detail: format!(
2555 "{allowed} is one of this registration's directories but is not writable, \
2556 so the daemon cannot use it"
2557 ),
2558 });
2559 }
2560 }
2561 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2562 controls.push(format!(
2563 "{subject} names exactly the required application-data and credential paths"
2564 ));
2565 }
2566}
2567
2568fn review_capabilities(
2571 expected: &str,
2572 wanted: &str,
2573 actual: &str,
2574 controls: &mut Vec<String>,
2575 findings: &mut Vec<PrivilegeFinding>,
2576) {
2577 let subject = "CapabilityBoundingSet";
2578 if actual.trim_start().starts_with('~') {
2580 findings.push(PrivilegeFinding {
2581 kind: FindingKind::Excess,
2582 subject: subject.to_string(),
2583 detail: format!("is `{actual}`, which keeps every capability it does not name"),
2584 });
2585 return;
2586 }
2587 let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2588 let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2589 let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2590 let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2591 if !extra.is_empty() {
2592 findings.push(PrivilegeFinding {
2593 kind: FindingKind::Excess,
2594 subject: subject.to_string(),
2595 detail: format!(
2596 "keeps {}, which the requirement does not ask for",
2597 extra.join(" ")
2598 ),
2599 });
2600 }
2601 if !missing.is_empty() {
2602 findings.push(PrivilegeFinding {
2603 kind: FindingKind::Shortfall,
2604 subject: subject.to_string(),
2605 detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2606 });
2607 }
2608 if extra.is_empty() && missing.is_empty() {
2609 controls.push(expected.to_string());
2610 }
2611}
2612
2613fn review_inbound_surface(
2621 text: &str,
2622 markers: &[(&str, &str)],
2623 controls: &mut Vec<String>,
2624 findings: &mut Vec<PrivilegeFinding>,
2625) {
2626 let mut clean = true;
2627 for (marker, detail) in markers {
2628 if text.contains(marker) {
2629 clean = false;
2630 findings.push(PrivilegeFinding {
2631 kind: FindingKind::Excess,
2632 subject: (*marker).to_string(),
2633 detail: (*detail).to_string(),
2634 });
2635 }
2636 }
2637 if clean {
2638 controls.push(
2639 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2640 );
2641 }
2642}
2643
2644fn review_systemd(
2645 text: &str,
2646 plan: &InstallPlan,
2647 controls: &mut Vec<String>,
2648 findings: &mut Vec<PrivilegeFinding>,
2649) {
2650 let directives = ini_directives(text, "Service");
2651 for expected in systemd_hardening(plan.start_mode()) {
2652 let (key, value) = expected
2653 .split_once('=')
2654 .expect("every hardening directive is written as key=value");
2655 match directives.get(key) {
2656 Some(actual) if key == "CapabilityBoundingSet" => {
2657 review_capabilities(expected, value, actual, controls, findings);
2658 }
2659 Some(actual) if actual == value => controls.push((*expected).to_string()),
2660 Some(actual) => findings.push(PrivilegeFinding {
2661 kind: FindingKind::Excess,
2662 subject: key.to_string(),
2663 detail: format!(
2664 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2665 requirement does not ask for"
2666 ),
2667 }),
2668 None => findings.push(PrivilegeFinding {
2669 kind: FindingKind::Excess,
2670 subject: key.to_string(),
2671 detail: format!(
2672 "is absent, so the unit inherits systemd's default rather than `{value}`"
2673 ),
2674 }),
2675 }
2676 }
2677
2678 match directives.get("ReadWritePaths") {
2679 Some(value) => {
2680 let listed = split_quoted(value);
2681 review_writable_paths(
2682 DefinitionKind::SystemdUnit,
2683 "ReadWritePaths",
2684 &listed,
2685 plan,
2686 controls,
2687 findings,
2688 );
2689 }
2690 None => findings.push(PrivilegeFinding {
2691 kind: FindingKind::Shortfall,
2692 subject: "ReadWritePaths".to_string(),
2693 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2694 .to_string(),
2695 }),
2696 }
2697
2698 if directives.contains_key("PrivateUsers")
2701 && directives.get("PrivateUsers") == Some(&"no".to_string())
2702 {
2703 findings.push(PrivilegeFinding {
2704 kind: FindingKind::Excess,
2705 subject: "PrivateUsers".to_string(),
2706 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2707 .to_string(),
2708 });
2709 }
2710
2711 review_inbound_surface(
2712 text,
2713 &[
2714 (
2715 "ListenStream=",
2716 "asks systemd to open a listening socket for this service, which \
2717 07-security.md rule 2 forbids the product to have",
2718 ),
2719 (
2720 "ListenDatagram=",
2721 "asks systemd to open a listening socket for this service, which \
2722 07-security.md rule 2 forbids the product to have",
2723 ),
2724 ],
2725 controls,
2726 findings,
2727 );
2728}
2729
2730fn review_launchd(
2731 text: &str,
2732 plan: &InstallPlan,
2733 controls: &mut Vec<String>,
2734 findings: &mut Vec<PrivilegeFinding>,
2735) {
2736 match plist_string_value(text, "ProcessType") {
2740 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2741 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2742 }
2743 found => findings.push(PrivilegeFinding {
2744 kind: FindingKind::Shortfall,
2745 subject: "ProcessType".to_string(),
2746 detail: format!(
2747 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2748 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2749 when it next starts with no runner; `service status` says how to restart it",
2750 found.map_or_else(
2751 || "absent (launchd's `Standard`)".to_string(),
2752 |value| format!("`{value}`")
2753 )
2754 ),
2755 }),
2756 }
2757
2758 match plan.start_mode() {
2759 StartMode::Boot => {
2760 if plist_bool_value(text, "SessionCreate") == Some(true) {
2761 findings.push(PrivilegeFinding {
2762 kind: FindingKind::Excess,
2763 subject: "SessionCreate".to_string(),
2764 detail: "asks launchd to create a security session for a job that runs \
2765 outside every login session and has no use for one"
2766 .to_string(),
2767 });
2768 } else {
2769 controls.push("SessionCreate is not requested".to_string());
2770 }
2771 match plist_string_value(text, "UserName").as_deref() {
2772 Some("root") => {
2773 controls.push("UserName=root, stated rather than inherited".to_string())
2774 }
2775 Some(other) => findings.push(PrivilegeFinding {
2776 kind: FindingKind::Shortfall,
2777 subject: "UserName".to_string(),
2778 detail: format!(
2779 "is `{other}`, which cannot unlock the System Keychain: \
2780 /var/db/SystemKey is root-only, so the daemon would start and then \
2781 find no credential"
2782 ),
2783 }),
2784 None => findings.push(PrivilegeFinding {
2785 kind: FindingKind::Shortfall,
2786 subject: "UserName".to_string(),
2787 detail: "is absent, so the account is launchd's implicit default and this \
2788 review cannot confirm it"
2789 .to_string(),
2790 }),
2791 }
2792 }
2793 StartMode::Login => {
2794 if let Some(named) = plist_string_value(text, "UserName") {
2795 findings.push(PrivilegeFinding {
2796 kind: FindingKind::Excess,
2797 subject: "UserName".to_string(),
2798 detail: format!(
2799 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2800 naming an account here asks launchd for a switch a login-mode \
2801 registration has no reason to want"
2802 ),
2803 });
2804 } else {
2805 controls
2806 .push("no UserName: the agent runs as the operator and no other".to_string());
2807 }
2808 }
2809 }
2810
2811 review_inbound_surface(
2812 text,
2813 &[
2814 (
2815 "<key>Sockets</key>",
2816 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2817 forbids the product to have",
2818 ),
2819 (
2820 "<key>MachServices</key>",
2821 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2822 forbids the product to have",
2823 ),
2824 ],
2825 controls,
2826 findings,
2827 );
2828}
2829
2830fn review_scheduled_task(
2831 text: &str,
2832 controls: &mut Vec<String>,
2833 findings: &mut Vec<PrivilegeFinding>,
2834) {
2835 match xml_value(text, "RunLevel").as_deref() {
2836 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2837 Some(other) => findings.push(PrivilegeFinding {
2838 kind: FindingKind::Excess,
2839 subject: "RunLevel".to_string(),
2840 detail: format!(
2841 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2842 an administrator"
2843 ),
2844 }),
2845 None => findings.push(PrivilegeFinding {
2846 kind: FindingKind::Excess,
2847 subject: "RunLevel".to_string(),
2848 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2849 .to_string(),
2850 }),
2851 }
2852
2853 match xml_value(text, "LogonType").as_deref() {
2854 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2855 Some(other) => findings.push(PrivilegeFinding {
2856 kind: FindingKind::Excess,
2857 subject: "LogonType".to_string(),
2858 detail: format!(
2859 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2860 task; an interactive token needs neither"
2861 ),
2862 }),
2863 None => findings.push(PrivilegeFinding {
2864 kind: FindingKind::Shortfall,
2865 subject: "LogonType".to_string(),
2866 detail: "is absent, so this review cannot confirm that no credential is stored"
2867 .to_string(),
2868 }),
2869 }
2870}
2871
2872fn review_windows_service(
2873 text: &str,
2874 plan: &InstallPlan,
2875 controls: &mut Vec<String>,
2876 findings: &mut Vec<PrivilegeFinding>,
2877) {
2878 let directives = ini_directives(text, "windows-service");
2879
2880 match directives.get("ServiceType").map(String::as_str) {
2881 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2882 Some(other) => findings.push(PrivilegeFinding {
2883 kind: FindingKind::Excess,
2884 subject: "ServiceType".to_string(),
2885 detail: format!(
2886 "is `{other}`; an interactive or shared-process service reaches further than a \
2887 daemon that only talks to GitHub over HTTPS"
2888 ),
2889 }),
2890 None => findings.push(PrivilegeFinding {
2891 kind: FindingKind::Shortfall,
2892 subject: "ServiceType".to_string(),
2893 detail: "is absent, so this review cannot confirm the service is not interactive"
2894 .to_string(),
2895 }),
2896 }
2897
2898 match directives.get("Account").map(String::as_str) {
2902 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2903 controls.push(format!(
2904 "Account={account}: the only stock account the machine-scoped store's DACL \
2905 (SY, BA, OW) admits"
2906 ));
2907 }
2908 Some(other) => findings.push(PrivilegeFinding {
2909 kind: FindingKind::Shortfall,
2910 subject: "Account".to_string(),
2911 detail: format!(
2912 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2913 daemon would start and then find no credential. Widening that DACL is not this \
2914 registration's to do: an ACE reaching `{other}` would also reach every other \
2915 service running under it"
2916 ),
2917 }),
2918 None => findings.push(PrivilegeFinding {
2919 kind: FindingKind::Shortfall,
2920 subject: "Account".to_string(),
2921 detail: "is absent, so this review cannot confirm which account was registered"
2922 .to_string(),
2923 }),
2924 }
2925
2926 match directives.get("ReadWritePaths") {
2927 Some(value) => {
2928 let listed = split_quoted(value);
2929 review_writable_paths(
2930 DefinitionKind::WindowsService,
2931 "ReadWritePaths",
2932 &listed,
2933 plan,
2934 controls,
2935 findings,
2936 );
2937 }
2938 None => findings.push(PrivilegeFinding {
2939 kind: FindingKind::Shortfall,
2940 subject: "ReadWritePaths".to_string(),
2941 detail: "is absent, so the directories the service was installed against are not \
2942 recorded"
2943 .to_string(),
2944 }),
2945 }
2946}
2947
2948fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2956 let mut out = BTreeMap::new();
2957 let mut inside = false;
2958 for line in text.lines() {
2959 let line = line.trim();
2960 if line.starts_with('[') && line.ends_with(']') {
2961 inside = &line[1..line.len() - 1] == section;
2962 continue;
2963 }
2964 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2965 continue;
2966 }
2967 if let Some((key, value)) = line.split_once('=') {
2968 out.insert(key.trim().to_string(), value.trim().to_string());
2969 }
2970 }
2971 out
2972}
2973
2974#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2981fn systemd_exec_command_line(value: &str) -> String {
2982 let value = value.trim();
2983 let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
2984 if !value[..value.len() - command.len()].contains('@') {
2985 return command.to_string();
2986 }
2987 let mut words = split_command_line(command);
2988 if words.len() > 1 {
2989 words.remove(1);
2990 }
2991 words
2992 .iter()
2993 .map(|word| quote_argument(word))
2994 .collect::<Vec<_>>()
2995 .join(" ")
2996}
2997
2998#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3002fn systemd_timespan(value: &str) -> Option<Duration> {
3003 let mut rest = value.trim();
3004 if rest.is_empty() || rest == "infinity" {
3005 return None;
3006 }
3007 let mut seconds = 0f64;
3008 while !rest.is_empty() {
3009 let digits = rest
3010 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3011 .unwrap_or(rest.len());
3012 if digits == 0 {
3013 return None;
3014 }
3015 let number: f64 = rest[..digits].parse().ok()?;
3016 rest = rest[digits..].trim_start();
3017 let letters = rest
3018 .find(|c: char| !c.is_alphabetic())
3019 .unwrap_or(rest.len());
3020 let scale = match &rest[..letters] {
3021 "" | "s" | "sec" | "second" | "seconds" => 1.0,
3022 "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3023 "ms" | "msec" => 1e-3,
3024 "m" | "min" | "minute" | "minutes" => 60.0,
3025 "h" | "hr" | "hour" | "hours" => 3_600.0,
3026 "d" | "day" | "days" => 86_400.0,
3027 "w" | "week" | "weeks" => 604_800.0,
3028 "M" | "month" | "months" => 2_629_800.0,
3029 "y" | "year" | "years" => 31_557_600.0,
3030 _ => return None,
3031 };
3032 seconds += number * scale;
3033 rest = rest[letters..].trim_start();
3034 }
3035 Duration::try_from_secs_f64(seconds).ok()
3036}
3037
3038fn split_quoted(value: &str) -> Vec<String> {
3041 let mut out = Vec::new();
3042 let mut rest = value.trim();
3043 while !rest.is_empty() {
3044 if rest.starts_with('"') {
3045 if let Some(parsed) = executable_from_command_line(rest) {
3048 out.push(parsed.to_string_lossy().into_owned());
3049 }
3050 let mut depth = 0usize;
3052 let mut end = rest.len();
3053 for (index, c) in rest.char_indices() {
3054 match c {
3055 '\\' => depth += 1,
3056 '"' => {
3057 if depth.is_multiple_of(2) && index > 0 {
3058 end = index + 1;
3059 break;
3060 }
3061 depth = 0;
3062 }
3063 _ => depth = 0,
3064 }
3065 }
3066 rest = rest[end..].trim_start();
3067 } else {
3068 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3069 out.push(rest[..end].to_string());
3070 rest = rest[end..].trim_start();
3071 }
3072 }
3073 out
3074}
3075
3076pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3088 let open = format!("<{tag}>");
3089 let close = format!("</{tag}>");
3090 let start = text.find(&open)? + open.len();
3091 let end = text[start..].find(&close)? + start;
3092 Some(xml_unescape(text[start..end].trim()))
3093}
3094
3095#[cfg(any(windows, test))]
3101fn windows_login_task_starts_automatically(document: &str) -> bool {
3102 document.contains("<LogonTrigger>")
3103 && xml_value(document, "Enabled").as_deref() != Some("false")
3104}
3105
3106fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3108 let marker = format!("<key>{key}</key>");
3109 let start = text.find(&marker)? + marker.len();
3110 Some(text[start..].trim_start())
3111}
3112
3113pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3114 let rest = plist_value_after_key(text, key)?;
3115 if !rest.starts_with("<string>") {
3116 return None;
3117 }
3118 xml_value(rest, "string")
3119}
3120
3121fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3122 let rest = plist_value_after_key(text, key)?;
3123 if rest.starts_with("<true/>") {
3124 Some(true)
3125 } else if rest.starts_with("<false/>") {
3126 Some(false)
3127 } else {
3128 None
3129 }
3130}
3131
3132pub const RECORD_SCHEMA_VERSION: u32 = 1;
3143
3144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3156pub struct InstallRecord {
3157 pub schema_version: u32,
3159 pub service_name: String,
3161 pub manager: String,
3163 pub start_mode: StartMode,
3165 pub account: ServiceAccount,
3167 pub binary: PathBuf,
3169 #[serde(default)]
3177 pub source_binary: Option<PathBuf>,
3178 pub arguments: Vec<String>,
3180 pub restart_delay_secs: u64,
3182 pub restart_reset_secs: u64,
3184 pub log_file: PathBuf,
3186 #[serde(default)]
3194 pub starts_on_demand: bool,
3195 pub definition_path: Option<PathBuf>,
3197 pub installed_at: DateTime<Utc>,
3199 pub installed_by_version: String,
3201 pub directories: ServiceDirectories,
3206}
3207
3208impl InstallRecord {
3209 #[must_use]
3211 pub fn path(paths: &AppPaths) -> PathBuf {
3212 paths.config_dir().join(RECORD_FILE)
3213 }
3214
3215 #[must_use]
3217 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3218 Self {
3219 schema_version: RECORD_SCHEMA_VERSION,
3220 service_name: plan.identity().name().to_string(),
3221 manager: definition.kind().manager().to_string(),
3222 start_mode: plan.start_mode(),
3223 account: plan.account().clone(),
3224 binary: plan.binary().to_path_buf(),
3225 arguments: plan
3226 .arguments()
3227 .iter()
3228 .map(|argument| argument.to_string_lossy().into_owned())
3229 .collect(),
3230 restart_delay_secs: plan.restart().delay().as_secs(),
3231 restart_reset_secs: plan.restart().reset_after().as_secs(),
3232 starts_on_demand: plan.is_on_demand(),
3233 source_binary: plan.source_binary().map(Path::to_path_buf),
3234 log_file: plan.directories().log_file(),
3235 definition_path: definition.install_path().map(Path::to_path_buf),
3236 installed_at: at,
3237 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3238 directories: plan.directories().clone(),
3239 }
3240 }
3241
3242 #[must_use]
3250 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3251 let plan = InstallPlan::unchecked(
3252 identity,
3253 self.start_mode,
3254 self.binary.clone(),
3255 self.directories.clone(),
3256 )
3257 .with_arguments(self.arguments.clone())
3258 .with_restart(self.restart());
3259 let plan = if self.starts_on_demand {
3260 plan.started_on_demand()
3261 } else {
3262 plan
3263 };
3264 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3265 Ok(store) => plan.with_secret_guard(store.guard()),
3266 Err(_) => plan,
3267 }
3268 }
3269
3270 #[must_use]
3274 pub fn restart(&self) -> RestartPolicy {
3275 RestartPolicy::new(
3276 Duration::from_secs(self.restart_delay_secs),
3277 Duration::from_secs(self.restart_reset_secs),
3278 )
3279 .unwrap_or_default()
3280 }
3281
3282 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3293 let path = Self::path(paths);
3294 let text = match std::fs::read_to_string(&path) {
3295 Ok(text) => text,
3296 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3297 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3300 return Err(ServiceError::RecordNotPermitted {
3301 path,
3302 detail: error.to_string(),
3303 });
3304 }
3305 Err(error) => {
3306 return Err(ServiceError::Record {
3307 operation: "read",
3308 path,
3309 detail: error.to_string(),
3310 });
3311 }
3312 };
3313 let record: Self =
3314 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3315 path: path.clone(),
3316 detail: error.to_string(),
3317 })?;
3318 if record.schema_version != RECORD_SCHEMA_VERSION {
3319 return Err(ServiceError::RecordUnreadable {
3320 path,
3321 detail: format!(
3322 "it declares schema version {} and this build reads version {}",
3323 record.schema_version, RECORD_SCHEMA_VERSION
3324 ),
3325 });
3326 }
3327 Ok(Some(record))
3328 }
3329
3330 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3336 use std::io::Write as _;
3337
3338 let path = Self::path(paths);
3339 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3340 operation: "encode",
3341 path: path.clone(),
3342 detail: error.to_string(),
3343 })?;
3344 if let Some(parent) = path.parent() {
3345 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3346 operation: "write",
3347 path: path.clone(),
3348 detail: error.to_string(),
3349 })?;
3350 }
3351 let parent = path.parent().ok_or_else(|| ServiceError::Record {
3352 operation: "write",
3353 path: path.clone(),
3354 detail: "the record path has no parent directory".to_string(),
3355 })?;
3356 let mut temporary =
3357 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3358 operation: "write",
3359 path: path.clone(),
3360 detail: error.to_string(),
3361 })?;
3362 temporary
3363 .write_all(text.as_bytes())
3364 .and_then(|()| temporary.as_file().sync_all())
3365 .map_err(|error| ServiceError::Record {
3366 operation: "write",
3367 path: path.clone(),
3368 detail: error.to_string(),
3369 })?;
3370 #[cfg(unix)]
3386 {
3387 use std::os::unix::fs::PermissionsExt as _;
3388
3389 temporary
3390 .as_file()
3391 .set_permissions(std::fs::Permissions::from_mode(0o644))
3392 .map_err(|error| ServiceError::Record {
3393 operation: "write",
3394 path: path.clone(),
3395 detail: error.to_string(),
3396 })?;
3397 }
3398 temporary
3399 .persist(&path)
3400 .map(|_| ())
3401 .map_err(|error| ServiceError::Record {
3402 operation: "write",
3403 path,
3404 detail: error.error.to_string(),
3405 })
3406 }
3407
3408 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3414 let path = Self::path(paths);
3415 match std::fs::remove_file(&path) {
3416 Ok(()) => Ok(true),
3417 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3418 Err(error) => Err(ServiceError::Record {
3419 operation: "remove",
3420 path,
3421 detail: error.to_string(),
3422 }),
3423 }
3424 }
3425}
3426
3427const CONTACT_SCHEMA_VERSION: u32 = 1;
3433
3434#[derive(Debug, Clone, Serialize, Deserialize)]
3435struct ContactRecord {
3436 schema_version: u32,
3437 last_success: DateTime<Utc>,
3438}
3439
3440pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3461 write_state_record(
3462 &contact_path(paths),
3463 &ContactRecord {
3464 schema_version: CONTACT_SCHEMA_VERSION,
3465 last_success: at,
3466 },
3467 )
3468}
3469
3470pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3482 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3483}
3484
3485#[must_use]
3487pub fn contact_path(paths: &AppPaths) -> PathBuf {
3488 paths.state_dir().join(CONTACT_FILE)
3489}
3490
3491pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3494 let failed = |detail: String| ServiceError::Record {
3495 operation: "write",
3496 path: path.to_path_buf(),
3497 detail,
3498 };
3499 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3500 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3501 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3502 let temporary = path.with_extension("toml.new");
3503 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3504 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3505}
3506
3507pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3510 path: &Path,
3511) -> Result<Option<T>, ServiceError> {
3512 let failed = |detail: String| ServiceError::Record {
3513 operation: "read",
3514 path: path.to_path_buf(),
3515 detail,
3516 };
3517 let text = match std::fs::read_to_string(path) {
3518 Ok(text) => text,
3519 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3520 Err(error) => return Err(failed(error.to_string())),
3521 };
3522 toml::from_str(&text)
3523 .map(Some)
3524 .map_err(|error| failed(error.to_string()))
3525}
3526
3527pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3529 match std::fs::remove_file(path) {
3530 Ok(()) => Ok(()),
3531 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3532 Err(error) => Err(ServiceError::Record {
3533 operation: "remove",
3534 path: path.to_path_buf(),
3535 detail: error.to_string(),
3536 }),
3537 }
3538}
3539
3540#[derive(Debug, Serialize, Deserialize)]
3545#[serde(deny_unknown_fields)]
3546struct CredentialRejectionRecord {
3547 schema_version: u32,
3548 rejected_since: DateTime<Utc>,
3549}
3550
3551pub fn record_github_credential_rejected(
3567 paths: &AppPaths,
3568 at: DateTime<Utc>,
3569) -> Result<(), ServiceError> {
3570 if github_credential_rejected_since(paths)
3571 .ok()
3572 .flatten()
3573 .is_some()
3574 {
3575 return Ok(());
3576 }
3577 write_state_record(
3578 &credential_rejection_path(paths),
3579 &CredentialRejectionRecord {
3580 schema_version: CONTACT_SCHEMA_VERSION,
3581 rejected_since: at,
3582 },
3583 )
3584}
3585
3586pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3592 remove_state_record(&credential_rejection_path(paths))
3593}
3594
3595pub fn github_credential_rejected_since(
3601 paths: &AppPaths,
3602) -> Result<Option<DateTime<Utc>>, ServiceError> {
3603 Ok(
3604 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3605 .map(|record| record.rejected_since),
3606 )
3607}
3608
3609#[must_use]
3611pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3612 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3613}
3614
3615#[derive(Debug, Serialize, Deserialize)]
3616#[serde(deny_unknown_fields)]
3617struct CredentialUnreadableRecord {
3618 schema_version: u32,
3619 unreadable_since: DateTime<Utc>,
3620}
3621
3622pub fn record_credential_unreadable(
3641 paths: &AppPaths,
3642 at: DateTime<Utc>,
3643) -> Result<(), ServiceError> {
3644 if credential_unreadable_since(paths).ok().flatten().is_some() {
3645 return Ok(());
3646 }
3647 write_state_record(
3648 &credential_unreadable_path(paths),
3649 &CredentialUnreadableRecord {
3650 schema_version: CONTACT_SCHEMA_VERSION,
3651 unreadable_since: at,
3652 },
3653 )
3654}
3655
3656pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3662 remove_state_record(&credential_unreadable_path(paths))
3663}
3664
3665pub fn credential_unreadable_since(
3672 paths: &AppPaths,
3673) -> Result<Option<DateTime<Utc>>, ServiceError> {
3674 Ok(
3675 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3676 .map(|record| record.unreadable_since),
3677 )
3678}
3679
3680#[must_use]
3682pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3683 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3684}
3685
3686#[must_use]
3694pub fn sign_in_instruction(mode: StartMode) -> String {
3695 let (command, place) = match mode {
3696 StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3697 StartMode::Boot => (
3698 "runner-manager auth login --start-at boot".to_owned(),
3699 " from an elevated terminal",
3700 ),
3701 StartMode::Login => (
3702 "runner-manager auth login --start-at login".to_owned(),
3703 MACOS_LOGIN_PLACE,
3704 ),
3705 };
3706 format!("`{command}`{place}")
3707}
3708
3709#[must_use]
3718pub fn sudo_command(arguments: &str) -> String {
3719 let executable = if cfg!(test) {
3722 None
3723 } else {
3724 std::env::current_exe().ok()
3725 };
3726 format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3727}
3728
3729fn sudo_program(executable: Option<&Path>) -> String {
3731 const SECURE_PATH: [&str; 7] = [
3734 "/usr/local/sbin",
3735 "/usr/local/bin",
3736 "/usr/sbin",
3737 "/usr/bin",
3738 "/sbin",
3739 "/bin",
3740 "/snap/bin",
3741 ];
3742 let Some(executable) = executable else {
3743 return "runner-manager".to_owned();
3744 };
3745 if executable
3746 .parent()
3747 .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3748 {
3749 return "runner-manager".to_owned();
3750 }
3751 let text = executable.to_string_lossy();
3752 if text
3753 .chars()
3754 .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3755 {
3756 text.into_owned()
3757 } else {
3758 format!("'{}'", text.replace('\'', "'\\''"))
3759 }
3760}
3761
3762pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3765 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3766 keychain)"
3767} else {
3768 ""
3769};
3770
3771#[derive(Debug, Clone, PartialEq, Eq)]
3788pub struct DefinitionDrift {
3789 pub path: PathBuf,
3791 pub installed_by_version: String,
3793 pub rendered: String,
3795 pub start_mode: StartMode,
3797 pub manager: DefinitionKind,
3799}
3800
3801fn definition_drift(
3809 controls: &dyn ControlFactory,
3810 identity: &ServiceIdentity,
3811 record: &InstallRecord,
3812) -> Option<DefinitionDrift> {
3813 let compare = match host_definition_kind(record.start_mode) {
3814 DefinitionKind::SystemdUnit => systemd_definition_drift,
3815 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3816 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3817 };
3818 let path = record.definition_path.clone()?;
3819 let installed = controls.installed_definition(&path)?;
3820 compare(identity, record, path, &installed)
3821}
3822
3823fn launchd_definition_drift(
3832 identity: &ServiceIdentity,
3833 record: &InstallRecord,
3834 path: PathBuf,
3835 installed: &str,
3836) -> Option<DefinitionDrift> {
3837 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3838 || DefinitionDrift {
3839 path,
3840 installed_by_version: record.installed_by_version.clone(),
3841 rendered: launchd_plist(&record.plan(identity.clone())),
3842 start_mode: record.start_mode,
3843 manager: DefinitionKind::LaunchdPlist,
3844 },
3845 )
3846}
3847
3848fn systemd_definition_drift(
3849 identity: &ServiceIdentity,
3850 record: &InstallRecord,
3851 path: PathBuf,
3852 installed: &str,
3853) -> Option<DefinitionDrift> {
3854 let rendered = systemd_unit(&record.plan(identity.clone()));
3855 (installed != rendered).then(|| DefinitionDrift {
3856 path,
3857 installed_by_version: record.installed_by_version.clone(),
3858 rendered,
3859 start_mode: record.start_mode,
3860 manager: DefinitionKind::SystemdUnit,
3861 })
3862}
3863
3864const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3869
3870#[derive(Debug, Clone, Serialize, Deserialize)]
3871struct RootRefusalFile {
3872 schema_version: u32,
3873 #[serde(default)]
3875 refusals: BTreeMap<String, RootRefusalEntry>,
3876}
3877
3878#[derive(Debug, Clone, Serialize, Deserialize)]
3879struct RootRefusalEntry {
3880 at: DateTime<Utc>,
3881 kind: String,
3882 root: String,
3883 detail: String,
3884}
3885
3886#[derive(Debug, Clone, PartialEq, Eq)]
3888pub struct RunnerRootRefusal {
3889 pub policy: String,
3891 pub at: DateTime<Utc>,
3893 pub kind: String,
3895 pub root: String,
3897 pub detail: String,
3900}
3901
3902pub fn record_runner_root_refusal(
3936 paths: &AppPaths,
3937 policy: &str,
3938 at: DateTime<Utc>,
3939 kind: &str,
3940 root: &str,
3941 detail: &str,
3942) -> Result<(), ServiceError> {
3943 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3944 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3945 refusals: BTreeMap::new(),
3946 });
3947 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3948 file.refusals.insert(
3949 policy.to_owned(),
3950 RootRefusalEntry {
3951 at,
3952 kind: kind.to_owned(),
3953 root: root.to_owned(),
3954 detail: detail.to_owned(),
3955 },
3956 );
3957 write_refusal_file(paths, &file)
3958}
3959
3960pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3971 let Some(mut file) = read_refusal_file(paths)? else {
3972 return Ok(());
3973 };
3974 if file.refusals.remove(policy).is_none() {
3975 return Ok(());
3976 }
3977 if file.refusals.is_empty() {
3978 let path = root_refusal_path(paths);
3979 return match std::fs::remove_file(&path) {
3980 Ok(()) => Ok(()),
3981 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3982 Err(error) => Err(ServiceError::Record {
3983 operation: "remove",
3984 path,
3985 detail: error.to_string(),
3986 }),
3987 };
3988 }
3989 write_refusal_file(paths, &file)
3990}
3991
3992pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4004 Ok(read_refusal_file(paths)?
4005 .map(|file| {
4006 file.refusals
4007 .into_iter()
4008 .map(|(policy, entry)| RunnerRootRefusal {
4009 policy,
4010 at: entry.at,
4011 kind: entry.kind,
4012 root: entry.root,
4013 detail: entry.detail,
4014 })
4015 .collect()
4016 })
4017 .unwrap_or_default())
4018}
4019
4020fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4021 read_state_record(&root_refusal_path(paths))
4022}
4023
4024fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4025 write_state_record(&root_refusal_path(paths), file)
4026}
4027
4028#[must_use]
4030pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4031 paths.state_dir().join(ROOT_REFUSAL_FILE)
4032}
4033
4034#[derive(Debug, Clone, PartialEq, Eq)]
4045pub enum BinaryPath {
4046 Current {
4048 path: PathBuf,
4050 },
4051 Missing {
4058 recorded: PathBuf,
4060 },
4061 NotExecutable {
4064 recorded: PathBuf,
4066 detail: String,
4068 },
4069 Diverged {
4071 recorded: PathBuf,
4073 registered: PathBuf,
4075 },
4076}
4077
4078impl BinaryPath {
4079 #[must_use]
4081 pub const fn is_error(&self) -> bool {
4082 !matches!(self, Self::Current { .. })
4083 }
4084
4085 #[must_use]
4087 pub fn recorded(&self) -> &Path {
4088 match self {
4089 Self::Current { path } => path,
4090 Self::Missing { recorded }
4091 | Self::NotExecutable { recorded, .. }
4092 | Self::Diverged { recorded, .. } => recorded,
4093 }
4094 }
4095}
4096
4097impl fmt::Display for BinaryPath {
4098 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4099 match self {
4100 Self::Current { path } => write!(f, "{}", path.display()),
4101 Self::Missing { recorded } => write!(
4102 f,
4103 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4104 package manager that moved the binary is the usual cause; an `npm i -g` \
4105 installation moves with the active Node version. Run `service install` again \
4106 from the binary that is now installed.",
4107 recorded.display()
4108 ),
4109 Self::NotExecutable { recorded, detail } => write!(
4110 f,
4111 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4112 from the installed binary.",
4113 recorded.display()
4114 ),
4115 Self::Diverged {
4116 recorded,
4117 registered,
4118 } => write!(
4119 f,
4120 "{} -- STALE: the service manager is registered to start {} instead. Something \
4121 has edited the registration since it was installed. Run `service uninstall` and \
4122 `service install`; neither touches configuration, secrets, or the cache.",
4123 recorded.display(),
4124 registered.display()
4125 ),
4126 }
4127 }
4128}
4129
4130#[must_use]
4138pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4139 match std::fs::metadata(recorded) {
4140 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4141 return BinaryPath::Missing {
4142 recorded: recorded.to_path_buf(),
4143 };
4144 }
4145 Err(error) => {
4146 return BinaryPath::NotExecutable {
4147 recorded: recorded.to_path_buf(),
4148 detail: format!("it cannot be inspected ({error})"),
4149 };
4150 }
4151 Ok(metadata) if !metadata.is_file() => {
4152 return BinaryPath::NotExecutable {
4153 recorded: recorded.to_path_buf(),
4154 detail: "what is there is not a file".to_string(),
4155 };
4156 }
4157 Ok(_) => {}
4158 }
4159 if let Some(registered) = registered
4160 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
4161 {
4162 return BinaryPath::Diverged {
4163 recorded: recorded.to_path_buf(),
4164 registered: registered.to_path_buf(),
4165 };
4166 }
4167 BinaryPath::Current {
4168 path: recorded.to_path_buf(),
4169 }
4170}
4171
4172#[derive(Debug, Clone, PartialEq, Eq)]
4178pub struct Registration {
4179 pub manager: DefinitionKind,
4184 pub start_mode: StartMode,
4186 pub command_line: String,
4188 pub account: Option<String>,
4195 pub running: bool,
4197 pub starts_automatically: bool,
4204 pub restart_delay: Option<Duration>,
4213}
4214
4215impl Registration {
4216 #[must_use]
4218 pub fn binary(&self) -> Option<PathBuf> {
4219 executable_from_command_line(&self.command_line)
4220 }
4221}
4222
4223pub trait ServiceControl: fmt::Debug {
4229 fn manager(&self) -> DefinitionKind;
4231
4232 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4238
4239 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4250
4251 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4257
4258 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4265
4266 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4272}
4273
4274pub trait ControlFactory: fmt::Debug + Send + Sync {
4280 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4286
4287 fn installed_definition(&self, _path: &Path) -> Option<String> {
4291 None
4292 }
4293
4294 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4299 None
4300 }
4301}
4302
4303#[derive(Debug, Clone, Copy, Default)]
4305pub struct HostControls;
4306
4307#[derive(Debug, Clone)]
4313pub struct Installed {
4314 pub plan: InstallPlan,
4316 pub definition: ServiceDefinition,
4318 pub record: InstallRecord,
4320 pub review: PrivilegeReview,
4322 pub runner_root: RootAccessSummary,
4327 pub replaced_existing: bool,
4335}
4336
4337#[derive(Debug, Clone, PartialEq, Eq)]
4339pub struct Uninstalled {
4340 pub removed_registration: bool,
4342 pub removed_record: bool,
4344 pub removed_definition: Option<PathBuf>,
4346 pub preserved: Vec<PathBuf>,
4353}
4354
4355impl fmt::Display for Uninstalled {
4356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4357 if self.removed_registration {
4358 writeln!(f, "The service registration was removed.")?;
4359 } else {
4360 writeln!(f, "There was no service registration to remove.")?;
4361 }
4362 writeln!(f, "Nothing else was deleted. These are untouched:")?;
4363 for path in &self.preserved {
4364 writeln!(f, " {}", path.display())?;
4365 }
4366 write!(
4367 f,
4368 "The stored GitHub token is untouched too; `auth logout` is what purges it."
4369 )
4370 }
4371}
4372
4373#[derive(Debug, Clone, PartialEq, Eq)]
4375pub struct StartModeChange {
4376 pub from: StartMode,
4378 pub to: StartMode,
4380 pub changed: bool,
4382 pub store_scope: crate::secrets::SecretScope,
4385 pub runner_root: RootAccessSummary,
4391}
4392
4393impl fmt::Display for StartModeChange {
4394 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4395 if !self.changed {
4396 return write!(f, "The service already starts at {}.", self.to);
4397 }
4398 write!(
4399 f,
4400 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4401 if the token was stored under the other scope, run `auth login` again. {}",
4402 self.to, self.from, self.store_scope, self.runner_root
4403 )
4404 }
4405}
4406
4407#[derive(Debug, Clone)]
4415pub struct ServiceOperations {
4416 paths: AppPaths,
4417 identity: ServiceIdentity,
4418 controls: std::sync::Arc<dyn ControlFactory>,
4419 runner_root: Option<LocalAbsolutePath>,
4420}
4421
4422impl ServiceOperations {
4423 #[must_use]
4425 pub fn on_this_host(paths: AppPaths) -> Self {
4426 Self::with_controls(
4427 paths,
4428 ServiceIdentity::product(),
4429 std::sync::Arc::new(HostControls),
4430 )
4431 }
4432
4433 #[must_use]
4441 pub fn with_controls(
4442 paths: AppPaths,
4443 identity: ServiceIdentity,
4444 controls: std::sync::Arc<dyn ControlFactory>,
4445 ) -> Self {
4446 Self {
4447 paths,
4448 identity,
4449 controls,
4450 runner_root: None,
4451 }
4452 }
4453
4454 #[must_use]
4473 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4474 self.runner_root = Some(root);
4475 self
4476 }
4477
4478 #[must_use]
4480 pub const fn paths(&self) -> &AppPaths {
4481 &self.paths
4482 }
4483
4484 #[must_use]
4486 pub const fn identity(&self) -> &ServiceIdentity {
4487 &self.identity
4488 }
4489
4490 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4510 self.paths
4511 .create_all()
4512 .map_err(|source| ServiceError::Paths {
4513 source: Box::new(source),
4514 })?;
4515
4516 let _guard = self.refuse_while_an_agent_runs()?;
4519 let adopted = self.adopt_boot_installation(request.start_mode())?;
4520 if !adopted {
4524 self.refuse_foreign("replace")?;
4525 }
4526
4527 let (replacing, previous_was_running) = match self.find_registration()? {
4556 Some((existing, registration)) if existing == request.start_mode() => {
4557 (true, registration.running)
4558 }
4559 Some((existing, _)) => {
4560 return Err(ServiceError::AlreadyInstalled {
4561 name: self.identity.name().to_string(),
4562 existing,
4563 requested: request.start_mode(),
4564 });
4565 }
4566 None => (false, false),
4567 };
4568
4569 let plan = InstallPlan::resolve(
4570 self.identity.clone(),
4571 request,
4572 ServiceDirectories::of(&self.paths),
4573 )?;
4574
4575 let control = self.controls.control(plan.start_mode())?;
4580
4581 let root = self.prepare_runner_root(plan.start_mode())?;
4587
4588 let previous = if replacing {
4593 InstallRecord::read(&self.paths).ok().flatten()
4594 } else {
4595 None
4596 };
4597 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4598 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4599 }
4600
4601 let definition = match control.install(&plan) {
4602 Ok(definition) => definition,
4603 Err(cause) => {
4604 let restored =
4607 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4608 return Err(rolled_back(
4609 retained_runner_root(&root),
4610 restored,
4611 "install",
4612 &self.identity,
4613 cause,
4614 ));
4615 }
4616 };
4617 let review = review_least_privilege(&definition, &plan);
4618 let record = InstallRecord::of(&plan, &definition, Utc::now());
4619 if let Err(cause) = record.write(&self.paths) {
4620 let rollback = control
4621 .uninstall(&self.identity)
4622 .map(|_| ())
4623 .and_then(|()| {
4624 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4625 });
4626 return Err(rolled_back(
4627 retained_runner_root(&root),
4628 rollback,
4629 "install",
4630 &self.identity,
4631 cause,
4632 ));
4633 }
4634
4635 if let Err(cause) = control.start(&self.identity) {
4642 let remove_new = control.uninstall(&self.identity).map(|_| ());
4643 let restore_registration = remove_new.and_then(|()| {
4644 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4645 });
4646 let restore_record = restore_registration.and_then(|()| match &previous {
4647 Some(previous) => previous.write(&self.paths),
4648 None => InstallRecord::remove(&self.paths).map(|_| ()),
4649 });
4650 return Err(rolled_back(
4651 retained_runner_root(&root),
4652 restore_record,
4653 "install and start",
4654 &self.identity,
4655 cause,
4656 ));
4657 }
4658 Ok(Installed {
4659 plan,
4660 definition,
4661 record,
4662 review,
4663 runner_root: root.summary().clone(),
4664 replaced_existing: replacing,
4665 })
4666 }
4667
4668 fn reinstate(
4679 &self,
4680 control: &dyn ServiceControl,
4681 previous: Option<&InstallRecord>,
4682 was_running: bool,
4683 ) -> Result<(), ServiceError> {
4684 let Some(record) = previous else {
4685 return Ok(());
4686 };
4687 control.install(&record.plan(self.identity.clone()))?;
4688 if was_running {
4689 control.start(&self.identity)?;
4690 }
4691 Ok(())
4692 }
4693
4694 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4703 let record = InstallRecord::read(&self.paths).ok().flatten();
4704 let removed_definition = record
4705 .as_ref()
4706 .and_then(|record| record.definition_path.clone());
4707
4708 let mut removed_registration = false;
4709 let mut foreign = None;
4716 for mode in [StartMode::Boot, StartMode::Login] {
4717 let control = self.controls.control(mode)?;
4718 if let Some(directories) = control
4719 .query(&self.identity)
4720 .ok()
4721 .flatten()
4722 .and_then(|registration| self.foreign_directories(®istration))
4723 {
4724 foreign = Some(directories);
4725 continue;
4726 }
4727 if control.uninstall(&self.identity)? {
4728 removed_registration = true;
4729 }
4730 }
4731 if !removed_registration && let Some(directories) = foreign {
4732 return Err(self.foreign_refusal("uninstall", directories));
4733 }
4734 let removed_record = InstallRecord::remove(&self.paths)?;
4735 Ok(Uninstalled {
4736 removed_registration,
4737 removed_record,
4738 removed_definition: removed_definition.filter(|_| removed_registration),
4739 preserved: self
4740 .paths
4741 .all()
4742 .iter()
4743 .map(|(_, path)| (*path).to_path_buf())
4744 .collect(),
4745 })
4746 }
4747
4748 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4770 let Some(record) = InstallRecord::read(&self.paths)? else {
4771 return Err(ServiceError::NotInstalled {
4772 name: self.identity.name().to_string(),
4773 operation: "switch the start mode of",
4774 });
4775 };
4776 let from = record.start_mode;
4777 if from == to {
4778 return Ok(StartModeChange {
4782 from,
4783 to,
4784 changed: false,
4785 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4786 runner_root: RootAccessSummary::NotApplicable,
4787 });
4788 }
4789
4790 self.refuse_foreign("switch the start mode of")?;
4793
4794 #[cfg(windows)]
4795 let arguments = {
4796 let mut arguments = record.arguments.clone();
4797 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4798 if to == StartMode::Boot {
4799 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4800 }
4801 arguments
4802 };
4803 #[cfg(not(windows))]
4804 let arguments = record.arguments.clone();
4805 let plan = InstallPlan::unchecked(
4806 self.identity.clone(),
4807 to,
4808 record.binary.clone(),
4809 record.directories.clone(),
4810 )
4811 .with_arguments(arguments)
4812 .with_restart(record.restart());
4813 let plan = if record.starts_on_demand {
4814 plan.started_on_demand()
4815 } else {
4816 plan
4817 };
4818 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4819 Ok(store) => plan.with_secret_guard(store.guard()),
4820 Err(_) => plan,
4821 };
4822
4823 let target = self.controls.control(to)?;
4830 let previous = self.controls.control(from)?;
4835
4836 let root = self.prepare_runner_root(to)?;
4844
4845 let definition = match target.install(&plan) {
4846 Ok(definition) => definition,
4847 Err(cause) => {
4848 return Err(undo_runner_root(
4849 &root,
4850 "switch start mode",
4851 &self.identity,
4852 cause,
4853 ));
4854 }
4855 };
4856 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4857 if let Err(cause) = next_record.write(&self.paths) {
4858 return Err(rolled_back(
4859 retained_runner_root(&root),
4860 target.uninstall(&self.identity),
4861 "switch start mode",
4862 &self.identity,
4863 cause,
4864 ));
4865 }
4866
4867 if let Err(cause) = previous.uninstall(&self.identity) {
4871 let target_rollback = target.uninstall(&self.identity);
4872 let record_rollback = record.write(&self.paths);
4873 return Err(rolled_back(
4874 retained_runner_root(&root),
4875 target_rollback.and(record_rollback),
4876 "switch start mode",
4877 &self.identity,
4878 cause,
4879 ));
4880 }
4881 Ok(StartModeChange {
4882 from,
4883 to,
4884 changed: true,
4885 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4886 runner_root: root.summary().clone(),
4887 })
4888 }
4889
4890 pub fn start(&self) -> Result<(), ServiceError> {
4896 let mode = self.own_registration_mode("start")?;
4897 self.controls.control(mode)?.start(&self.identity)
4898 }
4899
4900 fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
4908 match self.registration_for_status()? {
4909 None => Err(ServiceError::NotInstalled {
4910 name: self.identity.name().to_string(),
4911 operation,
4912 }),
4913 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4914 Some((registration, None)) => Ok(registration.start_mode),
4915 }
4916 }
4917
4918 pub fn stop(&self) -> Result<bool, ServiceError> {
4924 let mode = self.own_registration_mode("stop")?;
4925 self.controls.control(mode)?.stop(&self.identity)
4926 }
4927
4928 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4959 match self.registration_for_status()? {
4960 Some((registration, Some(directories))) => {
4961 Ok(self.foreign_status(registration, directories))
4962 }
4963 found => {
4964 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4965 Ok(record) => (record, None),
4966 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4967 Err(error) => return Err(error),
4968 };
4969 self.status_at(
4970 &self.paths,
4971 record,
4972 record_refused.as_ref(),
4973 found.map(|(registration, _)| registration),
4974 )
4975 }
4976 }
4977 }
4978
4979 fn status_at(
4982 &self,
4983 paths: &AppPaths,
4984 record: Option<InstallRecord>,
4985 record_refused: Option<&ServiceError>,
4986 found: Option<Registration>,
4987 ) -> Result<ServiceStatus, ServiceError> {
4988 let last_github_contact = last_github_contact(paths)?;
4989 let drift = record
4990 .as_ref()
4991 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4992 let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
4993 let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
4994 let probed = self.controls.unattended_login();
4995 let unattended = record.as_ref().and_then(|record| {
4996 let account = probed.as_ref().map_or_else(String::new, |_| {
4999 found
5000 .as_ref()
5001 .and_then(|registration| registration.account.clone())
5002 .unwrap_or_else(|| service_account_name(&record.account))
5003 });
5004 unattended_gap(record.start_mode, &account, probed.as_ref())
5005 });
5006 let now = Utc::now();
5007 let installed = record.is_some() || found.is_some();
5008 let status = ServiceStatus::compose(
5009 self.identity.clone(),
5010 record,
5011 record_refused,
5012 found,
5013 last_github_contact,
5014 paths,
5015 )
5016 .with_definition_drift(drift)
5017 .with_credential_rejection(credential_rejected_since)
5018 .with_credential_unreadable(credential_unreadable_since)
5019 .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5020 .with_unattended_gap(unattended)
5021 .with_host_unfit(
5022 crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5023 );
5024 let restart = status.restart_command();
5025 Ok(status.with_stall(
5026 crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5027 ))
5028 }
5029
5030 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5037 Ok(InstallRecord::read(&self.paths)?
5038 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5039 }
5040
5041 #[cfg(target_os = "macos")]
5063 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5064 let root = unsafe { libc::getuid() } == 0;
5070 if root != (drift.start_mode == StartMode::Boot) {
5071 return Err(ServiceError::Control {
5072 operation: "rewrite",
5073 name: self.identity.name().to_string(),
5074 manager: "launchd",
5075 detail: format!(
5076 "the install record says --start-at {}, but this daemon runs as {}; the plist \
5077 is left as it is",
5078 drift.start_mode,
5079 if root { "root" } else { "the operator" }
5080 ),
5081 });
5082 }
5083 replace_definition(self.identity.name(), drift)?;
5084 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5088 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5089 let _ = record.write(&self.paths);
5090 }
5091 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5092 }
5093
5094 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5104 #[cfg(not(windows))]
5105 {
5106 let _ = (mode, &self.runner_root);
5109 Ok(RootAccessChange::not_applicable())
5110 }
5111 #[cfg(windows)]
5112 {
5113 let wrap = |source| ServiceError::RunnerRoot {
5114 source: Box::new(source),
5115 };
5116 if let Some(root) = self
5134 .runner_root
5135 .as_ref()
5136 .filter(|_| self.identity.is_fixture() || cfg!(test))
5137 {
5138 let admission = RootAdmission::of_this_account().map_err(wrap)?;
5139 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5140 .map_err(wrap);
5141 }
5142
5143 let admission = match ServiceAccount::for_start_mode(mode) {
5144 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5147 ServiceAccount::InvokingUser | ServiceAccount::Root => {
5151 RootAdmission::of_this_account().map_err(wrap)?
5152 }
5153 };
5154 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5155 }
5156 }
5157
5158 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5160 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5161 |source| match source {
5162 held @ LockError::Held { .. } => ServiceError::LockHeld {
5163 source: Box::new(held),
5164 },
5165 other => ServiceError::LockUnreadable {
5166 source: Box::new(other),
5167 },
5168 },
5169 )
5170 }
5171
5172 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5174 for mode in [StartMode::Boot, StartMode::Login] {
5175 let control = self.controls.control(mode)?;
5176 if let Some(registration) = control.query(&self.identity)? {
5177 return Ok(Some((registration.start_mode, registration)));
5178 }
5179 }
5180 Ok(None)
5181 }
5182
5183 fn registration_for_status(
5192 &self,
5193 ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5194 let Some((mode, found)) = self.find_registration()? else {
5195 return Ok(None);
5196 };
5197 let Some(directories) = self.foreign_directories(&found) else {
5198 return Ok(Some((found, None)));
5199 };
5200 let other = match mode {
5201 StartMode::Boot => StartMode::Login,
5202 StartMode::Login => StartMode::Boot,
5203 };
5204 let own = self
5205 .controls
5206 .control(other)
5207 .ok()
5208 .and_then(|control| control.query(&self.identity).ok().flatten())
5209 .filter(|registration| self.foreign_directories(registration).is_none());
5210 Ok(Some(match own {
5211 Some(own) => (own, None),
5212 None => (found, Some(directories)),
5213 }))
5214 }
5215
5216 pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5234 #[cfg(target_os = "linux")]
5235 return self.adopt_boot_installation_on_linux(mode);
5236 #[cfg(not(target_os = "linux"))]
5237 {
5238 let _ = mode;
5239 Ok(false)
5240 }
5241 }
5242
5243 #[cfg(target_os = "linux")]
5244 fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5245 if mode != StartMode::Boot {
5246 return Ok(false);
5247 }
5248 self.refuse_overridden_account()?;
5249 if !crate::host_fitness::is_elevated() {
5250 return Ok(false);
5251 }
5252 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5253 return Ok(false);
5254 };
5255 let Some(old) = self.foreign_directories(&found) else {
5256 return Ok(false);
5257 };
5258 self.controls
5259 .control(StartMode::Boot)?
5260 .stop(&self.identity)?;
5261 if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5266 let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5267 for (from, to, skip) in [
5268 (&old.config, self.paths.config_dir(), None),
5269 (&old.state, self.paths.state_dir(), Some("bin")),
5271 ] {
5272 set_aside(to, &stamp)
5273 .and_then(|()| copy_tree(from, to, skip))
5274 .map_err(|error| ServiceError::Control {
5275 operation: "move the data of",
5276 name: self.identity.name().to_string(),
5277 manager: "systemd",
5278 detail: format!(
5279 "cannot copy {} to {}: {error}",
5280 from.display(),
5281 to.display()
5282 ),
5283 })?;
5284 }
5285 }
5286 Ok(true)
5287 }
5288
5289 #[cfg(target_os = "linux")]
5296 fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5297 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5298 return Ok(());
5299 };
5300 match found.account.as_deref() {
5301 Some(account) if account != ServiceAccount::Root.as_str() => {
5302 Err(ServiceError::Control {
5303 operation: "install",
5304 name: self.identity.name().to_string(),
5305 manager: "systemd",
5306 detail: format!(
5307 "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5308 a boot service must run as root to use {}. Remove `User=` and `Group=` \
5309 from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5310 self.identity.systemd_unit(),
5311 crate::paths::SYSTEM_DATA_ROOT,
5312 ),
5313 })
5314 }
5315 _ => Ok(()),
5316 }
5317 }
5318
5319 fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5322 match self.registration_for_status()? {
5323 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5324 _ => Ok(()),
5325 }
5326 }
5327
5328 fn foreign_refusal(
5331 &self,
5332 operation: &'static str,
5333 directories: ServiceDirectories,
5334 ) -> ServiceError {
5335 let foreign = self.foreign_service_at(directories);
5336 ServiceError::ForeignInstallation {
5337 name: self.identity.name().to_string(),
5338 operation,
5339 installed_by: foreign.installed_by(),
5340 act_as: foreign.act_as(),
5341 }
5342 }
5343
5344 fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5347 ServiceDirectories::from_command_line(®istration.command_line)
5348 .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5349 }
5350
5351 #[must_use]
5356 pub fn foreign_service(&self) -> Option<ForeignService> {
5357 let (_, directories) = self.registration_for_status().ok()??;
5358 Some(self.foreign_service_at(directories?))
5359 }
5360
5361 fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5364 let root = directories.root();
5365 let home = host_home();
5366 let this_account = home
5369 .as_deref()
5370 .filter(|home| contains_directory(home, &root))
5371 .and_then(|_| {
5372 if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5373 Some(format!(
5374 "with `--data-dir {}`",
5375 quote_argument(&root.to_string_lossy())
5376 ))
5377 } else {
5378 AppPaths::discover()
5379 .ok()
5380 .filter(|standard| directories == ServiceDirectories::of(standard))
5381 .map(|_| "without `--data-dir`".to_owned())
5382 }
5383 });
5384 ForeignService {
5385 owner: data_root_owner(&root, home.as_deref()),
5386 this_account,
5387 root,
5388 directories,
5389 local_root: ServiceDirectories::of(&self.paths).root(),
5390 local_record: InstallRecord::path(&self.paths),
5391 unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5392 unreadable: None,
5393 }
5394 }
5395
5396 fn foreign_status(
5403 &self,
5404 registration: Registration,
5405 directories: ServiceDirectories,
5406 ) -> ServiceStatus {
5407 let service_paths = directories.paths();
5408 let mut foreign = self.foreign_service_at(directories);
5409 let unreadable = match InstallRecord::read(&service_paths) {
5410 Ok(Some(record)) => {
5411 match self.status_at(
5412 &service_paths,
5413 Some(record),
5414 None,
5415 Some(registration.clone()),
5416 ) {
5417 Ok(status) => return status.with_foreign(foreign),
5418 Err(error) => error.to_string(),
5419 }
5420 }
5421 Ok(None) => format!(
5422 "there is no install record at {}",
5423 InstallRecord::path(&service_paths).display()
5424 ),
5425 Err(
5430 ServiceError::RecordNotPermitted { path, detail }
5431 | ServiceError::RecordUnreadable { path, detail }
5432 | ServiceError::Record { path, detail, .. },
5433 ) => format!("this account cannot read {} ({detail})", path.display()),
5434 Err(error) => error.to_string(),
5435 };
5436 foreign.unreadable = Some(unreadable);
5437 ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5438 }
5439
5440 pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5464 let config = self.paths.config_dir();
5465 let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5466 let mut service_root = None;
5467 for mode in [StartMode::Boot, StartMode::Login] {
5468 let registration = self
5469 .controls
5470 .control(mode)
5471 .and_then(|control| control.query(&self.identity))
5472 .map_err(|error| {
5473 in_use(format!(
5474 "whether a {mode} registration runs against it could not be read ({error})"
5475 ))
5476 })?;
5477 let Some(registration) = registration else {
5478 continue;
5479 };
5480 let Some(directories) = self.foreign_directories(®istration) else {
5481 return Err(in_use(format!(
5482 "{} holds a registration that runs against {}. `service status` describes it",
5483 registration.manager,
5484 config.display()
5485 )));
5486 };
5487 if let Some(overlap) = directories.all().into_iter().find(|directory| {
5488 contains_directory(directory, config) || contains_directory(config, directory)
5489 }) {
5490 return Err(in_use(format!(
5491 "the machine's service runs against {}, which overlaps {}",
5492 overlap.display(),
5493 config.display()
5494 )));
5495 }
5496 service_root.get_or_insert_with(|| directories.root());
5497 }
5498 let _lock =
5499 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5500 in_use(format!(
5501 "an agent may be running against these directories: {source}"
5502 ))
5503 })?;
5504 let failed = |operation: &'static str, path: &Path| {
5505 let path = path.to_path_buf();
5506 move |error: std::io::Error| ServiceError::Record {
5507 operation,
5508 path,
5509 detail: error.to_string(),
5510 }
5511 };
5512 let kept = [
5514 self.paths.state_dir(),
5515 self.paths.runtime_dir(),
5516 self.paths.logs_dir(),
5517 ];
5518 let mut entries = Vec::new();
5519 for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5520 let entry = entry.map_err(failed("list", config))?.path();
5521 let archived = entry
5522 .file_name()
5523 .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5524 if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5525 entries.push(entry);
5526 }
5527 }
5528 if entries.is_empty() {
5529 return Ok(ForgottenLocal {
5530 archive: None,
5531 moved: entries,
5532 service_root,
5533 });
5534 }
5535 entries.sort();
5536 let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5537 std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5538 let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5539 for (index, entry) in entries.iter().enumerate() {
5540 if let Err(error) = std::fs::rename(entry, inside(entry)) {
5541 let stranded: Vec<String> = entries[..index]
5542 .iter()
5543 .rev()
5544 .filter(|done| std::fs::rename(inside(done), done).is_err())
5545 .map(|done| inside(done).display().to_string())
5546 .collect();
5547 let _ = std::fs::remove_dir(&archive);
5548 let mut detail = error.to_string();
5549 if !stranded.is_empty() {
5550 detail.push_str(&format!(
5551 "; these could not be put back and are still in the archive: {}",
5552 stranded.join(", ")
5553 ));
5554 }
5555 return Err(ServiceError::Record {
5556 operation: "archive",
5557 path: entry.clone(),
5558 detail,
5559 });
5560 }
5561 }
5562 Ok(ForgottenLocal {
5563 archive: Some(archive),
5564 moved: entries,
5565 service_root,
5566 })
5567 }
5568}
5569
5570pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5573
5574#[derive(Debug, Clone, PartialEq, Eq)]
5576pub struct ForgottenLocal {
5577 pub archive: Option<PathBuf>,
5579 pub moved: Vec<PathBuf>,
5581 pub service_root: Option<PathBuf>,
5584}
5585
5586impl fmt::Display for ForgottenLocal {
5587 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5588 let Some(archive) = &self.archive else {
5589 return write!(
5590 f,
5591 "Nothing to archive: this account's configuration directory is empty."
5592 );
5593 };
5594 writeln!(f, "Archived this account's unused local configuration")?;
5595 writeln!(f, " archive {}", archive.display())?;
5596 for path in &self.moved {
5597 writeln!(f, " moved {}", path.display())?;
5598 }
5599 if let Some(root) = &self.service_root {
5600 writeln!(
5601 f,
5602 " service data root {} (untouched)",
5603 root.display()
5604 )?;
5605 }
5606 write!(
5607 f,
5608 " restore move everything in the archive back into its parent \
5609 directory"
5610 )
5611 }
5612}
5613
5614fn same_directory(left: &Path, right: &Path) -> bool {
5617 let text = |path: &Path| {
5618 path.to_string_lossy()
5619 .trim_end_matches(['/', '\\'])
5620 .to_owned()
5621 };
5622 same_path_text(&text(left), &text(right))
5623 || matches!(
5624 (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5625 (Ok(left), Ok(right)) if left == right
5626 )
5627}
5628
5629const REMEDY_OPENINGS: [&str; 8] = [
5632 ". Run ",
5633 ". If nothing is waiting",
5634 ". Re-register",
5635 ". Switch the start mode",
5636 " Sign in again",
5637 ". Fix: ",
5638 "; to do it now",
5639 "; if the service manager denies",
5640];
5641
5642fn diagnosis_only(detail: &str) -> String {
5651 let cut = REMEDY_OPENINGS
5652 .iter()
5653 .filter_map(|opening| detail.find(opening))
5654 .min()
5655 .unwrap_or(detail.len());
5656 let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5657 let names_a_change = |text: &str| {
5658 [
5659 "service install",
5660 "service uninstall",
5661 "auth login",
5662 "launchctl",
5663 "systemctl",
5664 ]
5665 .iter()
5666 .any(|command| text.contains(command))
5667 };
5668 if !names_a_change(diagnosis) {
5669 return diagnosis.to_owned();
5670 }
5671 let end = [", ", "; ", ". "]
5673 .iter()
5674 .filter_map(|separator| diagnosis.find(separator))
5675 .min()
5676 .unwrap_or(diagnosis.len());
5677 let first = &diagnosis[..end];
5678 if names_a_change(first) {
5679 "the installation's own records report this".to_owned()
5680 } else {
5681 first.to_owned()
5682 }
5683}
5684
5685fn path_key(path: &Path) -> Vec<String> {
5688 path.components()
5689 .map(|component| {
5690 let text = component.as_os_str().to_string_lossy();
5691 if cfg!(windows) {
5692 text.to_lowercase()
5693 } else {
5694 text.into_owned()
5695 }
5696 })
5697 .collect()
5698}
5699
5700fn contains_directory(outer: &Path, inner: &Path) -> bool {
5703 path_key(inner).starts_with(&path_key(outer))
5704 || matches!(
5705 (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5706 (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5707 )
5708}
5709
5710#[must_use]
5715pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5716 let target = path_key(root);
5717 if cfg!(windows) {
5718 if target
5719 .windows(3)
5720 .any(|run| run == ["system32", "config", "systemprofile"])
5721 {
5722 return Some("LocalSystem".to_owned());
5723 }
5724 } else if [
5725 "/root",
5726 "/var/root",
5727 "/private/var/root",
5728 crate::paths::SYSTEM_DATA_ROOT,
5729 ]
5730 .iter()
5731 .any(|home| root.starts_with(home))
5732 {
5733 return Some("root".to_owned());
5734 }
5735 let users = path_key(home?.parent()?);
5736 if users.len() < 2 {
5739 return None;
5740 }
5741 if target.len() > users.len() && target[..users.len()] == users[..] {
5742 root.components()
5743 .nth(users.len())
5744 .map(|name| name.as_os_str().to_string_lossy().into_owned())
5745 } else {
5746 None
5747 }
5748}
5749
5750#[derive(Debug, Clone, PartialEq, Eq)]
5764pub struct ForeignService {
5765 pub directories: ServiceDirectories,
5767 pub root: PathBuf,
5769 pub owner: Option<String>,
5771 pub this_account: Option<String>,
5776 pub local_root: PathBuf,
5778 pub local_record: PathBuf,
5780 pub unused_local_record: Option<InstallRecord>,
5782 pub unreadable: Option<String>,
5785}
5786
5787impl ForeignService {
5788 #[must_use]
5790 pub fn where_to_look(&self) -> String {
5791 if let Some(reach) = &self.this_account {
5792 return reach.clone();
5793 }
5794 let elevated = if cfg!(windows) {
5795 "from an elevated terminal (Run as administrator)"
5796 } else {
5797 "with sudo"
5798 };
5799 match &self.owner {
5800 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5801 format!("{elevated}, or signed in as {owner}")
5802 }
5803 _ => elevated.to_owned(),
5804 }
5805 }
5806
5807 #[must_use]
5812 pub fn act_as(&self) -> String {
5813 if let Some(reach) = &self.this_account {
5814 return reach.clone();
5815 }
5816 match &self.owner {
5817 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5818 format!("signed in as {owner}")
5819 }
5820 _ => format!(
5821 "from the account whose data root {} is",
5822 self.root.display()
5823 ),
5824 }
5825 }
5826
5827 #[must_use]
5830 pub const fn is_unread(&self) -> bool {
5831 self.unreadable.is_some()
5832 }
5833
5834 #[must_use]
5836 pub fn installed_by(&self) -> String {
5837 match &self.owner {
5838 Some(owner) => format!("{owner}'s data root {}", self.root.display()),
5839 None => format!("the data root {}", self.root.display()),
5840 }
5841 }
5842
5843 #[must_use]
5845 pub fn label(&self) -> String {
5846 match &self.owner {
5847 Some(owner) => format!("{} ({owner})", self.root.display()),
5848 None => self.root.display().to_string(),
5849 }
5850 }
5851
5852 #[must_use]
5854 pub fn unused_local_note(&self) -> Option<String> {
5855 let record = self.unused_local_record.as_ref()?;
5856 Some(format!(
5857 "unused local configuration from an earlier install: {} records a {} registration \
5858 made by runner-manager {} on {}, and no service on this machine runs against it. \
5859 `runner-manager host forget-local` moves it aside; it never touches the service's \
5860 own directories or any secret.",
5861 self.local_record.display(),
5862 record.start_mode,
5863 record.installed_by_version,
5864 record.installed_at.format("%Y-%m-%d"),
5865 ))
5866 }
5867}
5868
5869#[derive(Debug, Clone, PartialEq, Eq)]
5875pub struct StatusProblem {
5876 pub subject: &'static str,
5878 pub detail: String,
5880}
5881
5882impl fmt::Display for StatusProblem {
5883 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5884 write!(f, "{}: {}", self.subject, self.detail)
5885 }
5886}
5887
5888#[derive(Debug, Clone)]
5897pub struct ServiceStatus {
5898 identity: ServiceIdentity,
5899 record: Option<InstallRecord>,
5900 registration: Option<Registration>,
5901 binary: Option<BinaryPath>,
5902 log_file: PathBuf,
5903 store: Option<crate::secrets::ActiveStore>,
5904 last_github_contact: Option<DateTime<Utc>>,
5905 runner_root: Option<(PathBuf, RootAccessReport)>,
5906 credential_rejected_since: Option<DateTime<Utc>>,
5907 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
5908 unattended_gap: Option<UnattendedGap>,
5909 foreign: Option<ForeignService>,
5910 problems: Vec<StatusProblem>,
5911 notes: Vec<String>,
5912}
5913
5914#[derive(Debug, Clone, PartialEq, Eq)]
5917pub struct UnattendedGap {
5918 pub reason: String,
5920 pub remedy: String,
5922}
5923
5924#[must_use]
5927pub fn service_account_name(account: &ServiceAccount) -> String {
5928 match account {
5929 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
5931 .ok()
5932 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
5933 .or_else(crate::unattended_login::current_account)
5934 .unwrap_or_else(|| "the operator".to_owned()),
5935 other => other.as_str().to_owned(),
5936 }
5937}
5938
5939fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
5942 registration
5943 .and_then(|registration| registration.account.as_deref())
5944 .unwrap_or(recorded)
5945}
5946
5947#[cfg(any(windows, test))]
5951fn windows_service_account(name: &str) -> String {
5952 if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
5953 ServiceAccount::LocalSystem.as_str().to_string()
5954 } else {
5955 name.to_string()
5956 }
5957}
5958
5959#[cfg(any(windows, test))]
5962fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
5963 user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
5964}
5965
5966#[must_use]
5975pub fn unattended_gap(
5976 mode: StartMode,
5977 account: &str,
5978 found: Option<&crate::unattended_login::UnattendedLogin>,
5979) -> Option<UnattendedGap> {
5980 let Some(found) = found else {
5981 return (mode == StartMode::Login).then(|| UnattendedGap {
5982 reason: "This registration starts at login, so the agent does not run until the \
5983 operator signs in; this host does not resume work after an unattended \
5984 reboot."
5985 .to_owned(),
5986 remedy: "For machine-on operation, move the service and credential to `--start-at \
5987 boot` from an elevated terminal."
5988 .to_owned(),
5989 });
5990 };
5991 let resume = crate::unattended_login::resume(found, mode, account);
5992 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
5993 return None;
5996 }
5997 Some(UnattendedGap {
5998 reason: resume.explain(account)?,
5999 remedy: resume.remedy().unwrap_or_else(|| {
6000 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6001 FileVault can be read."
6002 .to_owned()
6003 }),
6004 })
6005}
6006
6007impl ServiceStatus {
6008 fn compose(
6013 identity: ServiceIdentity,
6014 record: Option<InstallRecord>,
6015 record_refused: Option<&ServiceError>,
6016 registration: Option<Registration>,
6017 last_github_contact: Option<DateTime<Utc>>,
6018 paths: &AppPaths,
6019 ) -> Self {
6020 let mut problems = Vec::new();
6021 let mut notes = Vec::new();
6022
6023 if let Some(refusal) = record_refused {
6024 problems.push(StatusProblem {
6029 subject: "install record",
6030 detail: refusal.to_string(),
6031 });
6032 }
6033
6034 let log_file = record.as_ref().map_or_else(
6035 || ServiceDirectories::of(paths).log_file(),
6036 |record| record.log_file.clone(),
6037 );
6038
6039 let binary = record.as_ref().map(|record| {
6040 inspect_binary(
6041 &record.binary,
6042 registration
6043 .as_ref()
6044 .and_then(Registration::binary)
6045 .as_deref(),
6046 )
6047 });
6048 if let Some(state) = &binary
6049 && state.is_error()
6050 {
6051 problems.push(StatusProblem {
6052 subject: "binary",
6053 detail: state.to_string(),
6054 });
6055 }
6056
6057 match (&record, ®istration) {
6058 (Some(_), None) => problems.push(StatusProblem {
6059 subject: "registration",
6060 detail: "this host has a service record but no service manager knows the \
6061 registration. Run `service install` again; it deletes nothing."
6062 .to_string(),
6063 }),
6064 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6069 subject: "record",
6070 detail: format!(
6071 "{} holds a registration for this service but there is no install record, so \
6072 the path it was installed from and the directories it was installed against \
6073 are unknown. Run `service uninstall` and `service install`.",
6074 found.manager
6075 ),
6076 }),
6077 (None, Some(_)) => {}
6078 (Some(record), Some(found)) => {
6079 if record.start_mode != found.start_mode {
6080 problems.push(StatusProblem {
6081 subject: "start mode",
6082 detail: format!(
6083 "the record says {} and {} holds a {} registration. Switch the start \
6084 mode again to make them agree.",
6085 record.start_mode, found.manager, found.start_mode
6086 ),
6087 });
6088 }
6089 if record.start_mode == StartMode::Boot && !found.starts_automatically {
6090 problems.push(StatusProblem {
6091 subject: "start mode",
6092 detail: format!(
6093 "{} holds the registration but will not start it by itself, so this \
6094 host does not resume work after a reboot.",
6095 found.manager
6096 ),
6097 });
6098 }
6099 if let Some(actual) = found.restart_delay {
6100 let expected = record.restart().effective_delay(found.manager);
6101 if actual != expected {
6102 problems.push(StatusProblem {
6103 subject: "restart policy",
6104 detail: format!(
6105 "the record says the service restarts after {}s and {} reports \
6106 {}s. Something has edited the registration since it was \
6107 installed.",
6108 expected.as_secs(),
6109 found.manager,
6110 actual.as_secs()
6111 ),
6112 });
6113 } else if expected != record.restart().delay() {
6114 notes.push(format!(
6119 "{} expresses the restart delay in whole minutes, so the {}s asked \
6120 for is enforced as {}s. The service therefore never restarts faster \
6121 than the configured bound.",
6122 found.manager,
6123 record.restart().delay().as_secs(),
6124 expected.as_secs()
6125 ));
6126 }
6127 }
6128 if found.starts_automatically && !found.running {
6129 problems.push(StatusProblem {
6130 subject: "runtime",
6131 detail: format!(
6132 "{} holds an automatic registration, but the daemon is stopped. Run \
6133 `runner-manager service install --start-at {}`; if the service \
6134 manager denies replacement, retry from an elevated terminal. If it \
6135 stops again, inspect {}.",
6136 found.manager,
6137 found.start_mode,
6138 log_file.display()
6139 ),
6140 });
6141 }
6142 }
6143 (None, None) => {}
6144 }
6145
6146 let store = record.as_ref().and_then(|record| {
6151 let registered_mode = registration
6152 .as_ref()
6153 .map_or(record.start_mode, |found| found.start_mode);
6154 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6155 .ok()
6156 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6157 });
6158 if let Some(store) = &store
6159 && !store.agrees_with_start_mode()
6160 {
6161 problems.push(StatusProblem {
6162 subject: "secret store",
6163 detail: format!(
6164 "{store}. Run `auth login` again so the token is stored where the registered \
6165 start mode can read it."
6166 ),
6167 });
6168 }
6169
6170 if last_github_contact.is_none() {
6171 notes.push(
6172 "GitHub has not been reached successfully since this host's state directory was \
6173 created."
6174 .to_string(),
6175 );
6176 }
6177
6178 let runner_root = crate::runner_root::default_runner_root(paths)
6185 .ok()
6186 .map(|root| {
6187 let path = root.as_path().to_path_buf();
6188 let report = crate::runner_root_access::report(&path);
6189 (path, report)
6190 });
6191 if let Some((
6201 path,
6202 RootAccessReport::Present {
6203 broad_write: true, ..
6204 },
6205 )) = &runner_root
6206 {
6207 notes.push(format!(
6208 "the platform default runner root {} can be written by ordinary local users, so \
6209 it is not a safe place to run jobs. `service install` refuses it rather than \
6210 tightening it, because the contents of a directory anybody could write cannot \
6211 be trusted: remove or empty it, or choose another root with `runner-manager \
6212 host set-runtime-root --path <PATH>`.",
6213 path.display()
6214 ));
6215 }
6216
6217 match runner_root_refusals(paths) {
6240 Ok(refusals) => {
6241 for refusal in refusals {
6242 notes.push(format!(
6243 "policy {} started no runner: its runner root {} refused the launch \
6244 ({}), last at {}. {} This clears when that policy next places a \
6245 runner.",
6246 refusal.policy,
6247 refusal.root,
6248 refusal.kind,
6249 refusal.at.to_rfc3339(),
6250 refusal.detail,
6251 ));
6252 }
6253 }
6254 Err(error) => notes.push(format!(
6255 "whether the agent could use its runner roots could not be read: {error}"
6256 )),
6257 }
6258
6259 Self {
6260 identity,
6261 record,
6262 registration,
6263 binary,
6264 log_file,
6265 store,
6266 last_github_contact,
6267 runner_root,
6268 credential_rejected_since: None,
6269 launches_blocked: None,
6270 unattended_gap: None,
6271 foreign: None,
6272 problems,
6273 notes,
6274 }
6275 }
6276
6277 fn with_foreign(mut self, foreign: ForeignService) -> Self {
6288 let act_as = foreign.act_as();
6289 for problem in &mut self.problems {
6290 problem.detail = format!(
6291 "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6292 own directories, not the service's.",
6293 diagnosis_only(&problem.detail)
6294 );
6295 }
6296 if let Some(note) = foreign.unused_local_note() {
6297 self.notes.push(note);
6298 }
6299 self.foreign = Some(foreign);
6300 self
6301 }
6302
6303 fn foreign_unread(
6311 identity: ServiceIdentity,
6312 registration: Registration,
6313 foreign: ForeignService,
6314 ) -> Self {
6315 let mut problems = Vec::new();
6316 let look = foreign.where_to_look();
6317 if registration.starts_automatically && !registration.running {
6318 problems.push(StatusProblem {
6319 subject: "runtime",
6320 detail: format!(
6321 "{} holds an automatic registration for the service installed from {}, but \
6322 it is stopped. Run `runner-manager service status` {look} to see why; do not \
6323 reinstall it from this account.",
6324 registration.manager,
6325 foreign.installed_by(),
6326 ),
6327 });
6328 }
6329 if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6330 problems.push(StatusProblem {
6331 subject: "start mode",
6332 detail: format!(
6333 "{} holds the boot registration installed from {} but will not start it by \
6334 itself. Run `runner-manager service status` {look}.",
6335 registration.manager,
6336 foreign.installed_by(),
6337 ),
6338 });
6339 }
6340 let mut notes = Vec::new();
6341 if let Some(reason) = &foreign.unreadable {
6342 notes.push(format!(
6343 "its health is not known here: {reason}. Run `runner-manager service status` \
6344 {look} to see it."
6345 ));
6346 }
6347 notes.extend(foreign.unused_local_note());
6348 Self {
6349 identity,
6350 record: None,
6351 log_file: foreign.directories.log_file(),
6352 registration: Some(registration),
6353 binary: None,
6354 store: None,
6355 last_github_contact: None,
6356 runner_root: None,
6357 credential_rejected_since: None,
6358 launches_blocked: None,
6359 unattended_gap: None,
6360 foreign: Some(foreign),
6361 problems,
6362 notes,
6363 }
6364 }
6365
6366 #[must_use]
6369 pub const fn foreign(&self) -> Option<&ForeignService> {
6370 self.foreign.as_ref()
6371 }
6372
6373 #[must_use]
6376 pub fn is_unread(&self) -> bool {
6377 self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6378 }
6379
6380 #[must_use]
6382 pub fn verdict(&self) -> String {
6383 if !self.is_healthy() {
6384 return "NOT healthy".to_owned();
6385 }
6386 match &self.foreign {
6387 Some(foreign) if foreign.is_unread() => format!(
6388 "{}; installed from {}, so its health is read {}",
6389 if self.is_running() {
6390 "running"
6391 } else {
6392 "not running"
6393 },
6394 foreign.installed_by(),
6395 foreign.where_to_look()
6396 ),
6397 _ => "healthy".to_owned(),
6398 }
6399 }
6400
6401 #[must_use]
6403 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6404 if let Some(drift) = drift {
6405 if drift.manager == DefinitionKind::LaunchdPlist {
6406 let restart = match drift.start_mode {
6410 StartMode::Boot => format!(
6411 "sudo launchctl kickstart -k system/{}",
6412 self.identity.launchd_label()
6413 ),
6414 StartMode::Login => format!(
6415 "launchctl kickstart -k gui/$(id -u)/{}",
6416 self.identity.launchd_label()
6417 ),
6418 };
6419 self.problems.push(StatusProblem {
6420 subject: "definition",
6421 detail: format!(
6422 "{} was written by runner-manager {} with a `ProcessType` other than \
6423 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6424 daemon starts, so their jobs run below normal priority. The daemon \
6425 rewrites it and reloads itself when it starts with no runner, which it \
6426 does after every upgrade; to do it now, restart the service while no \
6427 job is running: `{restart}`. Configuration and credentials are kept.",
6428 drift.path.display(),
6429 drift.installed_by_version,
6430 ),
6431 });
6432 return self;
6433 }
6434 let repair = match drift.start_mode {
6435 StartMode::Boot => format!(
6436 "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6437 `runner-manager wsl install --distribution <name>` from Windows)",
6438 sudo_command("service install --start-at boot")
6439 ),
6440 StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6441 service install --start-at login`"
6442 .to_owned(),
6443 };
6444 self.problems.push(StatusProblem {
6445 subject: "definition",
6446 detail: format!(
6447 "{} was written by runner-manager {} and differs from what {} renders, so \
6448 the daemon runs under outdated settings (for example a credential frozen \
6449 at service start). Re-register it with {repair}; configuration and \
6450 credentials are kept.",
6451 drift.path.display(),
6452 drift.installed_by_version,
6453 env!("CARGO_PKG_VERSION"),
6454 ),
6455 });
6456 }
6457 self
6458 }
6459
6460 #[must_use]
6462 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6463 if let Some(since) = since {
6464 self.credential_rejected_since = Some(since);
6465 self.problems.push(StatusProblem {
6466 subject: "github credential",
6467 detail: format!(
6468 "GitHub has rejected the service's credential since {}, so the service \
6469 cannot see queued jobs or start runners. Run `runner-manager auth login` \
6470 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6471 from Windows).",
6472 since.to_rfc3339()
6473 ),
6474 });
6475 }
6476 self
6477 }
6478
6479 #[must_use]
6481 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6482 self.credential_rejected_since
6483 }
6484
6485 #[must_use]
6488 pub fn with_launches_blocked(
6489 mut self,
6490 blocked: Option<crate::launch_health::LaunchesBlocked>,
6491 ) -> Self {
6492 if let Some(blocked) = &blocked {
6493 self.problems.push(StatusProblem {
6494 subject: LAUNCHES_BLOCKED_SUBJECT,
6495 detail: format!(
6496 "the service has started no runner {blocked}. Fix: {}.",
6497 blocked.remedy
6498 ),
6499 });
6500 }
6501 self.launches_blocked = blocked;
6502 self
6503 }
6504
6505 #[must_use]
6507 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6508 self.launches_blocked.as_ref()
6509 }
6510
6511 #[must_use]
6514 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6515 if let Some(since) = since {
6516 let mode = self.start_mode().unwrap_or_default();
6517 self.problems.push(StatusProblem {
6518 subject: CREDENTIAL_UNREADABLE_SUBJECT,
6519 detail: format!(
6520 "the service has been unable to read its stored GitHub credential since {}, \
6521 so it starts no runner; the diagnostic log has the store's own answer.{} \
6522 Sign in again: {}.",
6523 since.to_rfc3339(),
6524 if cfg!(target_os = "macos") {
6525 " That is what a keychain item written by a different build looks like."
6526 } else {
6527 ""
6528 },
6529 sign_in_instruction(mode)
6530 ),
6531 });
6532 }
6533 self
6534 }
6535
6536 #[must_use]
6544 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6545 self.runner_root
6546 .as_ref()
6547 .map(|(path, report)| (path.as_path(), report))
6548 }
6549
6550 #[must_use]
6552 pub const fn is_installed(&self) -> bool {
6553 self.registration.is_some() || self.record.is_some()
6554 }
6555
6556 #[must_use]
6558 pub fn is_running(&self) -> bool {
6559 self.registration
6560 .as_ref()
6561 .is_some_and(|registration| registration.running)
6562 }
6563
6564 #[must_use]
6566 pub fn is_healthy(&self) -> bool {
6567 self.problems.is_empty()
6568 }
6569
6570 #[must_use]
6572 pub fn problems(&self) -> &[StatusProblem] {
6573 &self.problems
6574 }
6575
6576 #[must_use]
6579 pub fn notes(&self) -> &[String] {
6580 &self.notes
6581 }
6582
6583 #[must_use]
6588 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6589 if let Some(unfit) = unfit {
6590 self.problems.push(StatusProblem {
6591 subject: HOST_UNFIT_SUBJECT,
6592 detail: format!(
6593 "the service has started no runner since {}, because a required host check \
6594 fails from its own session: {}",
6595 unfit.since.to_rfc3339(),
6596 unfit.describe()
6597 ),
6598 });
6599 }
6600 self
6601 }
6602
6603 #[must_use]
6607 pub fn with_stall(mut self, stall: Option<String>) -> Self {
6608 if let Some(detail) = stall {
6609 self.problems.push(StatusProblem {
6610 subject: DAEMON_STALLED_SUBJECT,
6611 detail,
6612 });
6613 }
6614 self
6615 }
6616
6617 #[must_use]
6620 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6621 if let Some(gap) = &gap {
6622 self.notes.push(gap.reason.clone());
6623 }
6624 self.unattended_gap = gap;
6625 self
6626 }
6627
6628 #[must_use]
6631 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6632 self.unattended_gap.as_ref()
6633 }
6634
6635 #[must_use]
6638 pub fn restart_command(&self) -> String {
6639 self.identity
6640 .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6641 }
6642
6643 #[must_use]
6647 pub fn start_mode(&self) -> Option<StartMode> {
6648 self.record
6649 .as_ref()
6650 .map(|record| record.start_mode)
6651 .or_else(|| {
6652 self.registration
6653 .as_ref()
6654 .filter(|_| self.is_unread())
6655 .map(|registration| registration.start_mode)
6656 })
6657 }
6658
6659 #[must_use]
6663 pub fn service_binary(&self) -> Option<PathBuf> {
6664 self.record
6665 .as_ref()
6666 .map(|record| record.binary.clone())
6667 .or_else(|| {
6668 self.registration
6669 .as_ref()
6670 .filter(|_| self.is_unread())
6671 .and_then(Registration::binary)
6672 })
6673 }
6674
6675 #[must_use]
6678 pub const fn binary(&self) -> Option<&BinaryPath> {
6679 self.binary.as_ref()
6680 }
6681
6682 #[must_use]
6684 pub fn log_file(&self) -> &Path {
6685 &self.log_file
6686 }
6687
6688 #[must_use]
6690 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6691 self.last_github_contact
6692 }
6693
6694 #[must_use]
6697 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6698 self.store.as_ref()
6699 }
6700
6701 #[must_use]
6703 pub const fn record(&self) -> Option<&InstallRecord> {
6704 self.record.as_ref()
6705 }
6706
6707 #[must_use]
6709 pub const fn registration(&self) -> Option<&Registration> {
6710 self.registration.as_ref()
6711 }
6712}
6713
6714impl fmt::Display for ServiceStatus {
6715 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6716 writeln!(f, "Service: {}", self.identity)?;
6717 match (&self.record, &self.registration) {
6718 (None, None) => {
6719 writeln!(
6720 f,
6721 " installed no. `service install` registers `{} {}`.",
6722 SERVICE_NAME,
6723 DAEMON_ARGUMENTS.join(" ")
6724 )?;
6725 }
6726 _ => {
6727 let manager = self
6728 .registration
6729 .as_ref()
6730 .map(|registration| registration.manager.manager());
6731 writeln!(
6732 f,
6733 " installed {}",
6734 manager.unwrap_or("yes, but no service manager knows it")
6735 )?;
6736 writeln!(
6737 f,
6738 " state {}",
6739 if self.is_running() {
6740 "running"
6741 } else {
6742 "not running"
6743 }
6744 )?;
6745 }
6746 }
6747 if let Some(foreign) = &self.foreign {
6748 writeln!(
6749 f,
6750 " installed from {} -- another installation's, not this account's ({})",
6751 foreign.installed_by(),
6752 foreign.local_root.display()
6753 )?;
6754 if foreign.is_unread()
6756 && let Some(registration) = &self.registration
6757 {
6758 writeln!(f, " start mode {}", registration.start_mode)?;
6759 if let Some(account) = ®istration.account {
6760 writeln!(f, " account {account}")?;
6761 }
6762 if let Some(binary) = self.service_binary() {
6763 writeln!(f, " binary {}", binary.display())?;
6764 }
6765 }
6766 }
6767 if let Some(record) = &self.record {
6768 writeln!(f, " start mode {}", record.start_mode)?;
6769 let recorded = record.account.to_string();
6770 let account = registered_account(self.registration.as_ref(), &recorded);
6771 writeln!(f, " account {account}")?;
6772 writeln!(f, " restart on failure {}", record.restart())?;
6773 writeln!(
6774 f,
6775 " arguments {}",
6776 record.arguments.join(" ")
6777 )?;
6778 }
6779 if let Some(binary) = &self.binary {
6780 writeln!(f, " binary {binary}")?;
6781 }
6782 writeln!(f, " diagnostic log {}", self.log_file.display())?;
6783 if let Some((path, report)) = &self.runner_root {
6784 writeln!(f, " default runner root {}", path.display())?;
6791 if *report != RootAccessReport::NotApplicable {
6792 writeln!(f, " default root access {report}")?;
6793 }
6794 }
6795 if let Some(store) = &self.store {
6796 writeln!(f, " secret store {store}")?;
6797 }
6798 if !self.is_unread() {
6801 writeln!(
6802 f,
6803 " last GitHub contact {}",
6804 match self.last_github_contact {
6805 Some(at) => at.to_rfc3339(),
6806 None => "never".to_string(),
6807 }
6808 )?;
6809 }
6810 for note in &self.notes {
6811 writeln!(f, " note {note}")?;
6812 }
6813 for problem in &self.problems {
6814 writeln!(f, " ERROR {problem}")?;
6815 }
6816 write!(f, " verdict {}", self.verdict())
6817 }
6818}
6819
6820#[derive(Debug, Clone, Default)]
6837pub struct RecordingControls {
6838 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6839}
6840
6841#[derive(Debug, Default)]
6842struct RecordingState {
6843 registrations: BTreeMap<(StartMode, String), Registration>,
6844 definitions: BTreeMap<String, ServiceDefinition>,
6845 calls: Vec<String>,
6846 #[cfg(test)]
6847 install_failures: BTreeMap<StartMode, String>,
6848 #[cfg(test)]
6849 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
6850}
6851
6852#[cfg(test)]
6853#[derive(Debug, Clone)]
6854enum TestInstallSideEffect {
6855 HideDirectory { directory: PathBuf, hidden: PathBuf },
6856}
6857
6858impl RecordingControls {
6859 #[must_use]
6861 pub fn new() -> Self {
6862 Self::default()
6863 }
6864
6865 #[must_use]
6868 pub fn calls(&self) -> Vec<String> {
6869 self.state.lock().expect("not poisoned").calls.clone()
6870 }
6871
6872 #[must_use]
6874 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
6875 self.state
6876 .lock()
6877 .expect("not poisoned")
6878 .registrations
6879 .iter()
6880 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
6881 .collect()
6882 }
6883
6884 #[must_use]
6886 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
6887 self.state
6888 .lock()
6889 .expect("not poisoned")
6890 .definitions
6891 .get(name)
6892 .cloned()
6893 }
6894
6895 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
6905 let mut state = self.state.lock().expect("not poisoned");
6906 if let Some((_, registration)) = state
6907 .registrations
6908 .iter_mut()
6909 .find(|((_, held), _)| held == name)
6910 {
6911 edit(registration);
6912 }
6913 }
6914
6915 #[cfg(test)]
6916 fn fail_next_install(&self, mode: StartMode, detail: &str) {
6917 self.state
6918 .lock()
6919 .expect("not poisoned")
6920 .install_failures
6921 .insert(mode, detail.to_string());
6922 }
6923
6924 #[cfg(test)]
6925 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
6926 self.state
6927 .lock()
6928 .expect("not poisoned")
6929 .after_install
6930 .insert(
6931 mode,
6932 TestInstallSideEffect::HideDirectory { directory, hidden },
6933 );
6934 }
6935}
6936
6937impl ControlFactory for RecordingControls {
6938 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6939 Ok(Box::new(RecordingControl {
6940 mode,
6941 state: std::sync::Arc::clone(&self.state),
6942 }))
6943 }
6944}
6945
6946#[derive(Debug)]
6947struct RecordingControl {
6948 mode: StartMode,
6949 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6950}
6951
6952impl RecordingControl {
6953 fn note(&self, operation: &str, name: &str) {
6954 self.state
6955 .lock()
6956 .expect("not poisoned")
6957 .calls
6958 .push(format!("{operation} {name} ({})", self.mode));
6959 }
6960}
6961
6962impl ServiceControl for RecordingControl {
6963 fn manager(&self) -> DefinitionKind {
6964 host_definition_kind(self.mode)
6968 }
6969
6970 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6971 self.note("install", plan.identity().name());
6972 #[cfg(test)]
6973 if let Some(detail) = self
6974 .state
6975 .lock()
6976 .expect("not poisoned")
6977 .install_failures
6978 .remove(&self.mode)
6979 {
6980 return Err(ServiceError::Control {
6981 operation: "install",
6982 name: plan.identity().name().to_string(),
6983 manager: "recording control",
6984 detail,
6985 });
6986 }
6987 let definition = ServiceDefinition::for_host(plan)?;
6992 let mut state = self.state.lock().expect("not poisoned");
6993 state.registrations.insert(
6994 (self.mode, plan.identity().name().to_string()),
6995 Registration {
6996 manager: host_definition_kind(self.mode),
6997 start_mode: self.mode,
6998 command_line: plan.command_line(),
6999 account: (*plan.account() != ServiceAccount::InvokingUser)
7000 .then(|| plan.account().as_str().to_string()),
7001 running: false,
7002 starts_automatically: true,
7003 restart_delay: Some(plan.restart().delay()),
7004 },
7005 );
7006 state
7007 .definitions
7008 .insert(plan.identity().name().to_string(), definition.clone());
7009 #[cfg(test)]
7010 let side_effect = state.after_install.remove(&self.mode);
7011 drop(state);
7012 #[cfg(test)]
7013 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7014 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7015 std::fs::write(&directory, b"blocks recreation")
7016 .expect("test fault can block record directory recreation");
7017 }
7018 Ok(definition)
7019 }
7020
7021 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7022 self.note("uninstall", identity.name());
7023 let mut state = self.state.lock().expect("not poisoned");
7024 state.definitions.remove(identity.name());
7025 Ok(state
7026 .registrations
7027 .remove(&(self.mode, identity.name().to_string()))
7028 .is_some())
7029 }
7030
7031 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7032 self.note("query", identity.name());
7033 Ok(self
7034 .state
7035 .lock()
7036 .expect("not poisoned")
7037 .registrations
7038 .get(&(self.mode, identity.name().to_string()))
7039 .cloned())
7040 }
7041
7042 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7043 self.note("start", identity.name());
7044 let mut state = self.state.lock().expect("not poisoned");
7045 match state
7046 .registrations
7047 .get_mut(&(self.mode, identity.name().to_string()))
7048 {
7049 Some(registration) => {
7050 registration.running = true;
7051 Ok(())
7052 }
7053 None => Err(ServiceError::NotInstalled {
7054 name: identity.name().to_string(),
7055 operation: "start",
7056 }),
7057 }
7058 }
7059
7060 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7061 self.note("stop", identity.name());
7062 let mut state = self.state.lock().expect("not poisoned");
7063 match state
7064 .registrations
7065 .get_mut(&(self.mode, identity.name().to_string()))
7066 {
7067 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7068 None => Err(ServiceError::NotInstalled {
7069 name: identity.name().to_string(),
7070 operation: "stop",
7071 }),
7072 }
7073 }
7074}
7075
7076impl ControlFactory for HostControls {
7077 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7078 sys::control(mode)
7079 }
7080
7081 fn installed_definition(&self, path: &Path) -> Option<String> {
7082 std::fs::read_to_string(path).ok()
7083 }
7084
7085 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7086 crate::unattended_login::probe()
7087 }
7088}
7089
7090#[must_use]
7097pub fn host_home() -> Option<PathBuf> {
7098 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7099}
7100
7101#[cfg(unix)]
7103fn home_directory() -> Option<PathBuf> {
7104 host_home()
7105}
7106
7107fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7116 let output = std::process::Command::new(program)
7117 .args(arguments)
7118 .output()?;
7119 Ok((
7120 output.status.success(),
7121 String::from_utf8_lossy(&output.stdout).into_owned(),
7122 String::from_utf8_lossy(&output.stderr).into_owned(),
7123 ))
7124}
7125
7126#[must_use]
7129pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7130 if cfg!(windows) {
7131 match mode {
7132 StartMode::Boot => DefinitionKind::WindowsService,
7133 StartMode::Login => DefinitionKind::WindowsScheduledTask,
7134 }
7135 } else if cfg!(target_os = "macos") {
7136 DefinitionKind::LaunchdPlist
7137 } else {
7138 DefinitionKind::SystemdUnit
7139 }
7140}
7141
7142#[cfg(any(target_os = "macos", test))]
7149fn enable_launchd_registration(
7150 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7151 domain: &str,
7152 service_target: &str,
7153 plist: &Path,
7154 name: &str,
7155 elevation_remedy: &'static str,
7156) -> Result<(), ServiceError> {
7157 let (enabled, cause) = launchctl(&[
7158 std::ffi::OsStr::new("enable"),
7159 std::ffi::OsStr::new(service_target),
7160 ]);
7161 if enabled {
7162 return Ok(());
7163 }
7164
7165 let (booted_out, bootout_detail) = launchctl(&[
7166 std::ffi::OsStr::new("bootout"),
7167 std::ffi::OsStr::new(service_target),
7168 ]);
7169 let removed = std::fs::remove_file(plist);
7170 if !booted_out || removed.is_err() {
7171 return Err(ServiceError::Rollback {
7172 operation: "enable launchd registration",
7173 name: name.to_string(),
7174 cause,
7175 rollback: format!(
7176 "launchctl bootout {domain}: {}; remove {}: {}",
7177 if booted_out {
7178 "succeeded".to_string()
7179 } else {
7180 bootout_detail
7181 },
7182 plist.display(),
7183 removed
7184 .err()
7185 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7186 ),
7187 });
7188 }
7189
7190 if cause.to_ascii_lowercase().contains("permission denied") {
7191 Err(ServiceError::NeedsElevation {
7192 operation: "enable",
7193 name: name.to_string(),
7194 detail: cause,
7195 remedy: elevation_remedy,
7196 })
7197 } else {
7198 Err(ServiceError::Control {
7199 operation: "enable",
7200 name: name.to_string(),
7201 manager: "launchd",
7202 detail: cause,
7203 })
7204 }
7205}
7206
7207#[derive(Debug, Clone)]
7216pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7217
7218impl ServiceShutdown {
7219 pub async fn wait(mut self) {
7221 if !*self.0.borrow() {
7222 let _ = self.0.changed().await;
7223 }
7224 }
7225}
7226
7227#[cfg(windows)]
7234pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7235where
7236 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7237{
7238 windows_host::run(Box::new(run))
7239}
7240
7241#[cfg(windows)]
7242mod windows_host {
7243 use std::ffi::OsString;
7244 use std::sync::{Arc, Mutex, OnceLock, mpsc};
7245 use std::time::Duration;
7246
7247 use windows_service::service::{
7248 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7249 ServiceType,
7250 };
7251 use windows_service::service_control_handler::{
7252 self, ServiceControlHandlerResult, ServiceStatusHandle,
7253 };
7254
7255 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7256
7257 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7258
7259 struct Invocation {
7260 run: Runner,
7261 result: mpsc::SyncSender<Result<u8, String>>,
7262 }
7263
7264 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7265
7266 windows_service::define_windows_service!(ffi_service_main, service_main);
7267
7268 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7269 let (result_tx, result_rx) = mpsc::sync_channel(1);
7270 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7271 let mut invocation = slot
7272 .lock()
7273 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7274 if invocation.is_some() {
7275 return Err(host_error(
7276 "prepare",
7277 "the service-host slot was already used",
7278 ));
7279 }
7280 *invocation = Some(Invocation {
7281 run,
7282 result: result_tx,
7283 });
7284 drop(invocation);
7285
7286 windows_service::service_dispatcher::start("", ffi_service_main)
7287 .map_err(|error| host_error("connect", &error.to_string()))?;
7288 result_rx
7289 .recv()
7290 .map_err(|error| host_error("finish", &error.to_string()))?
7291 .map_err(|detail| host_error("run", &detail))
7292 }
7293
7294 fn service_main(_arguments: Vec<OsString>) {
7295 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7296 return;
7297 };
7298 let result = run_service(invocation.run);
7299 let _ = invocation.result.send(result);
7300 }
7301
7302 fn run_service(run: Runner) -> Result<u8, String> {
7303 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7304 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7305 let handler_status = Arc::clone(&status);
7306 let handler = move |control| match control {
7307 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7308 ServiceControl::Stop | ServiceControl::Shutdown => {
7309 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7310 let _ = handle.set_service_status(service_status(
7311 ServiceState::StopPending,
7312 ServiceControlAccept::empty(),
7313 1,
7314 Duration::from_secs(300),
7315 0,
7316 ));
7317 }
7318 let _ = stop_tx.send(true);
7319 ServiceControlHandlerResult::NoError
7320 }
7321 _ => ServiceControlHandlerResult::NotImplemented,
7322 };
7323 let handle = service_control_handler::register("", handler)
7324 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7325 *status
7326 .lock()
7327 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7328 handle
7329 .set_service_status(service_status(
7330 ServiceState::Running,
7331 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7332 0,
7333 Duration::default(),
7334 0,
7335 ))
7336 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7337
7338 let exit = run(ServiceShutdown(stop_rx));
7339 handle
7340 .set_service_status(service_status(
7341 ServiceState::Stopped,
7342 ServiceControlAccept::empty(),
7343 0,
7344 Duration::default(),
7345 u32::from(exit),
7346 ))
7347 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7348 Ok(exit)
7349 }
7350
7351 fn service_status(
7352 state: ServiceState,
7353 accepted: ServiceControlAccept,
7354 checkpoint: u32,
7355 wait_hint: Duration,
7356 exit: u32,
7357 ) -> ServiceStatus {
7358 ServiceStatus {
7359 service_type: ServiceType::OWN_PROCESS,
7360 current_state: state,
7361 controls_accepted: accepted,
7362 exit_code: if exit == 0 {
7363 ServiceExitCode::Win32(0)
7364 } else {
7365 ServiceExitCode::ServiceSpecific(exit)
7366 },
7367 checkpoint,
7368 wait_hint,
7369 process_id: None,
7370 }
7371 }
7372
7373 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7374 ServiceError::Control {
7375 operation,
7376 name: SERVICE_NAME.to_string(),
7377 manager: "the Windows Service Control Manager",
7378 detail: detail.to_string(),
7379 }
7380 }
7381}
7382
7383#[cfg(windows)]
7384mod sys {
7385 use std::ffi::{OsStr, OsString};
7395 use std::time::{Duration, Instant};
7396
7397 use runner_manager_domain::model::StartMode;
7398 use windows_service::service::{
7399 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7400 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7401 ServiceState, ServiceType,
7402 };
7403 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7404
7405 use super::{
7406 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7407 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7408 };
7409
7410 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7413 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7417 const ACCESS_DENIED: i32 = 5;
7419 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7420 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7421
7422 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7423 choose \"Run as administrator\".";
7424
7425 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7426 Ok(match mode {
7427 StartMode::Boot => Box::new(ScmControl),
7428 StartMode::Login => Box::new(TaskControl),
7429 })
7430 }
7431
7432 #[derive(Debug)]
7435 struct ScmControl;
7436
7437 fn scm_error(
7441 operation: &'static str,
7442 name: &str,
7443 error: &windows_service::Error,
7444 ) -> ServiceError {
7445 let raw = match error {
7446 windows_service::Error::Winapi(io) => io.raw_os_error(),
7447 _ => None,
7448 };
7449 let detail = match error {
7450 windows_service::Error::Winapi(io) => io.to_string(),
7451 other => other.to_string(),
7452 };
7453 if raw == Some(ACCESS_DENIED) {
7454 return ServiceError::NeedsElevation {
7455 operation,
7456 name: name.to_string(),
7457 detail,
7458 remedy: ELEVATION_REMEDY,
7459 };
7460 }
7461 ServiceError::Control {
7462 operation,
7463 name: name.to_string(),
7464 manager: "the Windows Service Control Manager",
7465 detail,
7466 }
7467 }
7468
7469 fn open_manager(
7470 access: ServiceManagerAccess,
7471 operation: &'static str,
7472 name: &str,
7473 ) -> Result<ServiceManager, ServiceError> {
7474 ServiceManager::local_computer(None::<&OsStr>, access)
7475 .map_err(|error| scm_error(operation, name, &error))
7476 }
7477
7478 impl ServiceControl for ScmControl {
7479 fn manager(&self) -> DefinitionKind {
7480 DefinitionKind::WindowsService
7481 }
7482
7483 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7484 let spec = windows_service_spec(plan);
7485 let manager = open_manager(
7486 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7487 "install",
7488 &spec.name,
7489 )?;
7490 let info = ServiceInfo {
7491 name: OsString::from(&spec.name),
7492 display_name: OsString::from(&spec.display_name),
7493 service_type: ServiceType::OWN_PROCESS,
7495 start_type: if spec.automatic_start {
7496 ServiceStartType::AutoStart
7497 } else {
7498 ServiceStartType::OnDemand
7499 },
7500 error_control: ServiceErrorControl::Normal,
7501 executable_path: plan.binary().to_path_buf(),
7502 launch_arguments: plan.arguments().to_vec(),
7503 dependencies: Vec::new(),
7504 account_name: spec.account.as_ref().map(OsString::from),
7506 account_password: None,
7509 };
7510 let service = manager
7511 .create_service(
7512 &info,
7513 ServiceAccess::CHANGE_CONFIG
7514 | ServiceAccess::QUERY_CONFIG
7515 | ServiceAccess::QUERY_STATUS
7516 | ServiceAccess::START
7517 | ServiceAccess::STOP
7518 | ServiceAccess::DELETE,
7519 )
7520 .map_err(|error| scm_error("install", &spec.name, &error))?;
7521 service
7522 .set_description(&spec.description)
7523 .map_err(|error| scm_error("describe", &spec.name, &error))?;
7524 service
7525 .update_failure_actions(ServiceFailureActions {
7526 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7527 reboot_msg: None,
7528 command: None,
7529 actions: Some(vec![
7536 ServiceAction {
7537 action_type: ServiceActionType::Restart,
7538 delay: spec.restart.delay(),
7539 };
7540 3
7541 ]),
7542 })
7543 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7544 service
7547 .set_failure_actions_on_non_crash_failures(true)
7548 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7549 Ok(ServiceDefinition::windows_service(plan))
7550 }
7551
7552 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7553 let manager =
7554 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7555 let service = match manager.open_service(
7556 identity.name(),
7557 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7558 ) {
7559 Ok(service) => Some(service),
7560 Err(error) if is_missing(&error) => return Ok(false),
7561 Err(error) if is_marked_for_delete(&error) => None,
7566 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7567 };
7568 if let Some(service) = service {
7569 if let Ok(status) = service.query_status()
7573 && status.current_state != ServiceState::Stopped
7574 {
7575 let _ = service.stop();
7576 }
7577 match service.delete() {
7578 Ok(()) => {}
7579 Err(error) if is_marked_for_delete(&error) => {}
7583 Err(error) => {
7584 return Err(scm_error("uninstall", identity.name(), &error));
7585 }
7586 }
7587 drop(service);
7588 }
7589
7590 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7597 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7598 Ok(service) => {
7599 drop(service);
7600 Ok(false)
7601 }
7602 Err(error) if is_missing(&error) => Ok(true),
7603 Err(error) if is_marked_for_delete(&error) => Ok(false),
7604 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7605 }
7606 })?;
7607 if !absent {
7608 return Err(ServiceError::Control {
7609 operation: "verify uninstall of",
7610 name: identity.name().to_string(),
7611 manager: "the Windows Service Control Manager",
7612 detail: format!(
7613 "the registration was still visible {} seconds after DeleteService; \
7614 retry `service uninstall` from an elevated prompt",
7615 DELETE_TIMEOUT.as_secs()
7616 ),
7617 });
7618 }
7619 Ok(true)
7620 }
7621
7622 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7623 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7624 let service = match manager.open_service(
7625 identity.name(),
7626 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7627 ) {
7628 Ok(service) => service,
7629 Err(error) if is_missing(&error) => return Ok(None),
7630 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7631 };
7632 let config = service
7633 .query_config()
7634 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7635 let status = service
7636 .query_status()
7637 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7638 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7639 actions
7640 .actions
7641 .and_then(|actions| actions.into_iter().next())
7642 .filter(|action| action.action_type == ServiceActionType::Restart)
7643 .map(|action| action.delay)
7644 });
7645 Ok(Some(Registration {
7646 manager: DefinitionKind::WindowsService,
7647 start_mode: StartMode::Boot,
7648 command_line: config.executable_path.to_string_lossy().into_owned(),
7652 account: config
7653 .account_name
7654 .map(|account| super::windows_service_account(&account.to_string_lossy())),
7655 running: status.current_state == ServiceState::Running,
7656 starts_automatically: config.start_type == ServiceStartType::AutoStart,
7657 restart_delay,
7658 }))
7659 }
7660
7661 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7662 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7663 let service = manager
7664 .open_service(identity.name(), ServiceAccess::START)
7665 .map_err(|error| scm_error("start", identity.name(), &error))?;
7666 service
7667 .start::<&OsStr>(&[])
7668 .map_err(|error| scm_error("start", identity.name(), &error))
7669 }
7670
7671 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7672 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7673 let service = manager
7674 .open_service(
7675 identity.name(),
7676 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7677 )
7678 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7679 let status = service
7680 .query_status()
7681 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7682 if status.current_state == ServiceState::Stopped {
7683 return Ok(false);
7684 }
7685 service
7686 .stop()
7687 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7688 Ok(true)
7689 }
7690 }
7691
7692 fn is_missing(error: &windows_service::Error) -> bool {
7693 matches!(error, windows_service::Error::Winapi(io)
7694 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7695 }
7696
7697 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7698 matches!(error, windows_service::Error::Winapi(io)
7699 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7700 }
7701
7702 pub(super) fn wait_until_scm_absent(
7703 timeout: Duration,
7704 poll_interval: Duration,
7705 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7706 ) -> Result<bool, ServiceError> {
7707 let deadline = Instant::now() + timeout;
7708 loop {
7709 if probe_absent()? {
7710 return Ok(true);
7711 }
7712 if Instant::now() >= deadline {
7713 return Ok(false);
7714 }
7715 std::thread::sleep(poll_interval);
7716 }
7717 }
7718
7719 #[derive(Debug)]
7722 struct TaskControl;
7723
7724 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7725 if detail.to_ascii_lowercase().contains("access is denied") {
7726 return ServiceError::NeedsElevation {
7727 operation,
7728 name: name.to_string(),
7729 detail,
7730 remedy: ELEVATION_REMEDY,
7731 };
7732 }
7733 ServiceError::Control {
7734 operation,
7735 name: name.to_string(),
7736 manager: "Windows Task Scheduler",
7737 detail,
7738 }
7739 }
7740
7741 fn schtasks(
7742 operation: &'static str,
7743 name: &str,
7744 arguments: &[&OsStr],
7745 ) -> Result<(bool, String), ServiceError> {
7746 match run("schtasks.exe", arguments) {
7747 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7748 Err(error) => Err(task_error(operation, name, error.to_string())),
7749 }
7750 }
7751
7752 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7755 let mut bytes = vec![0xFF, 0xFE];
7756 for unit in text.encode_utf16() {
7757 bytes.extend_from_slice(&unit.to_le_bytes());
7758 }
7759 std::fs::write(path, bytes)
7760 }
7761
7762 impl ServiceControl for TaskControl {
7763 fn manager(&self) -> DefinitionKind {
7764 DefinitionKind::WindowsScheduledTask
7765 }
7766
7767 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7768 let name = plan.identity().name().to_string();
7769 let principal = TaskPrincipal::current()?;
7770 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7771 let directory = tempfile::tempdir()
7772 .map_err(|error| task_error("install", &name, error.to_string()))?;
7773 let document = directory.path().join("task.xml");
7774 write_utf16(&document, definition.text())
7775 .map_err(|error| task_error("install", &name, error.to_string()))?;
7776 let (ok, message) = schtasks(
7777 "install",
7778 &name,
7779 &[
7780 OsStr::new("/Create"),
7781 OsStr::new("/TN"),
7782 OsStr::new(&name),
7783 OsStr::new("/XML"),
7784 document.as_os_str(),
7785 ],
7786 )?;
7787 if !ok {
7788 return Err(task_error("install", &name, message));
7789 }
7790 Ok(definition)
7791 }
7792
7793 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7794 if self.query(identity)?.is_none() {
7795 return Ok(false);
7796 }
7797 let name = identity.name().to_string();
7798 let (ok, message) = schtasks(
7799 "uninstall",
7800 &name,
7801 &[
7802 OsStr::new("/Delete"),
7803 OsStr::new("/TN"),
7804 OsStr::new(&name),
7805 OsStr::new("/F"),
7806 ],
7807 )?;
7808 if !ok {
7809 return Err(task_error("uninstall", &name, message));
7810 }
7811 Ok(true)
7812 }
7813
7814 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7815 let name = identity.name().to_string();
7816 let (ok, document) = schtasks(
7817 "inspect",
7818 &name,
7819 &[
7820 OsStr::new("/Query"),
7821 OsStr::new("/TN"),
7822 OsStr::new(&name),
7823 OsStr::new("/XML"),
7824 OsStr::new("ONE"),
7825 ],
7826 )?;
7827 if !ok {
7828 return Ok(None);
7834 }
7835 let command = xml_value(&document, "Command").unwrap_or_default();
7836 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
7837 let command_line = if arguments.is_empty() {
7838 super::quote_argument(&command)
7839 } else {
7840 format!("{} {arguments}", super::quote_argument(&command))
7841 };
7842 Ok(Some(Registration {
7843 manager: DefinitionKind::WindowsScheduledTask,
7844 start_mode: StartMode::Login,
7845 command_line,
7846 account: super::scheduled_task_account(xml_value(&document, "UserId")),
7847 running: task_is_running(&name),
7848 starts_automatically: super::windows_login_task_starts_automatically(&document),
7849 restart_delay: xml_value(&document, "Interval")
7850 .as_deref()
7851 .and_then(parse_iso8601),
7852 }))
7853 }
7854
7855 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7856 let name = identity.name().to_string();
7857 let (ok, message) = schtasks(
7858 "start",
7859 &name,
7860 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
7861 )?;
7862 if ok {
7863 Ok(())
7864 } else {
7865 Err(task_error("start", &name, message))
7866 }
7867 }
7868
7869 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7870 let running = self
7871 .query(identity)?
7872 .is_some_and(|registration| registration.running);
7873 if !running {
7874 return Ok(false);
7875 }
7876 let name = identity.name().to_string();
7877 let (ok, message) = schtasks(
7878 "stop",
7879 &name,
7880 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
7881 )?;
7882 if ok {
7883 Ok(true)
7884 } else {
7885 Err(task_error("stop", &name, message))
7886 }
7887 }
7888 }
7889
7890 fn task_is_running(name: &str) -> bool {
7904 let Ok((true, stdout, _)) = run(
7905 "schtasks.exe",
7906 &[
7907 OsStr::new("/Query"),
7908 OsStr::new("/TN"),
7909 OsStr::new(name),
7910 OsStr::new("/FO"),
7911 OsStr::new("CSV"),
7912 OsStr::new("/NH"),
7913 ],
7914 ) else {
7915 return false;
7916 };
7917 stdout
7918 .lines()
7919 .filter_map(|line| line.rsplit(',').next())
7920 .any(|status| {
7921 status
7922 .trim()
7923 .trim_matches('"')
7924 .eq_ignore_ascii_case("running")
7925 })
7926 }
7927
7928 fn parse_iso8601(value: &str) -> Option<Duration> {
7932 let rest = value.strip_prefix("PT")?;
7933 if let Some(minutes) = rest.strip_suffix('M') {
7934 return minutes
7935 .parse::<u64>()
7936 .ok()
7937 .map(|minutes| Duration::from_secs(minutes * 60));
7938 }
7939 rest.strip_suffix('S')?
7940 .parse::<u64>()
7941 .ok()
7942 .map(Duration::from_secs)
7943 }
7944}
7945
7946#[cfg(unix)]
7958fn write_definition(
7959 operation: &'static str,
7960 name: &str,
7961 path: &Path,
7962 text: &str,
7963 remedy: &'static str,
7964) -> Result<(), ServiceError> {
7965 if let Some(parent) = path.parent()
7966 && let Err(error) = std::fs::create_dir_all(parent)
7967 && error.kind() != std::io::ErrorKind::AlreadyExists
7968 {
7969 return Err(definition_error(operation, name, error, remedy, parent));
7970 }
7971 std::fs::write(path, text)
7972 .map_err(|error| definition_error(operation, name, error, remedy, path))
7973}
7974
7975#[cfg(all(unix, any(target_os = "macos", test)))]
7982fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
7983 use std::io::Write as _;
7984
7985 let path = drift.path.as_path();
7986 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
7987 let failed = |error: std::io::Error, at: &Path| {
7988 definition_error("rewrite", name, error, SUDO_REMEDY, at)
7989 };
7990 let mut staged =
7991 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
7992 staged
7993 .write_all(drift.rendered.as_bytes())
7994 .and_then(|()| staged.as_file().sync_all())
7995 .map_err(|error| failed(error, staged.path()))?;
7996 std::fs::set_permissions(
7997 staged.path(),
7998 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
7999 )
8000 .map_err(|error| failed(error, path))?;
8001 staged
8002 .persist(path)
8003 .map(drop)
8004 .map_err(|error| failed(error.error, path))
8005}
8006
8007#[cfg(unix)]
8008fn definition_error(
8009 operation: &'static str,
8010 name: &str,
8011 error: std::io::Error,
8012 remedy: &'static str,
8013 path: &Path,
8014) -> ServiceError {
8015 let detail = format!("{}: {error}", path.display());
8016 if error.kind() == std::io::ErrorKind::PermissionDenied {
8017 ServiceError::NeedsElevation {
8018 operation,
8019 name: name.to_string(),
8020 detail,
8021 remedy,
8022 }
8023 } else {
8024 ServiceError::Control {
8025 operation,
8026 name: name.to_string(),
8027 manager: "the local service manager",
8028 detail,
8029 }
8030 }
8031}
8032
8033#[cfg(unix)]
8035const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8036 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8037 agent not running until you sign in.";
8038
8039#[cfg(target_os = "macos")]
8044mod sys {
8045 use std::ffi::OsStr;
8058 use std::path::PathBuf;
8059 use std::time::Duration;
8060
8061 use runner_manager_domain::model::StartMode;
8062
8063 use super::{
8064 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8065 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8066 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8067 write_definition, xml_value,
8068 };
8069
8070 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8071 Ok(Box::new(LaunchdControl { mode }))
8072 }
8073
8074 #[derive(Debug)]
8075 struct LaunchdControl {
8076 mode: StartMode,
8077 }
8078
8079 impl LaunchdControl {
8080 fn domain(&self) -> String {
8082 match self.mode {
8083 StartMode::Boot => "system".to_string(),
8084 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8087 }
8088 }
8089
8090 fn service_target(&self, identity: &ServiceIdentity) -> String {
8091 format!("{}/{}", self.domain(), identity.launchd_label())
8092 }
8093
8094 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8097 let file = format!("{}.plist", identity.launchd_label());
8098 match self.mode {
8099 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8100 StartMode::Login => {
8101 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8102 }
8103 }
8104 }
8105
8106 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8107 ServiceError::Control {
8108 operation,
8109 name: name.to_string(),
8110 manager: "launchd",
8111 detail,
8112 }
8113 }
8114
8115 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8116 match run("launchctl", arguments) {
8117 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8118 Err(error) => (false, error.to_string()),
8119 }
8120 }
8121 }
8122
8123 impl ServiceControl for LaunchdControl {
8124 fn manager(&self) -> DefinitionKind {
8125 DefinitionKind::LaunchdPlist
8126 }
8127
8128 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8129 let name = plan.identity().name().to_string();
8130 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8131 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8132 return Err(self.failed(
8133 "install",
8134 &name,
8135 "this account has no home directory, so there is nowhere to put a \
8136 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8137 /Library/LaunchDaemons."
8138 .to_string(),
8139 ));
8140 };
8141 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8142 let target = self.domain();
8143 let (ok, message) = self.launchctl(&[
8144 OsStr::new("bootstrap"),
8145 OsStr::new(&target),
8146 path.as_os_str(),
8147 ]);
8148 if !ok {
8149 let _ = std::fs::remove_file(&path);
8153 if message.to_ascii_lowercase().contains("permission denied") {
8154 return Err(ServiceError::NeedsElevation {
8155 operation: "install",
8156 name,
8157 detail: message,
8158 remedy: SUDO_REMEDY,
8159 });
8160 }
8161 return Err(self.failed("install", &name, message));
8162 }
8163 let service_target = self.service_target(plan.identity());
8166 enable_launchd_registration(
8167 |arguments| self.launchctl(arguments),
8168 &target,
8169 &service_target,
8170 &path,
8171 &name,
8172 SUDO_REMEDY,
8173 )?;
8174 Ok(definition)
8175 }
8176
8177 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8178 let Some(path) = self.plist_path(identity) else {
8179 return Ok(false);
8180 };
8181 if !path.exists() {
8182 return Ok(false);
8183 }
8184 let target = self.service_target(identity);
8185 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8186 if !ok
8187 && !message.to_ascii_lowercase().contains("no such process")
8188 && !message.contains("113")
8189 {
8190 if message.to_ascii_lowercase().contains("permission denied") {
8191 return Err(ServiceError::NeedsElevation {
8192 operation: "uninstall",
8193 name: identity.name().to_string(),
8194 detail: message,
8195 remedy: SUDO_REMEDY,
8196 });
8197 }
8198 return Err(self.failed("uninstall", identity.name(), message));
8199 }
8200 std::fs::remove_file(&path).map_err(|error| {
8203 super::definition_error(
8204 "uninstall",
8205 identity.name(),
8206 error,
8207 SUDO_REMEDY,
8208 path.as_path(),
8209 )
8210 })?;
8211 Ok(true)
8212 }
8213
8214 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8215 let Some(path) = self.plist_path(identity) else {
8216 return Ok(None);
8217 };
8218 let Ok(document) = std::fs::read_to_string(&path) else {
8219 return Ok(None);
8220 };
8221 let target = self.service_target(identity);
8222 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8223 Ok(Some(Registration {
8224 manager: DefinitionKind::LaunchdPlist,
8225 start_mode: self.mode,
8226 command_line: program_arguments(&document),
8227 account: plist_string_value(&document, "UserName").or_else(|| {
8230 (self.mode == StartMode::Boot)
8231 .then(|| super::ServiceAccount::Root.as_str().to_string())
8232 }),
8233 running: loaded && printed.contains("state = running"),
8234 starts_automatically: document.contains("<key>RunAtLoad</key>")
8235 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8236 restart_delay: xml_value(
8237 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8238 "integer",
8239 )
8240 .and_then(|value| value.parse::<u64>().ok())
8241 .map(Duration::from_secs),
8242 }))
8243 }
8244
8245 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8246 let target = self.service_target(identity);
8247 let (ok, message) = self.launchctl(&[
8248 OsStr::new("kickstart"),
8249 OsStr::new("-k"),
8250 OsStr::new(&target),
8251 ]);
8252 if ok {
8253 Ok(())
8254 } else {
8255 Err(self.failed("start", identity.name(), message))
8256 }
8257 }
8258
8259 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8260 let running = self
8261 .query(identity)?
8262 .is_some_and(|registration| registration.running);
8263 if !running {
8264 return Ok(false);
8265 }
8266 let target = self.service_target(identity);
8267 let (ok, message) = self.launchctl(&[
8268 OsStr::new("kill"),
8269 OsStr::new("SIGTERM"),
8270 OsStr::new(&target),
8271 ]);
8272 if ok {
8273 Ok(true)
8274 } else {
8275 Err(self.failed("stop", identity.name(), message))
8276 }
8277 }
8278 }
8279
8280 pub(super) fn reload_detached(
8295 identity: &ServiceIdentity,
8296 mode: StartMode,
8297 plist: &std::path::Path,
8298 ) -> Result<(), ServiceError> {
8299 use std::os::unix::process::CommandExt;
8300
8301 const SCRIPT: &str = r#"(
8302 sleep 1
8303 /bin/launchctl bootout "$1"
8304 tries=0
8305 until /bin/launchctl bootstrap "$2" "$3"; do
8306 tries=$((tries + 1))
8307 if [ "$tries" -ge 60 ]; then
8308 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8309 exit 1
8310 fi
8311 sleep 1
8312 done
8313) </dev/null &"#;
8314
8315 let control = LaunchdControl { mode };
8316 let reinstall = match mode {
8319 StartMode::Boot => "sudo runner-manager service install --start-at boot",
8320 StartMode::Login => "runner-manager service install --start-at login",
8321 };
8322 let mut command = std::process::Command::new("/bin/sh");
8323 command
8324 .args(["-c", SCRIPT, "runner-manager-reload"])
8325 .arg(control.service_target(identity))
8326 .arg(control.domain())
8327 .arg(plist)
8328 .arg(reinstall)
8329 .stdin(std::process::Stdio::null());
8330 unsafe {
8333 command.pre_exec(|| {
8334 if libc::setsid() == -1 {
8335 return Err(std::io::Error::last_os_error());
8336 }
8337 Ok(())
8338 });
8339 }
8340 let status = command.status().map_err(|error| {
8341 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8342 })?;
8343 if status.success() {
8344 Ok(())
8345 } else {
8346 Err(control.failed(
8347 "reload",
8348 identity.name(),
8349 format!("the reload helper exited with {status}"),
8350 ))
8351 }
8352 }
8353
8354 fn program_arguments(document: &str) -> String {
8356 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8357 return String::new();
8358 };
8359 let Some(end) = rest.find("</array>") else {
8360 return String::new();
8361 };
8362 let mut out = Vec::new();
8363 let mut cursor = &rest[..end];
8364 while let Some(open) = cursor.find("<string>") {
8365 let after = &cursor[open + "<string>".len()..];
8366 let Some(close) = after.find("</string>") else {
8367 break;
8368 };
8369 out.push(quote_argument(&super::xml_unescape(&after[..close])));
8370 cursor = &after[close..];
8371 }
8372 out.join(" ")
8373 }
8374}
8375
8376#[cfg(all(unix, not(target_os = "macos")))]
8381mod sys {
8382 use std::ffi::OsStr;
8392 use std::path::PathBuf;
8393
8394 use runner_manager_domain::model::StartMode;
8395
8396 use super::{
8397 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8398 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8399 home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8400 write_definition,
8401 };
8402
8403 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8404 Ok(Box::new(SystemdControl { mode }))
8405 }
8406
8407 #[derive(Debug)]
8408 struct SystemdControl {
8409 mode: StartMode,
8410 }
8411
8412 impl SystemdControl {
8413 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8414 let file = identity.systemd_unit();
8415 match self.mode {
8416 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8417 StartMode::Login => {
8418 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8419 }
8420 }
8421 }
8422
8423 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8425 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8426 if self.mode == StartMode::Login {
8427 all.push(OsStr::new("--user"));
8428 }
8429 all.extend(arguments.iter().map(OsStr::new));
8430 match run("systemctl", &all) {
8431 Ok((ok, stdout, stderr)) => (
8432 ok,
8433 if stdout.trim().is_empty() {
8434 stderr
8435 } else {
8436 stdout
8437 },
8438 ),
8439 Err(error) => (false, error.to_string()),
8440 }
8441 }
8442
8443 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8444 ServiceError::Control {
8445 operation,
8446 name: name.to_string(),
8447 manager: "systemd",
8448 detail,
8449 }
8450 }
8451 }
8452
8453 impl ServiceControl for SystemdControl {
8454 fn manager(&self) -> DefinitionKind {
8455 DefinitionKind::SystemdUnit
8456 }
8457
8458 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8459 use std::os::unix::fs::MetadataExt as _;
8460
8461 let name = plan.identity().name().to_string();
8462 if self.mode == StartMode::Boot {
8463 let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8466 .iter()
8467 .filter_map(|path| {
8468 let path = std::path::Path::new(path);
8469 let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8470 super::boot_directory_refusal(path, owner)
8471 })
8472 .collect::<Vec<_>>();
8473 if !refusals.is_empty() {
8474 return Err(self.failed(
8475 "install",
8476 &name,
8477 format!(
8478 "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8479 directory there that root does not own is handed back with \
8480 `sudo chown -R root:root <directory>`",
8481 refusals.join("; "),
8482 super::sudo_command("service install --start-at boot"),
8483 crate::paths::SYSTEM_DATA_ROOT,
8484 crate::paths::SYSTEM_LOGS_DIR,
8485 ),
8486 ));
8487 }
8488 }
8489 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8490 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8491 return Err(self.failed(
8492 "install",
8493 &name,
8494 "this account has no home directory, so there is nowhere to put a systemd \
8495 user unit. Use --start-at boot, which installs a system unit under \
8496 /etc/systemd/system."
8497 .to_string(),
8498 ));
8499 };
8500 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8501 let unit = plan.identity().systemd_unit();
8502 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8503 if !reloaded {
8504 let _ = std::fs::remove_file(&path);
8505 return Err(self.failed("install", &name, message));
8506 }
8507 let (enabled, message) = self.systemctl(&["enable", &unit]);
8508 if !enabled {
8509 let _ = std::fs::remove_file(&path);
8510 let _ = self.systemctl(&["daemon-reload"]);
8511 return Err(self.failed("install", &name, message));
8512 }
8513 Ok(definition)
8514 }
8515
8516 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8517 let Some(path) = self.unit_path(identity) else {
8518 return Ok(false);
8519 };
8520 if !path.exists() {
8521 return Ok(false);
8522 }
8523 let unit = identity.systemd_unit();
8524 let _ = self.systemctl(&["disable", "--now", &unit]);
8529 std::fs::remove_file(&path).map_err(|error| {
8530 super::definition_error(
8531 "uninstall",
8532 identity.name(),
8533 error,
8534 SUDO_REMEDY,
8535 path.as_path(),
8536 )
8537 })?;
8538 let _ = self.systemctl(&["daemon-reload"]);
8539 Ok(true)
8540 }
8541
8542 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8543 let Some(path) = self.unit_path(identity) else {
8544 return Ok(None);
8545 };
8546 if !path.exists() {
8547 return Ok(None);
8548 }
8549 let unit = identity.systemd_unit();
8550 let effective = match self.systemctl(&["cat", &unit]) {
8554 (true, text) => text,
8555 _ => match std::fs::read_to_string(&path) {
8556 Ok(document) => document,
8557 Err(_) => return Ok(None),
8558 },
8559 };
8560 let directives = ini_directives(&effective, "Service");
8561 let (_, shown) = self.systemctl(&[
8563 "show",
8564 "--property=ActiveState",
8565 "--property=UnitFileState",
8566 &unit,
8567 ]);
8568 let state = |key: &str| {
8569 shown
8570 .lines()
8571 .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8572 .map(str::trim)
8573 };
8574 Ok(Some(Registration {
8575 manager: DefinitionKind::SystemdUnit,
8576 start_mode: self.mode,
8577 command_line: directives
8578 .get("ExecStart")
8579 .map(|value| systemd_exec_command_line(value))
8580 .unwrap_or_default(),
8581 account: directives
8584 .get("User")
8585 .filter(|user| !user.is_empty())
8586 .cloned()
8587 .or_else(|| {
8588 (self.mode == StartMode::Boot)
8589 .then(|| super::ServiceAccount::Root.as_str().to_string())
8590 }),
8591 running: state("ActiveState") == Some("active"),
8592 starts_automatically: state("UnitFileState") == Some("enabled"),
8593 restart_delay: directives
8594 .get("RestartSec")
8595 .and_then(|value| systemd_timespan(value)),
8596 }))
8597 }
8598
8599 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8600 let unit = identity.systemd_unit();
8601 let (ok, message) = self.systemctl(&["start", &unit]);
8602 if ok {
8603 Ok(())
8604 } else {
8605 Err(self.failed("start", identity.name(), message))
8606 }
8607 }
8608
8609 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8610 let running = self
8611 .query(identity)?
8612 .is_some_and(|registration| registration.running);
8613 if !running {
8614 return Ok(false);
8615 }
8616 let unit = identity.systemd_unit();
8617 let (ok, message) = self.systemctl(&["stop", &unit]);
8618 if ok {
8619 Ok(true)
8620 } else {
8621 Err(self.failed("stop", identity.name(), message))
8622 }
8623 }
8624 }
8625}
8626
8627#[cfg(all(test, target_os = "macos"))]
8629pub(crate) mod launchd_fixture {
8630 use std::path::{Path, PathBuf};
8631
8632 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8636
8637 pub(crate) fn enabled() -> bool {
8640 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8641 if !enabled {
8642 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8643 }
8644 enabled
8645 }
8646
8647 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8655 let binary = directory.join("launch-agent-job");
8656 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8657 .expect("test binary copied");
8658 binary
8659 }
8660
8661 pub(crate) struct LoadedAgent(String);
8663
8664 impl LoadedAgent {
8665 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8668 let domain = format!("gui/{}", unsafe { libc::getuid() });
8671 let loaded = Self(format!("{domain}/{label}"));
8673 let bootstrap = std::process::Command::new("launchctl")
8674 .arg("bootstrap")
8675 .arg(&domain)
8676 .arg(plist)
8677 .output()
8678 .expect("launchctl runs");
8679 assert!(
8680 bootstrap.status.success(),
8681 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8682 String::from_utf8_lossy(&bootstrap.stderr)
8683 );
8684 loaded
8685 }
8686
8687 pub(crate) fn target(&self) -> &str {
8689 &self.0
8690 }
8691 }
8692
8693 impl Drop for LoadedAgent {
8694 fn drop(&mut self) {
8695 let _ = std::process::Command::new("launchctl")
8696 .args(["bootout", &self.0])
8697 .output();
8698 }
8699 }
8700}
8701
8702#[cfg(test)]
8703mod tests {
8704 use super::*;
8705
8706 use std::collections::BTreeMap;
8707
8708 #[test]
8709 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8710 assert!(windows_login_task_starts_automatically(
8711 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8712 ));
8713 assert!(windows_login_task_starts_automatically(
8714 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8715 ));
8716 assert!(!windows_login_task_starts_automatically(
8717 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8718 ));
8719 assert!(!windows_login_task_starts_automatically(
8720 "<Task><BootTrigger></BootTrigger></Task>"
8721 ));
8722 }
8723
8724 fn linux_plan(mode: StartMode) -> InstallPlan {
8731 InstallPlan::unchecked(
8732 ServiceIdentity::product(),
8733 mode,
8734 "/opt/runner-manager/bin/runner-manager",
8735 ServiceDirectories {
8736 config: PathBuf::from("/var/lib/runner-manager/config"),
8737 state: PathBuf::from("/var/lib/runner-manager/state"),
8738 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8739 logs: PathBuf::from("/var/log/runner-manager"),
8740 },
8741 )
8742 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8743 }
8744
8745 fn windows_plan(mode: StartMode) -> InstallPlan {
8746 InstallPlan::unchecked(
8747 ServiceIdentity::product(),
8748 mode,
8749 "C:\\Program Files\\runner-manager\\runner-manager.exe",
8750 ServiceDirectories {
8751 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8752 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8753 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8754 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8755 },
8756 )
8757 }
8758
8759 fn edited(text: &str, from: &str, to: &str) -> String {
8767 assert!(
8768 text.contains(from),
8769 "the rendered definition does not contain `{from}`, so this test would assert \
8770 nothing about a widened one"
8771 );
8772 text.replace(from, to)
8773 }
8774
8775 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8777 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8778 let Ok(entries) = std::fs::read_dir(directory) else {
8779 return;
8780 };
8781 for entry in entries.flatten() {
8782 let path = entry.path();
8783 if path.is_dir() {
8784 walk(&path, out);
8785 } else if let Ok(bytes) = std::fs::read(&path) {
8786 out.insert(path, bytes);
8787 }
8788 }
8789 }
8790 let mut out = BTreeMap::new();
8791 for root in roots {
8792 walk(root, &mut out);
8793 }
8794 out
8795 }
8796
8797 struct Host {
8798 _root: tempfile::TempDir,
8799 paths: AppPaths,
8800 binary: PathBuf,
8801 runner_root: LocalAbsolutePath,
8810 controls: RecordingControls,
8811 }
8812
8813 impl Host {
8814 fn new() -> Self {
8815 let root = tempfile::tempdir().expect("a temporary directory");
8816 let paths = AppPaths::rooted_at(root.path());
8817 paths.create_all().expect("the four directories");
8818 let binary = root.path().join(if cfg!(windows) {
8819 "runner-manager.exe"
8820 } else {
8821 "runner-manager"
8822 });
8823 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
8824 let runner_root = LocalAbsolutePath::new(
8825 root.path()
8826 .join("runner-root")
8827 .to_str()
8828 .expect("a unicode temporary path"),
8829 )
8830 .expect("a local absolute path");
8831 Self {
8832 _root: root,
8833 paths,
8834 binary,
8835 runner_root,
8836 controls: RecordingControls::new(),
8837 }
8838 }
8839
8840 fn operations(&self) -> ServiceOperations {
8841 ServiceOperations::with_controls(
8842 self.paths.clone(),
8843 ServiceIdentity::product(),
8844 std::sync::Arc::new(self.controls.clone()),
8845 )
8846 .with_runner_root(self.runner_root.clone())
8847 }
8848
8849 fn request(&self, mode: StartMode) -> InstallRequest {
8850 InstallRequest::new(mode).for_binary(&self.binary)
8851 }
8852 }
8853
8854 #[cfg(windows)]
8855 #[test]
8856 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
8857 let probes = std::cell::Cell::new(0);
8858 let absent =
8859 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
8860 let next = probes.get() + 1;
8861 probes.set(next);
8862 Ok(next == 3)
8863 })
8864 .expect("the simulated SCM probe succeeds");
8865
8866 assert!(absent);
8867 assert_eq!(
8868 probes.get(),
8869 3,
8870 "uninstall must recheck after transient presence instead of treating it as a leak"
8871 );
8872 }
8873
8874 #[cfg(windows)]
8875 #[test]
8876 fn windows_recognises_an_already_pending_service_deletion() {
8877 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
8878
8879 assert!(super::sys::is_marked_for_delete(&error));
8880 }
8881
8882 #[test]
8883 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
8884 let root = tempfile::tempdir().expect("a temporary directory");
8885 let plist = root.path().join("fixture.plist");
8886 std::fs::write(&plist, b"fixture").expect("a plist fixture");
8887 let calls = std::cell::RefCell::new(Vec::new());
8888
8889 let error = enable_launchd_registration(
8890 |arguments| {
8891 let call = arguments
8892 .iter()
8893 .map(|argument| argument.to_string_lossy().into_owned())
8894 .collect::<Vec<_>>();
8895 let operation = call[0].clone();
8896 calls.borrow_mut().push(call);
8897 if operation == "enable" {
8898 (false, "label remains disabled".to_string())
8899 } else {
8900 (true, String::new())
8901 }
8902 },
8903 "system",
8904 "system/com.openai.runner-manager-selftest",
8905 &plist,
8906 "runner-manager-selftest",
8907 "rerun with administrative rights",
8908 )
8909 .expect_err("enable failure must fail the install");
8910
8911 assert!(
8912 matches!(
8913 error,
8914 ServiceError::Control {
8915 operation: "enable",
8916 ..
8917 }
8918 ),
8919 "{error}"
8920 );
8921 assert_eq!(calls.borrow().len(), 2);
8922 assert_eq!(calls.borrow()[0][0], "enable");
8923 assert_eq!(calls.borrow()[1][0], "bootout");
8924 assert!(!plist.exists(), "rollback must remove the plist");
8925 }
8926
8927 #[test]
8932 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
8933 let plan = windows_plan(StartMode::Boot);
8934 let command_line = plan.command_line();
8935 assert!(
8936 command_line.starts_with('"'),
8937 "a path with a space must be quoted, got {command_line}"
8938 );
8939 assert_eq!(
8940 executable_from_command_line(&command_line).as_deref(),
8941 Some(plan.binary())
8942 );
8943 }
8944
8945 #[test]
8946 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
8947 let plan = linux_plan(StartMode::Boot);
8948 let command_line = plan.command_line();
8949 assert!(!command_line.starts_with('"'), "got {command_line}");
8950 assert_eq!(
8951 executable_from_command_line(&command_line).as_deref(),
8952 Some(plan.binary())
8953 );
8954 }
8955
8956 #[test]
8957 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
8958 let awkward = r#"C:\odd "name"\rm.exe"#;
8961 let quoted = quote_argument(awkward);
8962 assert_eq!(
8963 executable_from_command_line(&format!("{quoted} daemon run"))
8964 .as_deref()
8965 .map(Path::to_string_lossy)
8966 .as_deref(),
8967 Some(awkward)
8968 );
8969 }
8970
8971 #[test]
8972 fn an_empty_command_line_has_no_executable() {
8973 assert_eq!(executable_from_command_line(" "), None);
8974 assert_eq!(executable_from_command_line(""), None);
8975 }
8976
8977 #[test]
8978 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
8979 let awkward = r#"DOMAIN\R&D <team> "ops""#;
8980 assert_eq!(
8981 xml_escape(awkward),
8982 "DOMAIN\\R&D <team> "ops""
8983 );
8984 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
8985 }
8986
8987 #[test]
8992 fn a_restart_delay_under_the_floor_is_refused() {
8993 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
8994 .expect_err("half a second is under the one-second floor");
8995 assert!(
8996 matches!(error, ServiceError::RestartDelay { .. }),
8997 "{error}"
8998 );
8999 }
9000
9001 #[test]
9002 fn a_restart_delay_over_the_ceiling_is_refused() {
9003 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9004 .expect_err("an hour is over the five-minute ceiling");
9005 assert!(
9006 matches!(error, ServiceError::RestartDelay { .. }),
9007 "{error}"
9008 );
9009 }
9010
9011 #[test]
9012 fn a_reset_window_no_longer_than_the_delay_is_refused() {
9013 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9014 .expect_err("a window equal to the delay can never elapse between restarts");
9015 assert!(
9016 matches!(error, ServiceError::RestartResetWindow { .. }),
9017 "{error}"
9018 );
9019 }
9020
9021 #[test]
9022 fn a_delay_inside_the_bound_is_accepted() {
9023 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9024 .expect("twenty seconds is inside the bound");
9025 assert_eq!(policy.delay(), Duration::from_secs(20));
9026 assert_eq!(policy.reset_after(), Duration::from_secs(300));
9027 }
9028
9029 #[test]
9034 fn a_fixture_identity_can_never_be_the_product_identity() {
9035 let fixture = ServiceIdentity::fixture("abc123");
9036 assert!(fixture.is_fixture());
9037 assert!(!ServiceIdentity::product().is_fixture());
9038 assert_ne!(fixture.name(), ServiceIdentity::product().name());
9039 assert_ne!(
9040 fixture.launchd_label(),
9041 ServiceIdentity::product().launchd_label()
9042 );
9043 assert_ne!(
9044 fixture.systemd_unit(),
9045 ServiceIdentity::product().systemd_unit()
9046 );
9047 }
9048
9049 #[test]
9050 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9051 let fixture = ServiceIdentity::fixture("A b/c:\\d");
9052 assert!(
9053 fixture
9054 .name()
9055 .chars()
9056 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9057 "got {}",
9058 fixture.name()
9059 );
9060 }
9061
9062 #[test]
9067 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9068 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9069 assert!(unit.contains("KillMode=process\n"), "{unit}");
9070 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9071 assert!(unit.contains("RestartSec=15\n"), "{unit}");
9072 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9073 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9074 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9075 }
9076
9077 #[test]
9078 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9079 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9080 assert!(
9081 !unit.contains("LoadCredential="),
9082 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9083 );
9084 }
9085
9086 #[test]
9087 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9088 let unit = systemd_unit(&linux_plan(StartMode::Login));
9089 assert!(
9090 !unit.contains("LoadCredential="),
9091 "a user unit must not name a root-owned credential file, got:\n{unit}"
9092 );
9093 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9094 }
9095
9096 #[test]
9097 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9098 let plan = linux_plan(StartMode::Boot);
9099 let unit = systemd_unit(&plan);
9100 let directives = ini_directives(&unit, "Service");
9101 let listed = split_quoted(
9102 directives
9103 .get("ReadWritePaths")
9104 .expect("the unit names its writable paths"),
9105 );
9106 assert_eq!(listed.len(), 5, "{listed:?}");
9107 for path in plan.directories().all() {
9108 assert!(
9109 listed.iter().any(|entry| entry == &path.to_string_lossy()),
9110 "{} is missing from {listed:?}",
9111 path.display()
9112 );
9113 }
9114 assert!(
9115 listed
9116 .iter()
9117 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9118 "atomic credential replacement needs its parent directory in {listed:?}"
9119 );
9120 }
9121
9122 #[test]
9123 fn the_unit_records_the_absolute_binary_path() {
9124 let plan = linux_plan(StartMode::Boot);
9125 let unit = systemd_unit(&plan);
9126 assert!(
9127 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9128 "{unit}"
9129 );
9130 }
9131
9132 #[test]
9137 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9138 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9139 assert!(
9142 plist.contains(
9143 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
9144 ),
9145 "{plist}"
9146 );
9147 assert!(
9148 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
9149 "{plist}"
9150 );
9151 }
9152
9153 #[test]
9154 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9155 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9156 assert_eq!(
9157 plist_string_value(&daemon, "UserName").as_deref(),
9158 Some("root")
9159 );
9160 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9161
9162 let agent = launchd_plist(&linux_plan(StartMode::Login));
9163 assert_eq!(
9164 plist_string_value(&agent, "UserName"),
9165 None,
9166 "a LaunchAgent already runs as the operator:\n{agent}"
9167 );
9168 }
9169
9170 #[test]
9171 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9172 for mode in [StartMode::Boot, StartMode::Login] {
9173 let plan = linux_plan(mode);
9174 let plist = launchd_plist(&plan);
9175 assert_eq!(
9176 plist_string_value(&plist, "ProcessType").as_deref(),
9177 Some("Interactive"),
9178 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9179 );
9180 let review = review_least_privilege(
9181 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9182 &plan,
9183 );
9184 assert!(
9185 review
9186 .controls()
9187 .iter()
9188 .any(|control| control == "ProcessType=Interactive"),
9189 "{review}"
9190 );
9191 assert!(
9192 !review
9193 .findings()
9194 .iter()
9195 .any(|finding| finding.subject == "ProcessType"),
9196 "{review}"
9197 );
9198 }
9199 }
9200
9201 #[test]
9205 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9206 let plan = linux_plan(StartMode::Login);
9207 let rendered = launchd_plist(&plan);
9208 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
9209 for (label, throttled) in [
9210 (
9211 "Background",
9212 edited(
9213 &rendered,
9214 interactive,
9215 "<key>ProcessType</key>\n <string>Background</string>\n",
9216 ),
9217 ),
9218 ("absent", edited(&rendered, interactive, "")),
9219 ] {
9220 let review = review_least_privilege(
9221 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9222 &plan,
9223 );
9224 assert!(review.is_least_privilege(), "{label}: {review}");
9225 assert!(
9226 review.findings().iter().any(|finding| {
9227 finding.kind == FindingKind::Shortfall
9228 && finding.subject == "ProcessType"
9229 && finding.detail.contains(label)
9230 }),
9231 "{label}: {review}"
9232 );
9233 }
9234 }
9235
9236 #[test]
9237 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9238 let host = Host::new();
9239 let identity = ServiceIdentity::product();
9240 let plan = InstallPlan::resolve(
9241 identity.clone(),
9242 &host.request(StartMode::Login),
9243 ServiceDirectories::of(&host.paths),
9244 )
9245 .expect("a resolvable plan");
9246 let installed = launchd_plist(&plan);
9247 let definition =
9248 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9249 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9250 record.installed_by_version = "0.4.31".into();
9251 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9252
9253 assert_eq!(
9254 launchd_definition_drift(&identity, &record, path.clone(), &installed),
9255 None
9256 );
9257 let edited_by_operator = edited(
9258 &installed,
9259 "</dict>\n</plist>\n",
9260 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
9261 );
9262 assert_eq!(
9263 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9264 None,
9265 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9266 );
9267
9268 let written_by_0_4_31 = edited(
9269 &installed,
9270 "<string>Interactive</string>",
9271 "<string>Background</string>",
9272 );
9273 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9274 .expect("a Background plist is drift");
9275 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9276 assert_eq!(drift.start_mode, StartMode::Login);
9277 assert_eq!(
9278 drift.rendered, installed,
9279 "the repair must write exactly what `service install` writes"
9280 );
9281
9282 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9283 .with_definition_drift(Some(drift));
9284 let problem = status
9285 .problems()
9286 .iter()
9287 .find(|problem| problem.subject == "definition")
9288 .expect("the drift is reported");
9289 assert!(
9292 problem.detail.contains("0.4.31")
9293 && problem.detail.contains(
9294 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9295 )
9296 && !problem.detail.contains("service install")
9297 && !problem.detail.contains("systemctl")
9298 && !problem.detail.contains("sudo"),
9299 "{}",
9300 problem.detail
9301 );
9302 }
9303
9304 #[cfg(unix)]
9305 #[test]
9306 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9307 use std::os::unix::fs::PermissionsExt as _;
9308
9309 let root = tempfile::tempdir().expect("a temporary directory");
9310 let path = root
9311 .path()
9312 .join("io.github.IvanMurzak.runner-manager.plist");
9313 std::fs::write(&path, "<string>Background</string>").expect("old plist");
9314 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9315 let drift = DefinitionDrift {
9316 path: path.clone(),
9317 installed_by_version: "0.4.31".into(),
9318 rendered: launchd_plist(&linux_plan(StartMode::Login)),
9319 start_mode: StartMode::Login,
9320 manager: DefinitionKind::LaunchdPlist,
9321 };
9322
9323 replace_definition("runner-manager", &drift).expect("replaced");
9324
9325 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9326 assert_eq!(
9327 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9328 0o644
9329 );
9330 assert_eq!(
9331 std::fs::read_dir(root.path()).unwrap().count(),
9332 1,
9333 "no staging file is left beside the plist"
9334 );
9335 }
9336
9337 #[cfg(target_os = "macos")]
9346 #[test]
9347 #[ignore = "run only by launchd, as the job of the detached-reload test"]
9348 fn detached_reload_job() {
9349 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9350 let tag = label
9351 .split_once("-selftest-")
9352 .map(|(_, tag)| tag.to_string())
9353 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9354 let identity = ServiceIdentity::fixture(&tag);
9355 assert_eq!(identity.launchd_label(), label);
9356 let path = std::env::current_dir()
9357 .expect("working directory")
9358 .join(format!("{label}.plist"));
9359 let installed = std::fs::read_to_string(&path).expect("own plist");
9360 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9361 let drift = DefinitionDrift {
9362 path: path.clone(),
9363 installed_by_version: "0.4.31".into(),
9364 rendered: installed.replace(
9365 "<string>Background</string>",
9366 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9367 ),
9368 start_mode: StartMode::Login,
9369 manager: DefinitionKind::LaunchdPlist,
9370 };
9371 replace_definition(identity.name(), &drift).expect("plist replaced");
9372 super::sys::reload_detached(&identity, StartMode::Login, &path)
9373 .expect("helper started");
9374 }
9375 std::thread::sleep(Duration::from_secs(120));
9376 }
9377
9378 #[cfg(target_os = "macos")]
9388 #[test]
9389 fn a_job_reloads_itself_from_its_rewritten_plist() {
9390 if !super::launchd_fixture::enabled() {
9391 return;
9392 }
9393 let root = tempfile::tempdir().expect("a temporary directory");
9394 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9395 let label = identity.launchd_label();
9396 let path = root.path().join(format!("{label}.plist"));
9397 let binary = super::launchd_fixture::job_binary(root.path());
9398 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9399 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9400 plist.push_str(&plist_string("Label", &label));
9401 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
9402 for argument in [
9403 binary.to_string_lossy().as_ref(),
9404 "--ignored",
9405 "--exact",
9406 "service::tests::detached_reload_job",
9407 "--test-threads=1",
9408 ] {
9409 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
9410 }
9411 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
9412 plist.push_str(&plist_string(
9413 "WorkingDirectory",
9414 &root.path().to_string_lossy(),
9415 ));
9416 plist.push_str(&plist_string("ProcessType", "Background"));
9417 plist.push_str("</dict>\n</plist>\n");
9418 std::fs::write(&path, plist).expect("plist written");
9419
9420 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9421 let spawn_type = || {
9422 let printed = std::process::Command::new("launchctl")
9423 .args(["print", loaded.target()])
9424 .output()
9425 .expect("launchctl runs");
9426 String::from_utf8_lossy(&printed.stdout)
9427 .lines()
9428 .find_map(|line| {
9429 line.trim()
9430 .strip_prefix("spawn type = ")
9431 .map(str::to_string)
9432 })
9433 };
9434 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9435
9436 let deadline = std::time::Instant::now() + Duration::from_secs(90);
9437 while spawn_type().as_deref() != Some("interactive (4)") {
9438 assert!(
9439 std::time::Instant::now() < deadline,
9440 "the job did not come back under its rewritten plist; launchd reports {:?}",
9441 spawn_type()
9442 );
9443 std::thread::sleep(Duration::from_millis(250));
9444 }
9445 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9446 assert_eq!(
9447 plist_string_value(&rewritten, "ProcessType").as_deref(),
9448 Some(LAUNCHD_PROCESS_TYPE)
9449 );
9450 }
9451
9452 #[test]
9453 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9454 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9455 assert!(
9456 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9457 "{plist}"
9458 );
9459 assert!(plist.contains("<string>daemon</string>"), "{plist}");
9460 assert!(plist.contains("<string>run</string>"), "{plist}");
9461 }
9462
9463 #[test]
9464 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9465 assert_eq!(
9466 ServiceIdentity::product().launchd_label(),
9467 "io.github.IvanMurzak.runner-manager"
9468 );
9469 }
9470
9471 #[test]
9476 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9477 let xml = windows_scheduled_task_xml(
9478 &windows_plan(StartMode::Login),
9479 &TaskPrincipal::named("HOST\\operator"),
9480 );
9481 assert!(xml.contains("<LogonTrigger>"), "{xml}");
9482 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9483 assert!(
9484 xml.contains("<LogonType>InteractiveToken</LogonType>"),
9485 "{xml}"
9486 );
9487 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9488 assert!(
9489 xml.contains("<Interval>PT1M</Interval>"),
9490 "Task Scheduler takes whole minutes only, and rejects the registration outright \
9491 for anything finer:\n{xml}"
9492 );
9493 }
9494
9495 #[test]
9496 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9497 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9502 let policy =
9503 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9504 .expect("inside the supported range");
9505 assert_eq!(
9506 policy
9507 .effective_delay(DefinitionKind::WindowsScheduledTask)
9508 .as_secs(),
9509 enforced,
9510 "a {asked}s delay must be enforced as {enforced}s"
9511 );
9512 assert!(
9513 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9514 "rounding must never shorten the bound"
9515 );
9516 }
9517 }
9518
9519 #[test]
9520 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9521 let policy = RestartPolicy::default();
9522 for kind in [
9523 DefinitionKind::WindowsService,
9524 DefinitionKind::LaunchdPlist,
9525 DefinitionKind::SystemdUnit,
9526 ] {
9527 assert_eq!(
9528 policy.effective_delay(kind),
9529 policy.delay(),
9530 "{kind:?} takes seconds and enforces exactly what it is given"
9531 );
9532 }
9533 }
9534
9535 #[test]
9536 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9537 let host = Host::new();
9538 let operations = host.operations();
9539 operations
9540 .install(&host.request(StartMode::Login))
9541 .expect("an install at login");
9542 host.controls.edit("runner-manager", |registration| {
9544 registration.manager = DefinitionKind::WindowsScheduledTask;
9545 registration.restart_delay = Some(Duration::from_secs(60));
9546 });
9547
9548 let status = operations.status().expect("a status");
9549 assert!(
9550 status.is_healthy(),
9551 "minute granularity is the manager's, not a mis-registration: {status}"
9552 );
9553 assert!(
9554 status
9555 .notes()
9556 .iter()
9557 .any(|note| note.contains("whole minutes")),
9558 "but the operator must be told why 15 became 60: {status}"
9559 );
9560
9561 host.controls.edit("runner-manager", |registration| {
9564 registration.restart_delay = Some(Duration::from_secs(1));
9565 });
9566 assert!(
9567 !operations.status().expect("a status").is_healthy(),
9568 "a one-second delay is not what any manager was asked for"
9569 );
9570 }
9571
9572 #[test]
9573 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9574 let plan = windows_plan(StartMode::Login);
9575 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9576 assert_eq!(
9577 xml_value(&xml, "Command").as_deref(),
9578 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9579 "{xml}"
9580 );
9581 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9582 }
9583
9584 #[test]
9585 fn an_account_name_holding_xml_punctuation_is_escaped() {
9586 let xml = windows_scheduled_task_xml(
9587 &windows_plan(StartMode::Login),
9588 &TaskPrincipal::named("R&D\\ops"),
9589 );
9590 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
9591 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9592 }
9593
9594 #[test]
9599 fn the_service_starts_automatically_under_the_account_the_store_admits() {
9600 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9601 let directives = ini_directives(&text, "windows-service");
9602 assert_eq!(
9603 directives.get("StartType").map(String::as_str),
9604 Some("AutoStart")
9605 );
9606 assert_eq!(
9607 directives.get("Account").map(String::as_str),
9608 Some("NT AUTHORITY\\SYSTEM")
9609 );
9610 assert_eq!(
9611 directives.get("ServiceType").map(String::as_str),
9612 Some("OWN_PROCESS")
9613 );
9614 assert_eq!(
9615 directives
9616 .get("FailureActionRestartDelaySecs")
9617 .map(String::as_str),
9618 Some("15")
9619 );
9620 assert_eq!(
9621 directives
9622 .get("FailureActionsOnNonCrashFailures")
9623 .map(String::as_str),
9624 Some("true"),
9625 "without this flag a non-zero exit is not a failure the manager restarts"
9626 );
9627 }
9628
9629 #[test]
9630 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9631 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9632 assert_eq!(spec.account, None);
9633 assert!(spec.automatic_start);
9634 assert!(
9635 spec.command_line.contains("daemon run"),
9636 "{}",
9637 spec.command_line
9638 );
9639 }
9640
9641 #[test]
9646 fn each_definition_goes_where_its_platform_expects_it() {
9647 let home = PathBuf::from("/home/op");
9648 assert_eq!(
9649 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9650 Some(Path::new(
9651 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9652 ))
9653 );
9654 assert_eq!(
9655 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9656 Some(Path::new(
9657 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9658 ))
9659 );
9660 assert_eq!(
9661 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9662 Some(Path::new("/etc/systemd/system/runner-manager.service"))
9663 );
9664 assert_eq!(
9665 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9666 Some(Path::new(
9667 "/home/op/.config/systemd/user/runner-manager.service"
9668 ))
9669 );
9670 assert_eq!(
9671 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9672 None,
9673 "the Service Control Manager has no file"
9674 );
9675 }
9676
9677 #[test]
9678 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9679 assert_eq!(
9680 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9681 None
9682 );
9683 assert_eq!(
9684 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9685 None
9686 );
9687 }
9688
9689 #[test]
9694 fn the_rendered_definitions_are_all_least_privilege() {
9695 let linux = linux_plan(StartMode::Boot);
9696 let windows = windows_plan(StartMode::Boot);
9697 for (definition, plan) in [
9698 (ServiceDefinition::systemd(&linux, None), &linux),
9699 (ServiceDefinition::launchd(&linux, None), &linux),
9700 (ServiceDefinition::windows_service(&windows), &windows),
9701 ] {
9702 let review = review_least_privilege(&definition, plan);
9703 assert!(
9704 review.is_least_privilege(),
9705 "{:?} should be least privilege, got:\n{review}",
9706 definition.kind()
9707 );
9708 assert!(
9709 !review.controls().is_empty(),
9710 "a review that confirms nothing proves nothing: {:?}",
9711 definition.kind()
9712 );
9713 }
9714 }
9715
9716 #[test]
9717 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9718 let plan = windows_plan(StartMode::Login);
9719 let definition =
9720 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9721 let review = review_least_privilege(&definition, &plan);
9722 assert!(review.is_least_privilege(), "{review}");
9723 assert!(
9724 review
9725 .controls()
9726 .iter()
9727 .any(|control| control.contains("LeastPrivilege")),
9728 "{review}"
9729 );
9730 }
9731
9732 #[test]
9733 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9734 let plan = linux_plan(StartMode::Boot);
9735 let rendered = systemd_unit(&plan);
9736 let widened = edited(
9737 &rendered,
9738 "ReadWritePaths=/var/lib/runner-manager/config",
9739 "ReadWritePaths=/etc /var/lib/runner-manager/config",
9740 );
9741 let review = review_least_privilege(
9742 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9743 &plan,
9744 );
9745 assert!(!review.is_least_privilege(), "{review}");
9746 assert!(
9747 review
9748 .excesses()
9749 .iter()
9750 .any(|finding| finding.detail.contains("/etc")),
9751 "the review must name the directory it objects to: {review}"
9752 );
9753 }
9754
9755 #[test]
9756 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9757 let plan = linux_plan(StartMode::Boot);
9758 let rendered = systemd_unit(&plan);
9759 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9760 let review = review_least_privilege(
9761 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9762 &plan,
9763 );
9764 assert!(!review.is_least_privilege(), "{review}");
9765 assert!(
9766 review
9767 .excesses()
9768 .iter()
9769 .any(|finding| finding.subject == "NoNewPrivileges"),
9770 "{review}"
9771 );
9772 }
9773
9774 fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
9776 review_least_privilege(
9777 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
9778 plan,
9779 )
9780 .findings()
9781 .iter()
9782 .any(|finding| finding.subject == subject && finding.kind == kind)
9783 }
9784
9785 #[test]
9786 fn every_unit_drops_every_capability_and_boot_units_hide_home_trees() {
9787 let boot = systemd_unit(&linux_plan(StartMode::Boot));
9788 let directives = ini_directives(&boot, "Service");
9789 assert_eq!(directives["CapabilityBoundingSet"], "");
9790 assert_eq!(directives["AmbientCapabilities"], "");
9791 assert_eq!(directives["ProtectHome"], "tmpfs");
9792 assert!(!directives.contains_key("BindPaths"));
9793
9794 let login = systemd_unit(&linux_plan(StartMode::Login));
9795 let directives = ini_directives(&login, "Service");
9796 assert_eq!(directives["CapabilityBoundingSet"], "");
9797 assert!(!directives.contains_key("ProtectHome"));
9798
9799 let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
9800 assert!(reports(
9801 &unprotected,
9802 &linux_plan(StartMode::Boot),
9803 "ProtectHome",
9804 FindingKind::Excess
9805 ));
9806 let inverted = boot.replace(
9807 "CapabilityBoundingSet=\n",
9808 "CapabilityBoundingSet=~CAP_SYS_ADMIN\n",
9809 );
9810 assert!(reports(
9811 &inverted,
9812 &linux_plan(StartMode::Boot),
9813 "CapabilityBoundingSet",
9814 FindingKind::Excess
9815 ));
9816 }
9817
9818 #[test]
9819 fn status_reports_the_registered_account_and_falls_back_to_the_record() {
9820 let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
9821 for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
9822 let host = Host::new();
9823 let operations = host.operations();
9824 operations.install(&host.request(StartMode::Boot)).unwrap();
9825 host.controls.edit("runner-manager", |registration| {
9826 registration.account = registered.map(str::to_string);
9827 });
9828 let status = operations.status().unwrap().to_string();
9829 assert!(
9830 status.contains(&format!("account {shown}\n")),
9831 "{status}"
9832 );
9833 }
9834 }
9835
9836 #[cfg(target_os = "linux")]
9837 #[test]
9838 fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
9839 let host = Host::new();
9840 let operations = host.operations();
9841 operations.install(&host.request(StartMode::Boot)).unwrap();
9842 host.controls.edit("runner-manager", |registration| {
9843 registration.account = Some("ivan".to_string());
9844 });
9845 let refused = operations
9846 .install(&host.request(StartMode::Boot))
9847 .expect_err("a unit running as ivan cannot use root's tree");
9848 let message = refused.to_string();
9849 assert!(message.contains("run as ivan"), "{message}");
9850 assert!(message.contains("Remove `User=`"), "{message}");
9851 }
9852
9853 #[test]
9854 fn manager_account_spellings_match_the_record() {
9855 assert_eq!(
9856 windows_service_account("LocalSystem"),
9857 ServiceAccount::LocalSystem.as_str()
9858 );
9859 assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
9860 assert_eq!(
9861 scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
9862 None
9863 );
9864 assert_eq!(
9865 scheduled_task_account(Some("HOST\\op".into())),
9866 Some("HOST\\op".to_string())
9867 );
9868 }
9869
9870 #[test]
9871 fn exec_start_prefixes_are_not_part_of_the_binary() {
9872 for (value, expected) in [
9873 ("/opt/rm daemon run", "/opt/rm daemon run"),
9874 ("-/opt/rm daemon run", "/opt/rm daemon run"),
9875 ("+!/opt/rm daemon run", "/opt/rm daemon run"),
9876 ("!!/opt/rm daemon run", "/opt/rm daemon run"),
9877 ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
9878 ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
9879 ] {
9880 assert_eq!(systemd_exec_command_line(value), expected, "{value}");
9881 }
9882 assert_eq!(
9883 executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
9884 Some(PathBuf::from("/opt/rm"))
9885 );
9886 }
9887
9888 #[test]
9889 fn restart_sec_accepts_systemd_time_spans() {
9890 for (value, expected) in [
9891 ("15", Duration::from_secs(15)),
9892 ("15s", Duration::from_secs(15)),
9893 ("1min", Duration::from_secs(60)),
9894 ("1min 30s", Duration::from_secs(90)),
9895 ("1min30s", Duration::from_secs(90)),
9896 ("2h", Duration::from_secs(7_200)),
9897 ("500ms", Duration::from_millis(500)),
9898 ("1.5s", Duration::from_millis(1_500)),
9899 ] {
9900 assert_eq!(systemd_timespan(value), Some(expected), "{value}");
9901 }
9902 for value in ["", "infinity", "soon", "5 parsecs", "s"] {
9903 assert_eq!(systemd_timespan(value), None, "{value}");
9904 }
9905 }
9906
9907 #[cfg(unix)]
9908 #[test]
9909 fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
9910 let system = ServiceDirectories::of(&AppPaths::system());
9911 assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
9912 assert_eq!(
9913 data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
9914 Some("root")
9915 );
9916 }
9917
9918 #[test]
9919 fn a_boot_service_refuses_directories_it_could_not_open() {
9920 let var = Path::new("/var/lib/runner-manager/state");
9921 assert_eq!(boot_directory_refusal(var, Some(0)), None);
9922 let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
9923 assert!(refused.contains("uid 1000"), "{refused}");
9924 assert!(boot_directory_refusal(var, None).is_some());
9925 for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
9926 let refused =
9927 boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
9928 assert!(refused.contains("cannot see"), "{refused}");
9929 }
9930 }
9931
9932 #[cfg(unix)]
9933 #[test]
9934 fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
9935 use std::os::unix::fs::PermissionsExt as _;
9936
9937 let from = tempfile::tempdir().unwrap();
9938 let to = tempfile::tempdir().unwrap();
9939 let target = to.path().join("state");
9940 std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
9941 std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
9942 std::fs::write(from.path().join("secret"), b"x").unwrap();
9943 std::fs::set_permissions(
9944 from.path().join("secret"),
9945 std::fs::Permissions::from_mode(0o600),
9946 )
9947 .unwrap();
9948 std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
9949 std::fs::create_dir(from.path().join("bin")).unwrap();
9950
9951 copy_tree(from.path(), &target, Some("bin")).unwrap();
9952
9953 assert_eq!(
9954 std::fs::read(target.join("journal/2026/attempt")).unwrap(),
9955 b"entry"
9956 );
9957 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
9958 assert_eq!(mode(&target.join("secret")), 0o600);
9959 assert_eq!(mode(&target.join("journal")), 0o700);
9960 assert_eq!(
9961 std::fs::read_link(target.join("latest")).unwrap(),
9962 Path::new("journal")
9963 );
9964 assert!(!target.join("bin").exists());
9965
9966 std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
9968 set_aside(&target, "20261110T000000Z").unwrap();
9969 assert!(!target.exists());
9970 let aside = to.path().join("state.before-move-20261110T000000Z");
9971 assert_eq!(
9972 std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
9973 b"stale"
9974 );
9975 copy_tree(from.path(), &target, Some("bin")).unwrap();
9976 assert!(!target.join("stale.sqlite3-wal").exists());
9977 set_aside(&to.path().join("absent"), "x").unwrap();
9978 }
9979
9980 #[test]
9981 fn systemd_drop_ins_replace_base_service_directives() {
9982 let base = systemd_unit(&linux_plan(StartMode::Boot));
9983 let combined = format!(
9984 "# /etc/systemd/system/runner-manager.service\n{base}\n\
9985 # /etc/systemd/system/runner-manager.service.d/account.conf\n\
9986 [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
9987 ExecStart=/opt/runner-manager daemon run\n"
9988 );
9989 let directives = ini_directives(&combined, "Service");
9990 assert_eq!(directives["User"], "ivan");
9991 assert_eq!(directives["RestartSec"], "30");
9992 assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
9993 assert_eq!(directives["ProtectSystem"], "strict");
9994 }
9995
9996 #[test]
9997 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
9998 let plan = linux_plan(StartMode::Boot);
9999 let rendered = systemd_unit(&plan);
10000 let widened = edited(
10001 &rendered,
10002 "CapabilityBoundingSet=\n",
10003 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10004 );
10005 let review = review_least_privilege(
10006 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10007 &plan,
10008 );
10009 assert!(!review.is_least_privilege(), "{review}");
10010 assert!(
10011 review
10012 .excesses()
10013 .iter()
10014 .any(|finding| finding.subject == "CapabilityBoundingSet"),
10015 "{review}"
10016 );
10017 }
10018
10019 #[test]
10020 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10021 let plan = linux_plan(StartMode::Boot);
10022 let rendered = systemd_unit(&plan);
10023 let widened = edited(
10024 &rendered,
10025 "[Install]",
10026 "ListenStream=127.0.0.1:9000\n\n[Install]",
10027 );
10028 let review = review_least_privilege(
10029 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10030 &plan,
10031 );
10032 assert!(
10033 !review.is_least_privilege(),
10034 "07-security.md rule 2 forbids any inbound surface: {review}"
10035 );
10036 }
10037
10038 #[test]
10039 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10040 let plan = linux_plan(StartMode::Boot);
10041 let rendered = systemd_unit(&plan);
10042 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10043 let review = review_least_privilege(
10044 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10045 &plan,
10046 );
10047 assert!(
10048 review.is_least_privilege(),
10049 "too little authority is not an excess: {review}"
10050 );
10051 assert!(
10052 review
10053 .findings()
10054 .iter()
10055 .any(|finding| finding.kind == FindingKind::Shortfall
10056 && finding.detail.contains("runtime")),
10057 "{review}"
10058 );
10059 }
10060
10061 #[test]
10062 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10063 let plan = linux_plan(StartMode::Login);
10064 let rendered = launchd_plist(&plan);
10065 let widened = edited(
10066 &rendered,
10067 "<key>ProcessType</key>",
10068 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
10069 );
10070 let review = review_least_privilege(
10071 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10072 &plan,
10073 );
10074 assert!(!review.is_least_privilege(), "{review}");
10075 assert!(
10076 review
10077 .excesses()
10078 .iter()
10079 .any(|finding| finding.subject == "UserName"),
10080 "{review}"
10081 );
10082 }
10083
10084 #[test]
10085 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10086 let plan = linux_plan(StartMode::Boot);
10087 let rendered = launchd_plist(&plan);
10088 let widened = edited(
10089 &rendered,
10090 "<key>SessionCreate</key>\n <false/>",
10091 "<key>SessionCreate</key>\n <true/>",
10092 );
10093 let review = review_least_privilege(
10094 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10095 &plan,
10096 );
10097 assert!(!review.is_least_privilege(), "{review}");
10098 assert!(
10099 review
10100 .excesses()
10101 .iter()
10102 .any(|finding| finding.subject == "SessionCreate"),
10103 "{review}"
10104 );
10105 }
10106
10107 #[test]
10108 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10109 let plan = linux_plan(StartMode::Boot);
10110 let rendered = launchd_plist(&plan);
10111 let widened = edited(
10112 &rendered,
10113 "<key>ProcessType</key>",
10114 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
10115 );
10116 let review = review_least_privilege(
10117 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10118 &plan,
10119 );
10120 assert!(!review.is_least_privilege(), "{review}");
10121 }
10122
10123 #[test]
10124 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10125 let plan = windows_plan(StartMode::Login);
10126 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10127 let widened = edited(
10128 &rendered,
10129 "<RunLevel>LeastPrivilege</RunLevel>",
10130 "<RunLevel>HighestAvailable</RunLevel>",
10131 );
10132 let review = review_least_privilege(
10133 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10134 &plan,
10135 );
10136 assert!(!review.is_least_privilege(), "{review}");
10137 assert!(
10138 review
10139 .excesses()
10140 .iter()
10141 .any(|finding| finding.subject == "RunLevel"),
10142 "{review}"
10143 );
10144 }
10145
10146 #[test]
10147 fn a_task_that_would_store_a_password_is_not_least_privilege() {
10148 let plan = windows_plan(StartMode::Login);
10149 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10150 let widened = edited(
10151 &rendered,
10152 "<LogonType>InteractiveToken</LogonType>",
10153 "<LogonType>Password</LogonType>",
10154 );
10155 let review = review_least_privilege(
10156 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10157 &plan,
10158 );
10159 assert!(!review.is_least_privilege(), "{review}");
10160 }
10161
10162 #[test]
10163 fn an_interactive_windows_service_is_not_least_privilege() {
10164 let plan = windows_plan(StartMode::Boot);
10165 let rendered = windows_service_descriptor(&plan);
10166 let widened = edited(
10167 &rendered,
10168 "ServiceType=OWN_PROCESS",
10169 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10170 );
10171 let review = review_least_privilege(
10172 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10173 &plan,
10174 );
10175 assert!(!review.is_least_privilege(), "{review}");
10176 assert!(
10177 review
10178 .excesses()
10179 .iter()
10180 .any(|finding| finding.subject == "ServiceType"),
10181 "{review}"
10182 );
10183 }
10184
10185 #[test]
10186 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10187 let plan = windows_plan(StartMode::Boot);
10188 let rendered = windows_service_descriptor(&plan);
10189 let changed = edited(
10190 &rendered,
10191 "Account=NT AUTHORITY\\SYSTEM",
10192 "Account=NT AUTHORITY\\LocalService",
10193 );
10194 let review = review_least_privilege(
10195 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10196 &plan,
10197 );
10198 assert!(review.is_least_privilege(), "{review}");
10204 assert!(
10205 review.findings().iter().any(|finding| {
10206 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10207 }),
10208 "{review}"
10209 );
10210 }
10211
10212 #[test]
10217 fn a_recorded_path_that_is_still_there_is_current() {
10218 let host = Host::new();
10219 let state = inspect_binary(&host.binary, Some(&host.binary));
10220 assert!(!state.is_error(), "{state}");
10221 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10222 }
10223
10224 #[test]
10225 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10226 let host = Host::new();
10227 let recorded = host.binary.clone();
10231 let healthy = inspect_binary(&recorded, Some(&recorded));
10232 assert!(
10233 !healthy.is_error(),
10234 "the discriminator: before the binary moves, this must be healthy"
10235 );
10236
10237 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10238
10239 let state = inspect_binary(&recorded, Some(&recorded));
10240 assert!(state.is_error(), "{state}");
10241 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10242 assert!(
10243 state.to_string().contains("npm"),
10244 "the message must name the cause an operator will not otherwise connect: {state}"
10245 );
10246 }
10247
10248 #[test]
10249 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10250 let root = tempfile::tempdir().expect("a temporary directory");
10251 let state = inspect_binary(root.path(), None);
10252 assert!(state.is_error(), "{state}");
10253 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10254 }
10255
10256 #[test]
10257 fn a_registration_naming_a_different_binary_is_a_divergence() {
10258 let host = Host::new();
10259 let other = host.binary.with_file_name("something-else");
10260 let state = inspect_binary(&host.binary, Some(&other));
10261 assert!(state.is_error(), "{state}");
10262 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10263 }
10264
10265 #[test]
10266 fn absence_is_reported_before_divergence() {
10267 let host = Host::new();
10268 let recorded = host.binary.clone();
10269 std::fs::remove_file(&recorded).expect("removable");
10270 let other = recorded.with_file_name("something-else");
10271 assert!(matches!(
10275 inspect_binary(&recorded, Some(&other)),
10276 BinaryPath::Missing { .. }
10277 ));
10278 }
10279
10280 #[test]
10285 fn the_record_round_trips_through_toml() {
10286 let host = Host::new();
10287 let plan = InstallPlan::resolve(
10288 ServiceIdentity::product(),
10289 &host.request(StartMode::Boot),
10290 ServiceDirectories::of(&host.paths),
10291 )
10292 .expect("a resolvable plan");
10293 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10294 let record = InstallRecord::of(&plan, &definition, Utc::now());
10295 record.write(&host.paths).expect("a writable record");
10296 let read = InstallRecord::read(&host.paths)
10297 .expect("a readable record")
10298 .expect("a record is there");
10299 assert_eq!(read, record);
10300 assert_eq!(read.binary, host.binary);
10301 assert!(read.binary.is_absolute());
10302 }
10303
10304 #[test]
10305 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10306 let host = Host::new();
10307 let identity = ServiceIdentity::product();
10308 let plan = InstallPlan::resolve(
10309 identity.clone(),
10310 &host.request(StartMode::Boot),
10311 ServiceDirectories::of(&host.paths),
10312 )
10313 .expect("a resolvable plan");
10314 let installed = systemd_unit(&plan);
10315 let definition =
10316 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10317 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10318 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10319
10320 assert_eq!(
10321 systemd_definition_drift(&identity, &record, path.clone(), &installed),
10322 None,
10323 "the record must rebuild exactly the unit `service install` wrote"
10324 );
10325
10326 record.installed_by_version = "0.4.7".into();
10327 let frozen = installed.replace(
10328 "[Service]\n",
10329 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10330 );
10331 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10332 .expect("an outdated unit is drift");
10333 assert_eq!(drift.installed_by_version, "0.4.7");
10334 assert_eq!(drift.rendered, installed);
10335
10336 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10337 .with_definition_drift(Some(drift));
10338 assert!(!status.is_healthy());
10339 assert!(
10340 status
10341 .problems()
10342 .iter()
10343 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10344 "{status}"
10345 );
10346 }
10347
10348 #[test]
10349 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10350 let host = Host::new();
10351 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10352 let first = Utc::now() - chrono::Duration::hours(9);
10353 record_github_credential_rejected(&host.paths, first).unwrap();
10354 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10355 let since = github_credential_rejected_since(&host.paths).unwrap();
10356 assert_eq!(since, Some(first));
10357
10358 let status = ServiceStatus::compose(
10359 ServiceIdentity::product(),
10360 None,
10361 None,
10362 None,
10363 None,
10364 &host.paths,
10365 )
10366 .with_credential_rejection(since);
10367 assert!(!status.is_healthy());
10368 assert_eq!(status.credential_rejected_since(), Some(first));
10369 assert!(status.to_string().contains("auth login"), "{status}");
10370
10371 clear_github_credential_rejection(&host.paths).unwrap();
10372 clear_github_credential_rejection(&host.paths).unwrap();
10373 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10374 }
10375
10376 #[test]
10378 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10379 use crate::launch_health::{
10380 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10381 record_launches_blocked,
10382 };
10383
10384 let host = Host::new();
10385 let compose = || {
10386 ServiceStatus::compose(
10387 ServiceIdentity::product(),
10388 None,
10389 None,
10390 None,
10391 None,
10392 &host.paths,
10393 )
10394 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10395 };
10396 assert!(
10397 !compose()
10398 .problems()
10399 .iter()
10400 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10401 );
10402 let blocked = BlockCause::FenceHeld {
10403 directory: PathBuf::from("launch-fence"),
10404 owner: None,
10405 }
10406 .blocked_since(Utc::now(), Viewpoint::Guest);
10407 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10408
10409 let status = compose();
10410 assert!(!status.is_healthy());
10411 assert_eq!(status.launches_blocked(), Some(&blocked));
10412 let problem = status
10413 .problems()
10414 .iter()
10415 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10416 .expect("reported under its own subject");
10417 assert!(problem.detail.contains("systemctl restart"), "{status}");
10418
10419 clear_launches_blocked(&host.paths).unwrap();
10420 assert_eq!(compose().launches_blocked(), None);
10421 }
10422
10423 #[test]
10426 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10427 let host = Host::new();
10428 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10429 let first = Utc::now() - chrono::Duration::minutes(40);
10430 record_credential_unreadable(&host.paths, first).unwrap();
10431 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10432 let since = credential_unreadable_since(&host.paths).unwrap();
10433 assert_eq!(since, Some(first), "a restart loop must not move the start");
10434
10435 let status = ServiceStatus::compose(
10436 ServiceIdentity::product(),
10437 None,
10438 None,
10439 None,
10440 None,
10441 &host.paths,
10442 )
10443 .with_credential_unreadable(since);
10444 assert!(!status.is_healthy());
10445 let problem = status
10446 .problems()
10447 .iter()
10448 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10449 .expect("reported under its own subject");
10450 assert!(
10452 problem
10453 .detail
10454 .contains(&sign_in_instruction(StartMode::default())),
10455 "{status}"
10456 );
10457
10458 clear_credential_unreadable(&host.paths).unwrap();
10459 clear_credential_unreadable(&host.paths).unwrap();
10460 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10461 let healthy = ServiceStatus::compose(
10462 ServiceIdentity::product(),
10463 None,
10464 None,
10465 None,
10466 None,
10467 &host.paths,
10468 )
10469 .with_credential_unreadable(None);
10470 assert!(
10471 !healthy
10472 .problems()
10473 .iter()
10474 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10475 "{healthy}"
10476 );
10477 }
10478
10479 #[test]
10480 fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10481 assert_eq!(sudo_program(None), "runner-manager");
10482 assert_eq!(
10483 sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10484 "runner-manager"
10485 );
10486 assert_eq!(
10487 sudo_program(Some(Path::new(
10488 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10489 runner-manager-linux-x64/bin/runner-manager"
10490 ))),
10491 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10492 runner-manager-linux-x64/bin/runner-manager"
10493 );
10494 assert_eq!(
10495 sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10496 "'/home/o'\\''p/my bin/runner-manager'"
10497 );
10498 assert_eq!(
10499 sudo_command("service status"),
10500 "sudo runner-manager service status"
10501 );
10502 }
10503
10504 #[test]
10505 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
10506 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
10507 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
10508 assert_eq!(
10509 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
10510 cfg!(unix),
10511 "a machine-scoped store needs root on Unix"
10512 );
10513 assert_eq!(
10514 sign_in_instruction(StartMode::Login).contains("SSH"),
10515 cfg!(target_os = "macos"),
10516 "only the macOS login keychain is out of an SSH session's reach"
10517 );
10518 }
10519
10520 #[cfg(unix)]
10529 #[test]
10530 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
10531 use std::os::unix::fs::PermissionsExt as _;
10532
10533 let host = Host::new();
10534 let plan = InstallPlan::resolve(
10535 ServiceIdentity::product(),
10536 &host.request(StartMode::Boot),
10537 ServiceDirectories::of(&host.paths),
10538 )
10539 .expect("a resolvable plan");
10540 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10541 InstallRecord::of(&plan, &definition, Utc::now())
10542 .write(&host.paths)
10543 .expect("a writable record");
10544
10545 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
10546 .expect("the record is there")
10547 .permissions()
10548 .mode()
10549 & 0o777;
10550 assert_eq!(
10551 mode, 0o644,
10552 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
10553 service install` wrote, and `service status` fails on their own host. It holds no \
10554 credential and sits in a 0700 directory, so 0644 discloses nothing"
10555 );
10556 }
10557
10558 #[test]
10562 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
10563 let host = Host::new();
10564 let path = InstallRecord::path(&host.paths);
10565 std::fs::write(
10566 &path,
10567 format!(
10568 "schema_version = {RECORD_SCHEMA_VERSION}
10569service_name = \"runner-manager\"
10570 manager = \"systemd\"
10571start_mode = \"boot\"
10572account = \"root\"
10573 binary = \"/x\"
10574arguments = []
10575restart_delay_secs = 15
10576 restart_reset_secs = 600
10577log_file = \"/x\"
10578 installed_at = \"2026-01-01T00:00:00Z\"
10579installed_by_version = \"0.1.0\"
10580 [directories]
10581config = \"/a\"
10582state = \"/b\"
10583runtime = \"/c\"
10584logs = \"/d\"
10585"
10586 ),
10587 )
10588 .expect("a writable record");
10589 let read = InstallRecord::read(&host.paths)
10590 .expect("a record missing an optional field is still readable")
10591 .expect("a record is there");
10592 assert_eq!(
10593 read.source_binary, None,
10594 "the legacy layout has no source, and must not invent one"
10595 );
10596 }
10597
10598 #[test]
10600 fn a_registration_remembers_the_file_it_was_copied_from() {
10601 let host = Host::new();
10602 let source = host.binary.with_file_name("npm-installed-runner-manager");
10603 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
10604 let plan = InstallPlan::resolve(
10605 ServiceIdentity::product(),
10606 &host.request(StartMode::Boot).copied_from(&source),
10607 ServiceDirectories::of(&host.paths),
10608 )
10609 .expect("a resolvable plan");
10610 let definition = ServiceDefinition::from_text(
10611 DefinitionKind::SystemdUnit,
10612 "[Service]
10613",
10614 );
10615 let record = InstallRecord::of(&plan, &definition, Utc::now());
10616 record.write(&host.paths).expect("a writable record");
10617
10618 let read = InstallRecord::read(&host.paths)
10619 .expect("a readable record")
10620 .expect("a record is there");
10621 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
10622 assert_ne!(
10623 read.source_binary.as_deref(),
10624 Some(read.binary.as_path()),
10625 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
10626 );
10627 }
10628
10629 #[test]
10630 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
10631 let host = Host::new();
10632 let path = InstallRecord::path(&host.paths);
10633 std::fs::write(
10634 &path,
10635 format!(
10636 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
10637 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
10638 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
10639 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
10640 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
10641 RECORD_SCHEMA_VERSION + 1
10642 ),
10643 )
10644 .expect("a writable record");
10645 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
10646 assert!(
10647 matches!(error, ServiceError::RecordUnreadable { .. }),
10648 "{error}"
10649 );
10650 assert!(
10651 error.to_string().contains("service uninstall"),
10652 "the message must say how to recover: {error}"
10653 );
10654 }
10655
10656 #[test]
10657 fn no_record_is_not_an_error() {
10658 let host = Host::new();
10659 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
10660 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
10661 }
10662
10663 #[test]
10668 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
10669 let host = Host::new();
10670 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
10671 }
10672
10673 #[test]
10674 fn the_heartbeat_round_trips_to_the_second() {
10675 let host = Host::new();
10676 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
10677 .expect("a valid timestamp")
10678 .with_timezone(&Utc);
10679 record_github_contact(&host.paths, at).expect("a writable heartbeat");
10680 assert_eq!(
10681 last_github_contact(&host.paths).expect("readable"),
10682 Some(at)
10683 );
10684 }
10685
10686 #[test]
10687 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
10688 let host = Host::new();
10689 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
10690 let error = last_github_contact(&host.paths)
10691 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
10692 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10693 }
10694
10695 #[test]
10701 fn a_runner_root_refusal_round_trips_for_service_status() {
10702 let host = Host::new();
10703 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10704
10705 assert!(
10706 runner_root_refusals(&host.paths)
10707 .expect("readable")
10708 .is_empty(),
10709 "no record means every policy is placing runners"
10710 );
10711
10712 record_runner_root_refusal(
10713 &host.paths,
10714 "policy-a",
10715 at,
10716 "denied_by_privacy_policy",
10717 "/Volumes/NVME/runners",
10718 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
10719 )
10720 .expect("a writable record");
10721
10722 let refusals = runner_root_refusals(&host.paths).expect("readable");
10723 assert_eq!(refusals.len(), 1);
10724 assert_eq!(refusals[0].policy, "policy-a");
10725 assert_eq!(refusals[0].at, at);
10726 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
10727 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
10728 assert!(
10729 refusals[0].detail.contains("/Volumes/NVME/runners")
10730 && refusals[0].detail.contains("Full Disk Access"),
10731 "the path and the remediation are the whole point of this file: {refusals:?}"
10732 );
10733 }
10734
10735 #[test]
10743 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
10744 let host = Host::new();
10745 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10746 record_runner_root_refusal(
10747 &host.paths,
10748 "broken",
10749 at,
10750 "denied_by_privacy_policy",
10751 "/Volumes/NVME/runners",
10752 "detail",
10753 )
10754 .expect("a writable record");
10755 record_runner_root_refusal(
10756 &host.paths,
10757 "also-broken",
10758 at,
10759 "not_writable",
10760 "/srv/other",
10761 "detail",
10762 )
10763 .expect("a writable record");
10764
10765 clear_runner_root_refusal(&host.paths, "healthy")
10767 .expect("clearing an absent policy is fine");
10768 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
10769
10770 let refusals = runner_root_refusals(&host.paths).expect("readable");
10771 assert_eq!(
10772 refusals
10773 .iter()
10774 .map(|r| r.policy.as_str())
10775 .collect::<Vec<_>>(),
10776 vec!["broken"],
10777 "the policy that is still refused must keep its record"
10778 );
10779 }
10780
10781 #[test]
10784 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
10785 let host = Host::new();
10786 record_runner_root_refusal(
10787 &host.paths,
10788 "p",
10789 Utc::now(),
10790 "not_writable",
10791 "/srv/x",
10792 "detail",
10793 )
10794 .expect("a writable record");
10795
10796 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
10797 assert!(
10798 !root_refusal_path(&host.paths).exists(),
10799 "a host with nothing refused leaves nothing behind"
10800 );
10801 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
10802 }
10803
10804 #[test]
10807 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
10808 let host = Host::new();
10809 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
10810 let error = runner_root_refusals(&host.paths)
10811 .expect_err("an unparseable record is not the same as no record");
10812 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10813 }
10814
10815 #[test]
10825 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
10826 let host = Host::new();
10827 record_runner_root_refusal(
10828 &host.paths,
10829 "policy-a",
10830 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
10831 "denied_by_privacy_policy",
10832 "/Volumes/NVME/runners",
10833 "Grant Full Disk Access to the program that runs the service",
10834 )
10835 .expect("a writable record");
10836
10837 let status = host.operations().status().expect("a readable status");
10838 let notes = status.notes().join("\n");
10839
10840 assert!(
10841 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
10842 "the directory and the remediation the log had to scrub must appear here: {notes}"
10843 );
10844 assert!(
10845 notes.contains("policy-a"),
10846 "the operator has to know which target placed no runner: {notes}"
10847 );
10848 assert!(
10849 !status
10850 .problems()
10851 .iter()
10852 .any(|problem| problem.subject == "runner root"),
10853 "a record nothing an operator types can clear must not drive the exit code"
10854 );
10855 }
10856
10857 #[test]
10863 fn install_records_the_absolute_binary_path_and_the_four_directories() {
10864 let host = Host::new();
10865 let installed = host
10866 .operations()
10867 .install(&host.request(StartMode::Boot))
10868 .expect("an install against the recording controls");
10869
10870 assert_eq!(installed.record.binary, host.binary);
10871 assert!(installed.record.binary.is_absolute());
10872 assert_eq!(installed.record.start_mode, StartMode::Boot);
10873 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
10874 assert_eq!(
10875 installed.record.directories,
10876 ServiceDirectories::of(&host.paths)
10877 );
10878 assert_eq!(
10879 installed.record.log_file,
10880 host.paths.logs_dir().join(LOG_FILE_STEM)
10881 );
10882 assert_eq!(installed.record.restart_delay_secs, 15);
10883
10884 let registrations = host.controls.registrations();
10885 assert_eq!(registrations.len(), 1);
10886 assert_eq!(registrations[0].0, StartMode::Boot);
10887 assert_eq!(registrations[0].1, "runner-manager");
10888 }
10889
10890 #[test]
10891 fn install_is_refused_while_the_single_instance_lock_is_held() {
10892 let host = Host::new();
10893 {
10895 let operations = host.operations();
10896 operations
10897 .install(&host.request(StartMode::Boot))
10898 .expect("an install with the lock free");
10899 operations.uninstall().expect("a clean slate");
10900 }
10901
10902 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
10903 .expect("this process takes the lock first");
10904
10905 let error = host
10906 .operations()
10907 .install(&host.request(StartMode::Boot))
10908 .expect_err("a second agent must not be registered while one is running");
10909 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
10910 assert!(
10911 error.to_string().contains("already running"),
10912 "the message must be actionable: {error}"
10913 );
10914 assert!(
10915 host.controls.registrations().is_empty(),
10916 "a refused install must register nothing"
10917 );
10918 assert_eq!(
10919 InstallRecord::read(&host.paths).expect("readable"),
10920 None,
10921 "a refused install must write no record"
10922 );
10923 }
10924
10925 #[test]
10933 fn installing_over_the_same_start_mode_replaces_the_registration() {
10934 let host = Host::new();
10935 let operations = host.operations();
10936 operations
10937 .install(&host.request(StartMode::Boot))
10938 .expect("the first install");
10939
10940 let again = operations
10941 .install(&host.request(StartMode::Boot))
10942 .expect("an install over the same mode replaces rather than refusing");
10943
10944 assert!(
10945 again.replaced_existing,
10946 "the operator is told this replaced something rather than made it"
10947 );
10948 assert_eq!(
10949 host.controls.registrations().len(),
10950 1,
10951 "replacing must not leave two registrations behind"
10952 );
10953 assert_eq!(
10954 InstallRecord::read(&host.paths)
10955 .expect("readable")
10956 .expect("a record")
10957 .start_mode,
10958 StartMode::Boot
10959 );
10960 }
10961
10962 #[test]
10966 fn installing_over_the_other_start_mode_is_refused() {
10967 let host = Host::new();
10968 let operations = host.operations();
10969 operations
10970 .install(&host.request(StartMode::Boot))
10971 .expect("the first install");
10972
10973 let error = operations
10974 .install(&host.request(StartMode::Login))
10975 .expect_err("a mode change is not an install");
10976 assert!(
10977 matches!(
10978 error,
10979 ServiceError::AlreadyInstalled {
10980 existing: StartMode::Boot,
10981 requested: StartMode::Login,
10982 ..
10983 }
10984 ),
10985 "{error}"
10986 );
10987 assert!(
10988 !error
10989 .to_string()
10990 .contains("switch the start mode in place,"),
10991 "the old remedy named a capability no command offers; the terminal UI is where \
10992 the start mode moves: {error}"
10993 );
10994 assert_eq!(host.controls.registrations().len(), 1);
10995 }
10996
10997 #[test]
10998 fn install_rolls_back_the_registration_when_record_persistence_fails() {
10999 let host = Host::new();
11000 let record_path = InstallRecord::path(&host.paths);
11001 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11002
11003 let error = host
11004 .operations()
11005 .install(&host.request(StartMode::Boot))
11006 .expect_err("record persistence must fail");
11007
11008 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11009 assert!(
11010 host.controls.registrations().is_empty(),
11011 "a failed install must not leave a live unrecorded registration"
11012 );
11013 assert!(
11014 host.controls
11015 .calls()
11016 .iter()
11017 .any(|call| call == "uninstall runner-manager (boot)"),
11018 "the registration must be explicitly rolled back: {:?}",
11019 host.controls.calls()
11020 );
11021 assert!(
11022 !host.runner_root.as_path().exists(),
11023 "the rollback must take the runner root this install created with it; a directory \
11024 prepared for a registration that does not exist is litter, and on Windows it is \
11025 litter with a security descriptor"
11026 );
11027 }
11028
11029 #[test]
11034 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11035 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11036
11037 assert_eq!(
11041 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11042 ServiceAccount::LocalSystem
11043 );
11044 assert_eq!(
11045 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11046 ServiceAccount::InvokingUser
11047 );
11048
11049 let boot = default_root_sddl(&RootAdmission::LocalSystem);
11050 assert!(!grants_broad_write(&boot), "{boot}");
11051 assert!(
11052 !boot.contains("S-1-5-21"),
11053 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11054 );
11055
11056 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11061 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11062 assert!(!grants_broad_write(&login), "{login}");
11063 }
11064
11065 #[test]
11066 fn an_install_reports_the_runner_root_it_prepared() {
11067 let host = Host::new();
11068 let installed = host
11069 .operations()
11070 .install(&host.request(StartMode::Boot))
11071 .expect("an install");
11072 let rendered = installed.runner_root.to_string();
11073 assert!(
11074 !rendered.contains("S-1-5-21"),
11075 "the report must add no identity to the output: {rendered}"
11076 );
11077 if cfg!(windows) {
11078 assert_eq!(
11079 installed.runner_root.path(),
11080 Some(host.runner_root.as_path())
11081 );
11082 assert!(
11083 host.runner_root.as_path().is_dir(),
11084 "the directory jobs would run in has to exist once the service is registered"
11085 );
11086 } else {
11087 assert_eq!(
11088 installed.runner_root,
11089 crate::runner_root_access::RootAccessSummary::NotApplicable,
11090 "macOS and Linux keep the runtime directory they have always used"
11091 );
11092 }
11093 }
11094
11095 #[test]
11096 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11097 let host = Host::new();
11098 let operations = host.operations();
11099 operations
11100 .install(&host.request(StartMode::Boot))
11101 .expect("an install at boot");
11102
11103 let change = operations
11104 .set_start_mode(StartMode::Login)
11105 .expect("a switch to login");
11106
11107 assert!(change.changed);
11108 if cfg!(windows) {
11109 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11110 assert!(
11111 host.runner_root.as_path().is_dir(),
11112 "the switch must not remove the directory it reconciled"
11113 );
11114 }
11115 assert!(
11118 change.to_string().contains("runner root"),
11119 "{}",
11120 change.to_string()
11121 );
11122 }
11123
11124 #[test]
11125 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11126 let host = Host::new();
11127 let operations = host.operations();
11128 operations
11129 .install(&host.request(StartMode::Boot))
11130 .expect("an install at boot");
11131
11132 let change = operations
11133 .set_start_mode(StartMode::Boot)
11134 .expect("a switch to the mode already in force");
11135
11136 assert!(!change.changed);
11137 assert_eq!(
11138 change.runner_root,
11139 crate::runner_root_access::RootAccessSummary::NotApplicable,
11140 "nothing moves, so nothing about the root's access control has to; reconciling here \
11141 would turn a no-op command into one that can fail on a permission it does not need"
11142 );
11143 }
11144
11145 #[cfg(windows)]
11146 #[test]
11147 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11148 let host = Host::new();
11149 host.controls
11150 .fail_next_install(StartMode::Boot, "injected registration failure");
11151
11152 let error = host
11153 .operations()
11154 .install(&host.request(StartMode::Boot))
11155 .expect_err("the manager refuses the registration");
11156
11157 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11158 assert!(
11159 !host.runner_root.as_path().exists(),
11160 "the directory was created for a registration that does not exist"
11161 );
11162 }
11163
11164 #[cfg(windows)]
11165 #[test]
11166 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11167 let host = Host::new();
11168 crate::runner_root_access::create_with_descriptor_for_tests(
11172 host.runner_root.as_path(),
11173 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11174 )
11175 .expect("a deliberately open runner root");
11176 let before = crate::runner_root_access::report(host.runner_root.as_path());
11177
11178 let error = host
11179 .operations()
11180 .install(&host.request(StartMode::Boot))
11181 .expect_err("an open runner root is refused");
11182
11183 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11184 assert!(
11185 error.to_string().contains("nothing was registered"),
11186 "{error}"
11187 );
11188 assert!(
11189 host.controls.registrations().is_empty(),
11190 "the refusal has to come before the platform is asked to register anything: {:?}",
11191 host.controls.calls()
11192 );
11193 assert_eq!(
11194 crate::runner_root_access::report(host.runner_root.as_path()),
11195 before,
11196 "an open directory is refused rather than tightened: its contents cannot be trusted, \
11197 so adopting it would be worse than declining it"
11198 );
11199 }
11200
11201 #[cfg(windows)]
11202 #[test]
11203 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11204 let host = Host::new();
11205 let operations = host.operations();
11206 operations
11207 .install(&host.request(StartMode::Boot))
11208 .expect("an install");
11209 assert!(host.runner_root.as_path().is_dir());
11210
11211 operations.uninstall().expect("an uninstall");
11212
11213 assert!(
11214 host.runner_root.as_path().is_dir(),
11215 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11216 runner root may hold an operator's retained workspaces."
11217 );
11218 }
11219
11220 #[test]
11221 fn install_reviews_what_it_registered() {
11222 let host = Host::new();
11223 let installed = host
11224 .operations()
11225 .install(&host.request(StartMode::Boot))
11226 .expect("an install");
11227 assert!(
11228 installed.review.is_least_privilege(),
11229 "{}",
11230 installed.review
11231 );
11232 assert!(
11233 !installed.review.controls().is_empty(),
11234 "a review that confirms nothing proves nothing: {}",
11235 installed.review
11236 );
11237 assert_eq!(
11238 installed.review.kind(),
11239 host_definition_kind(StartMode::Boot),
11240 "the review must be of the definition this host's manager was given"
11241 );
11242 assert!(
11243 !installed.review.account().justification().is_empty(),
11244 "a privileged account with no stated reason is an unreviewed one"
11245 );
11246 }
11247
11248 #[test]
11253 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11254 let host = Host::new();
11255 let operations = host.operations();
11256 operations
11257 .install(&host.request(StartMode::Boot))
11258 .expect("an install");
11259
11260 let config = host.paths.config_dir();
11264 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11265 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11266 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11267 std::fs::write(
11268 host.paths
11269 .state_dir()
11270 .join("packages/2.330.0/runner.tar.gz"),
11271 b"cached package",
11272 )
11273 .expect("writable");
11274 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11275 std::fs::write(
11276 host.paths.state_dir().join("secrets/user-access-token"),
11277 b"a stand-in for the stored credential",
11278 )
11279 .expect("writable");
11280 std::fs::write(
11281 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11282 b"diagnostics",
11283 )
11284 .expect("writable");
11285
11286 let roots: Vec<PathBuf> = host
11287 .paths
11288 .all()
11289 .iter()
11290 .map(|(_, path)| (*path).to_path_buf())
11291 .collect();
11292 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11293 let before = snapshot(&roots);
11294
11295 assert!(
11298 before.len() >= 6,
11299 "the fixture must actually contain the files this test is about, got {before:#?}"
11300 );
11301 let record_path = InstallRecord::path(&host.paths);
11302 assert!(
11303 before.contains_key(&record_path),
11304 "the install record must be present before uninstall"
11305 );
11306
11307 let uninstalled = operations.uninstall().expect("an uninstall");
11308 assert!(uninstalled.removed_registration);
11309 assert!(uninstalled.removed_record);
11310
11311 let after = snapshot(&roots);
11312
11313 let mut expected = before.clone();
11315 expected.remove(&record_path);
11316 assert_eq!(
11317 after, expected,
11318 "uninstall must remove its own record and nothing else"
11319 );
11320 assert!(
11321 !record_path.exists(),
11322 "the record itself must go, or `uninstall` did nothing at all"
11323 );
11324 assert!(
11325 uninstalled
11326 .preserved
11327 .iter()
11328 .all(|path| roots.contains(&path.as_path())),
11329 "the preserved list must name the four directories: {uninstalled}"
11330 );
11331 }
11332
11333 #[test]
11334 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11335 let host = Host::new();
11336 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11337 assert!(!uninstalled.removed_registration);
11338 assert!(!uninstalled.removed_record);
11339 }
11340
11341 #[test]
11342 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11343 let host = Host::new();
11344 let operations = host.operations();
11345 operations
11346 .install(&host.request(StartMode::Boot))
11347 .expect("an install");
11348 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11349
11350 let uninstalled = operations.uninstall().expect("an uninstall");
11351 assert!(
11352 uninstalled.removed_registration,
11353 "a lost record must not strand a registration"
11354 );
11355 assert!(host.controls.registrations().is_empty());
11356 }
11357
11358 #[test]
11363 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11364 let host = Host::new();
11365 let operations = host.operations();
11366 operations
11367 .install(&host.request(StartMode::Boot))
11368 .expect("an install at boot");
11369
11370 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11374
11375 let change = operations
11376 .set_start_mode(StartMode::Login)
11377 .expect("a switch that does not reinstall the product");
11378 assert!(change.changed);
11379 assert_eq!(change.from, StartMode::Boot);
11380 assert_eq!(change.to, StartMode::Login);
11381 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11382
11383 let record = InstallRecord::read(&host.paths)
11384 .expect("readable")
11385 .expect("a record");
11386 assert_eq!(record.start_mode, StartMode::Login);
11387 assert_eq!(
11388 record.binary, host.binary,
11389 "the recorded path must survive the switch untouched"
11390 );
11391
11392 let registrations = host.controls.registrations();
11393 assert_eq!(registrations.len(), 1, "{registrations:?}");
11394 assert_eq!(registrations[0].0, StartMode::Login);
11395 assert!(
11396 registrations[0]
11397 .2
11398 .command_line
11399 .contains(&host.binary.to_string_lossy().into_owned()),
11400 "{:?}",
11401 registrations[0].2
11402 );
11403 }
11404
11405 #[test]
11406 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11407 let host = Host::new();
11408 let operations = host.operations();
11409 operations
11410 .install(&host.request(StartMode::Boot))
11411 .expect("an install at boot");
11412 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11413 host.controls
11414 .fail_next_install(StartMode::Login, "injected target failure");
11415
11416 let error = operations
11417 .set_start_mode(StartMode::Login)
11418 .expect_err("the target manager refuses the install");
11419
11420 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11421 assert_eq!(
11422 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11423 record_before
11424 );
11425 let registrations = host.controls.registrations();
11426 assert_eq!(registrations.len(), 1, "{registrations:?}");
11427 assert_eq!(registrations[0].0, StartMode::Boot);
11428 }
11429
11430 #[test]
11431 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11432 let host = Host::new();
11433 let operations = host.operations();
11434 operations
11435 .install(&host.request(StartMode::Boot))
11436 .expect("an install at boot");
11437 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11438 let config = host.paths.config_dir().to_path_buf();
11439 let hidden = config.with_file_name("config-hidden-by-fault");
11440 host.controls.hide_directory_after_install(
11441 StartMode::Login,
11442 config.clone(),
11443 hidden.clone(),
11444 );
11445
11446 let error = operations
11447 .set_start_mode(StartMode::Login)
11448 .expect_err("the injected filesystem fault prevents persistence");
11449
11450 std::fs::remove_file(&config).expect("remove the injected blocker");
11451 std::fs::rename(&hidden, &config).expect("restore the record directory");
11452 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11453 assert_eq!(
11454 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11455 record_before
11456 );
11457 let registrations = host.controls.registrations();
11458 assert_eq!(registrations.len(), 1, "{registrations:?}");
11459 assert_eq!(registrations[0].0, StartMode::Boot);
11460 assert!(
11461 host.controls
11462 .calls()
11463 .iter()
11464 .any(|call| call == "uninstall runner-manager (login)"),
11465 "the target must be rolled back: {:?}",
11466 host.controls.calls()
11467 );
11468 }
11469
11470 #[test]
11471 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11472 let host = Host::new();
11473 let operations = host.operations();
11474 operations
11475 .install(&host.request(StartMode::Boot))
11476 .expect("an install");
11477 let before = host.controls.calls().len();
11478
11479 let change = operations
11480 .set_start_mode(StartMode::Boot)
11481 .expect("a no-op switch");
11482 assert!(!change.changed);
11483 assert_eq!(
11484 host.controls.calls().len(),
11485 before,
11486 "a no-op switch must not touch the service manager"
11487 );
11488 }
11489
11490 #[test]
11491 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11492 let host = Host::new();
11493 let error = host
11494 .operations()
11495 .set_start_mode(StartMode::Login)
11496 .expect_err("there is nothing to switch");
11497 assert!(
11498 matches!(error, ServiceError::NotInstalled { .. }),
11499 "{error}"
11500 );
11501 }
11502
11503 #[test]
11508 fn status_reports_the_four_facts_journey_five_asks_for() {
11509 let host = Host::new();
11510 let operations = host.operations();
11511 operations
11512 .install(&host.request(StartMode::Boot))
11513 .expect("an install");
11514 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
11515 .expect("a valid timestamp")
11516 .with_timezone(&Utc);
11517 record_github_contact(&host.paths, at).expect("a heartbeat");
11518
11519 let status = operations.status().expect("a status");
11520 assert_eq!(status.start_mode(), Some(StartMode::Boot));
11521 assert_eq!(
11522 status.binary().map(BinaryPath::recorded),
11523 Some(host.binary.as_path())
11524 );
11525 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
11526 assert_eq!(status.last_github_contact(), Some(at));
11527 assert!(status.is_installed());
11528 assert!(status.is_healthy(), "{status}");
11529
11530 let printed = status.to_string();
11531 for fragment in [
11532 "start mode",
11533 "diagnostic log",
11534 "last GitHub contact",
11535 "binary",
11536 ] {
11537 assert!(printed.contains(fragment), "{printed}");
11538 }
11539 }
11540
11541 #[cfg(unix)]
11553 #[test]
11554 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
11555 use std::os::unix::fs::PermissionsExt as _;
11556
11557 if unsafe { libc::geteuid() } == 0 {
11561 return;
11562 }
11563
11564 let host = Host::new();
11565 let operations = host.operations();
11566 operations
11567 .install(&host.request(StartMode::Boot))
11568 .expect("an install");
11569 assert!(
11570 operations.status().expect("a status").is_healthy(),
11571 "the discriminator: healthy before the record is made unreadable"
11572 );
11573
11574 let record = InstallRecord::path(&host.paths);
11575 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
11576 .expect("the mode is applied");
11577
11578 let status = operations
11579 .status()
11580 .expect("a record this account may not read is reported, not thrown");
11581 assert!(status.is_installed(), "{status}");
11582 assert!(!status.is_healthy(), "{status}");
11583
11584 let printed = status.to_string();
11585 assert!(
11586 printed.contains("this account may not read it"),
11587 "the operator is told which of the two states this is: {printed}"
11588 );
11589 assert!(
11590 !printed.contains("there is no install record"),
11591 "a record that is there and unreadable is not a record that is missing, and the \
11592 missing one's remedy starts with `service uninstall`: {printed}"
11593 );
11594
11595 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
11598 .expect("the mode is restored");
11599 }
11600
11601 #[test]
11602 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
11603 let host = Host::new();
11604 let operations = host.operations();
11605 operations
11606 .install(&host.request(StartMode::Boot))
11607 .expect("an install");
11608
11609 assert!(
11612 operations.status().expect("a status").is_healthy(),
11613 "the freshly installed host must be healthy"
11614 );
11615
11616 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
11617
11618 let status = operations.status().expect("a status");
11619 assert!(!status.is_healthy(), "{status}");
11620 assert!(
11621 status
11622 .problems()
11623 .iter()
11624 .any(|problem| problem.subject == "binary"),
11625 "{status}"
11626 );
11627 assert!(status.to_string().contains("STALE"), "{status}");
11628 }
11629
11630 #[test]
11631 fn status_reports_a_registration_that_would_not_start_at_boot() {
11632 let host = Host::new();
11633 let operations = host.operations();
11634 operations
11635 .install(&host.request(StartMode::Boot))
11636 .expect("an install");
11637 assert!(operations.status().expect("a status").is_healthy());
11638
11639 host.controls.edit("runner-manager", |registration| {
11640 registration.starts_automatically = false;
11641 });
11642
11643 let status = operations.status().expect("a status");
11644 assert!(!status.is_healthy(), "{status}");
11645 assert!(
11646 status
11647 .problems()
11648 .iter()
11649 .any(|problem| problem.detail.contains("after a reboot")),
11650 "{status}"
11651 );
11652 }
11653
11654 #[test]
11655 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
11656 let host = Host::new();
11657 let operations = host.operations();
11658 operations
11659 .install(&host.request(StartMode::Login))
11660 .expect("install and immediate start");
11661
11662 assert!(
11663 operations.status().expect("running status").is_running(),
11664 "install must not wait for the next login trigger"
11665 );
11666 operations.stop().expect("stop the registration");
11667 let status = operations.status().expect("stopped status");
11668 assert!(!status.is_healthy(), "{status}");
11669 assert!(
11670 status
11671 .problems()
11672 .iter()
11673 .any(|problem| problem.subject == "runtime"),
11674 "{status}"
11675 );
11676 let rendered = status.to_string();
11677 assert!(
11678 rendered.contains("runner-manager service install --start-at login"),
11679 "{rendered}"
11680 );
11681 assert!(
11682 !rendered.contains("runner-manager service start"),
11683 "{rendered}"
11684 );
11685 }
11686
11687 #[test]
11688 fn status_reports_a_restart_policy_something_else_edited() {
11689 let host = Host::new();
11690 let operations = host.operations();
11691 operations
11692 .install(&host.request(StartMode::Boot))
11693 .expect("an install");
11694 assert!(operations.status().expect("a status").is_healthy());
11695
11696 host.controls.edit("runner-manager", |registration| {
11697 registration.restart_delay = Some(Duration::from_secs(1));
11698 });
11699
11700 let status = operations.status().expect("a status");
11701 assert!(!status.is_healthy(), "{status}");
11702 assert!(
11703 status
11704 .problems()
11705 .iter()
11706 .any(|problem| problem.subject == "restart policy"),
11707 "{status}"
11708 );
11709 }
11710
11711 #[test]
11712 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
11713 let host = Host::new();
11714 let operations = host.operations();
11715 operations
11716 .install(&host.request(StartMode::Boot))
11717 .expect("an install");
11718 let other = host.binary.with_file_name("someone-elses.exe");
11719 std::fs::write(&other, b"x").expect("writable");
11720
11721 host.controls.edit("runner-manager", |registration| {
11722 registration.command_line = quote_argument(&other.to_string_lossy());
11723 });
11724
11725 let status = operations.status().expect("a status");
11726 assert!(!status.is_healthy(), "{status}");
11727 assert!(
11728 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11729 "{status}"
11730 );
11731 }
11732
11733 #[test]
11734 fn status_reports_a_record_no_service_manager_knows_about() {
11735 let host = Host::new();
11736 let operations = host.operations();
11737 operations
11738 .install(&host.request(StartMode::Boot))
11739 .expect("an install");
11740 for mode in [StartMode::Boot, StartMode::Login] {
11742 host.controls
11743 .control(mode)
11744 .expect("a control")
11745 .uninstall(&ServiceIdentity::product())
11746 .expect("removed");
11747 }
11748
11749 let status = operations.status().expect("a status");
11750 assert!(!status.is_healthy(), "{status}");
11751 assert!(
11752 status
11753 .problems()
11754 .iter()
11755 .any(|problem| problem.subject == "registration"),
11756 "{status}"
11757 );
11758 }
11759
11760 #[test]
11761 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
11762 let host = Host::new();
11763 let status = host.operations().status().expect("a status");
11764 assert!(!status.is_installed());
11765 assert!(
11766 status.is_healthy(),
11767 "a host that never installed the service has no fault to report: {status}"
11768 );
11769 assert!(status.to_string().contains("installed"), "{status}");
11770 }
11771
11772 #[test]
11773 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
11774 let host = Host::new();
11775 let operations = host.operations();
11776 operations
11777 .install(&host.request(StartMode::Login))
11778 .expect("an install at login");
11779 let status = operations.status().expect("a status");
11780 assert!(
11781 status
11782 .notes()
11783 .iter()
11784 .any(|note| note.contains("does not run until the operator signs in")),
11785 "05-infrastructure.md requires `service status` to say so: {status}"
11786 );
11787 }
11788
11789 #[test]
11792 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
11793 use crate::unattended_login::{AutoLogin, UnattendedLogin};
11794 let automatic = UnattendedLogin {
11795 auto_login: AutoLogin::As("ivan".into()),
11796 filevault_on: Some(false),
11797 };
11798 assert_eq!(
11799 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
11800 None
11801 );
11802
11803 let off = UnattendedLogin {
11804 auto_login: AutoLogin::Off,
11805 filevault_on: Some(false),
11806 };
11807 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
11808 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
11809 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
11810
11811 let locked = UnattendedLogin {
11812 auto_login: AutoLogin::As("ivan".into()),
11813 filevault_on: Some(true),
11814 };
11815 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
11816 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
11817
11818 let unread = UnattendedLogin {
11821 auto_login: AutoLogin::As("ivan".into()),
11822 filevault_on: None,
11823 };
11824 assert_eq!(
11825 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
11826 None
11827 );
11828
11829 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
11831 assert!(
11832 gap.reason
11833 .contains("does not run until the operator signs in")
11834 );
11835 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
11836 }
11837
11838 #[test]
11841 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
11842 let host = Host::new();
11843 let operations = host.operations();
11844 operations
11845 .install(&host.request(StartMode::Login))
11846 .expect("an install at login");
11847 let stalled_problem = |operations: &ServiceOperations| {
11848 operations
11849 .status()
11850 .expect("a status")
11851 .problems()
11852 .iter()
11853 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
11854 .map(|problem| problem.detail.clone())
11855 };
11856 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
11857 assert_eq!(stalled_problem(&operations), None, "beating");
11858
11859 crate::daemon_heartbeat::beat(
11860 &operations.paths,
11861 Utc::now() - chrono::Duration::minutes(20),
11862 )
11863 .unwrap();
11864 let detail = stalled_problem(&operations).expect("a stall is a problem");
11865 assert!(detail.contains("has made no progress since"), "{detail}");
11866 let restart = ServiceIdentity::product().restart_command(StartMode::Login);
11869 assert!(detail.contains(&restart), "{detail}");
11870 assert!(!detail.contains("runner-manager service stop"), "{detail}");
11871 assert!(!detail.contains("runner-manager service start"), "{detail}");
11872 }
11873
11874 #[test]
11878 fn the_restart_command_is_the_service_managers_own() {
11879 let identity = ServiceIdentity::product();
11880 let boot = identity.restart_command(StartMode::Boot);
11881 let login = identity.restart_command(StartMode::Login);
11882 for command in [&boot, &login] {
11883 assert!(!command.contains("runner-manager service"), "{command}");
11884 assert!(command.contains(identity.name()), "{command}");
11885 }
11886 assert_ne!(boot, login);
11887 if cfg!(windows) {
11888 assert!(
11889 boot.starts_with("Restart-Service -Name runner-manager"),
11890 "{boot}"
11891 );
11892 assert!(
11893 login.contains("schtasks /Run /TN runner-manager"),
11894 "{login}"
11895 );
11896 } else if cfg!(target_os = "macos") {
11897 assert!(
11898 boot.starts_with("sudo launchctl kickstart -k system/"),
11899 "{boot}"
11900 );
11901 assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
11902 } else {
11903 assert_eq!(boot, "sudo systemctl restart runner-manager.service");
11904 assert_eq!(login, "systemctl --user restart runner-manager.service");
11905 }
11906 }
11907
11908 #[test]
11910 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
11911 let host = Host::new();
11912 let operations = host.operations();
11913 operations
11914 .install(&host.request(StartMode::Login))
11915 .expect("an install at login");
11916 let paths = operations.paths.clone();
11917 crate::host_fitness::record_host_unfit(
11918 &paths,
11919 &[crate::host_fitness::UnfitFinding {
11920 id: "host.runner_root_responsive".into(),
11921 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
11922 remedy: Some("click Allow".into()),
11923 }],
11924 Utc::now(),
11925 )
11926 .unwrap();
11927 let status = operations.status().expect("a status");
11928 assert!(!status.is_healthy(), "{status}");
11929 let problem = status
11930 .problems()
11931 .iter()
11932 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
11933 .expect("the refusal is a problem");
11934 assert!(
11935 problem.detail.contains("/Volumes/NVME/runners"),
11936 "{problem:?}"
11937 );
11938 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
11939
11940 crate::host_fitness::clear_host_unfit(&paths).unwrap();
11943 crate::host_fitness::record_host_unfit(
11944 &paths,
11945 &[],
11946 Utc::now() - chrono::Duration::hours(2),
11947 )
11948 .unwrap();
11949 let status = operations.status().expect("a status");
11950 assert!(
11951 !status
11952 .problems()
11953 .iter()
11954 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
11955 "{status}"
11956 );
11957 }
11958
11959 #[test]
11964 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
11965 let host = Host::new();
11966 let operations = host.operations();
11967 operations
11968 .install(&host.request(StartMode::Login))
11969 .expect("an install at login");
11970 operations.start().expect("a start");
11971 assert!(operations.status().expect("a status").is_running());
11972 assert!(operations.stop().expect("a stop"));
11973 assert!(!operations.status().expect("a status").is_running());
11974 }
11975
11976 #[test]
11977 fn starting_a_host_with_no_registration_is_refused() {
11978 let host = Host::new();
11979 let error = host.operations().start().expect_err("nothing to start");
11980 assert!(
11981 matches!(error, ServiceError::NotInstalled { .. }),
11982 "{error}"
11983 );
11984 }
11985
11986 #[cfg(windows)]
12002 #[test]
12003 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12004 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12005
12006 let root = tempfile::tempdir().expect("a temporary directory");
12007 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12008 .expect("a rooted machine-scoped store");
12009 store
12010 .store(&secrecy::SecretString::from("a stand-in for the token"))
12011 .expect("the store accepts a value");
12012 let protection = store.protection().expect("the DACL can be read back");
12013
12014 assert!(
12015 protection.description().contains(";;;SY)"),
12016 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12017 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12018 protection.description()
12019 );
12020 assert_eq!(
12021 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12022 ServiceAccount::LocalSystem,
12023 "and that is the account this installer registers, which is why SY is what matters"
12024 );
12025 assert!(
12026 !protection.readable_by_other_local_users(),
12027 "the same DACL must still exclude ordinary local users: {}",
12028 protection.description()
12029 );
12030
12031 for rejected in [";;;LS)", ";;;NS)"] {
12035 assert!(
12036 !protection.description().contains(rejected),
12037 "if the store ever admitted {rejected}, the least-privilege analysis in \
12038 docs/service-account.md would need redoing: {}",
12039 protection.description()
12040 );
12041 }
12042 }
12043
12044 fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12049 let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12050 arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12051 host.request(mode).with_arguments(arguments)
12052 }
12053
12054 fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12057 ServiceOperations::with_controls(
12058 account.paths.clone(),
12059 ServiceIdentity::product(),
12060 std::sync::Arc::new(machine.controls.clone()),
12061 )
12062 .with_runner_root(account.runner_root.clone())
12063 }
12064
12065 fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12069 let owner = Host::new();
12070 owner
12071 .operations()
12072 .install(&request_with_directories(&owner, StartMode::Boot))
12073 .expect("the owner's boot install");
12074 let earlier = Host::new();
12075 earlier
12076 .operations()
12077 .install(&request_with_directories(&earlier, StartMode::Login))
12078 .expect("the second account's earlier login install");
12079 assert!(
12080 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12081 "the earlier install left its record behind"
12082 );
12083 (owner, earlier)
12084 }
12085
12086 const DESTRUCTIVE_ADVICE: [&str; 4] = [
12089 "service uninstall",
12090 "service install`",
12091 "service install --",
12092 "auth login",
12093 ];
12094
12095 #[test]
12096 fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12097 let (owner, earlier) = a_machine_with_a_stale_second_account();
12098
12099 let status = second_account(&earlier, &owner).status().expect("a status");
12100
12101 let foreign = status.foreign().expect("the service is the owner's");
12102 assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12103 assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12104 assert!(foreign.unreadable.is_none(), "{status}");
12105 assert!(
12106 status.is_healthy(),
12107 "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12108 {status}"
12109 );
12110 for subject in [
12111 "binary",
12112 "start mode",
12113 "secret store",
12114 "record",
12115 "registration",
12116 ] {
12117 assert!(
12118 !status
12119 .problems()
12120 .iter()
12121 .any(|problem| problem.subject == subject),
12122 "{subject} compared the second account's record against the owner's \
12123 registration: {status}"
12124 );
12125 }
12126 let rendered = status.to_string();
12127 for advice in DESTRUCTIVE_ADVICE {
12128 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12129 }
12130 assert!(rendered.contains("another installation's"), "{rendered}");
12131 assert!(
12132 status
12133 .notes()
12134 .iter()
12135 .any(|note| note.contains("unused local configuration")
12136 && note.contains("host forget-local")),
12137 "{status}"
12138 );
12139 assert_eq!(status.start_mode(), Some(StartMode::Boot));
12141 }
12142
12143 #[test]
12144 fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12145 let (owner, earlier) = a_machine_with_a_stale_second_account();
12146 InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12149
12150 let operations = second_account(&earlier, &owner);
12151 let status = operations.status().expect("a status");
12152 let foreign = status.foreign().expect("the service is the owner's");
12153 assert!(foreign.unreadable.is_some(), "{status}");
12154 assert!(
12155 status.is_healthy(),
12156 "running, and nothing it could judge: {status}"
12157 );
12158 let rendered = status.to_string();
12159 for advice in DESTRUCTIVE_ADVICE {
12160 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12161 }
12162 assert!(rendered.contains("running; installed from"), "{rendered}");
12163 assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12164
12165 owner.operations().stop().expect("stopped");
12167 let stopped = operations.status().expect("a status");
12168 assert!(!stopped.is_healthy(), "{stopped}");
12169 let rendered = stopped.to_string();
12170 for advice in DESTRUCTIVE_ADVICE {
12171 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12172 }
12173 }
12174
12175 #[test]
12176 fn this_accounts_own_registration_still_reports_every_mismatch() {
12177 let host = Host::new();
12178 let operations = host.operations();
12179 operations
12180 .install(&request_with_directories(&host, StartMode::Boot))
12181 .expect("an install");
12182 let other = host.binary.with_file_name("someone-elses");
12183 std::fs::write(&other, b"x").expect("writable");
12184 host.controls.edit("runner-manager", |registration| {
12186 let mut arguments = split_command_line(®istration.command_line);
12187 arguments[0] = other.to_string_lossy().into_owned();
12188 registration.command_line = arguments
12189 .iter()
12190 .map(|argument| quote_argument(argument))
12191 .collect::<Vec<_>>()
12192 .join(" ");
12193 registration.start_mode = StartMode::Login;
12194 });
12195
12196 let status = operations.status().expect("a status");
12197 assert!(status.foreign().is_none(), "{status}");
12198 assert!(
12199 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12200 "{status}"
12201 );
12202 assert!(
12203 status
12204 .problems()
12205 .iter()
12206 .any(|problem| problem.subject == "start mode"),
12207 "{status}"
12208 );
12209 assert!(status.to_string().contains("service uninstall"), "{status}");
12210 }
12211
12212 #[test]
12213 fn directories_read_back_from_a_command_line_with_spaces() {
12214 let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12215 let directories = ServiceDirectories {
12216 config: root.join("config"),
12217 state: root.join("data").join("state"),
12218 runtime: root.join("data").join("runtime"),
12219 logs: root.join("data").join("logs"),
12220 };
12221 let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12222 for argument in ["daemon", "run"]
12223 .map(OsString::from)
12224 .into_iter()
12225 .chain(directories.arguments())
12226 {
12227 command_line.push(' ');
12228 command_line.push_str("e_argument(&argument.to_string_lossy()));
12229 }
12230 assert_eq!(
12231 ServiceDirectories::from_command_line(&command_line),
12232 Some(directories.clone())
12233 );
12234 assert_eq!(directories.root(), root);
12235 assert_eq!(
12236 ServiceDirectories::from_command_line("runner-manager daemon run"),
12237 None,
12238 "a registration that names no directories cannot be judged foreign"
12239 );
12240 }
12241
12242 #[test]
12243 fn the_owner_of_a_data_root_is_read_from_its_path() {
12244 let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12245 let home = users.join("ivand");
12246 assert_eq!(
12247 data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12248 Some("yuriv")
12249 );
12250 assert_eq!(
12251 data_root_owner(
12252 Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12253 Some(&home)
12254 ),
12255 None
12256 );
12257 if cfg!(windows) {
12258 assert_eq!(
12259 data_root_owner(
12260 Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12261 Some(&home)
12262 )
12263 .as_deref(),
12264 Some("LocalSystem")
12265 );
12266 } else {
12267 assert_eq!(
12268 data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12269 Some("root")
12270 );
12271 }
12272 }
12273
12274 #[test]
12275 fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12276 let (owner, earlier) = a_machine_with_a_stale_second_account();
12277 let owner_before = snapshot(&[owner._root.path()]);
12278 let config = earlier.paths.config_dir().to_path_buf();
12279 let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12280
12281 let forgotten = second_account(&earlier, &owner)
12282 .forget_local(Utc::now())
12283 .expect("archived");
12284
12285 assert_eq!(
12286 snapshot(&[owner._root.path()]),
12287 owner_before,
12288 "the owner's directories must not change by one byte"
12289 );
12290 let archive = forgotten.archive.expect("something was archived");
12291 assert!(archive.starts_with(&config), "{}", archive.display());
12292 assert_eq!(
12293 std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12294 record,
12295 "moved, not rewritten or deleted"
12296 );
12297 assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12298 assert_eq!(
12299 forgotten.service_root,
12300 Some(ServiceDirectories::of(&owner.paths).root())
12301 );
12302 let status = second_account(&earlier, &owner).status().expect("status");
12304 assert!(
12305 !status
12306 .notes()
12307 .iter()
12308 .any(|note| note.contains("unused local configuration")),
12309 "{status}"
12310 );
12311 }
12312
12313 #[test]
12314 fn forget_local_refuses_a_configuration_a_service_runs_against() {
12315 let (owner, earlier) = a_machine_with_a_stale_second_account();
12316 let before = snapshot(&[owner._root.path()]);
12317 let refused = owner.operations().forget_local(Utc::now());
12318 assert!(
12319 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12320 "{refused:?}"
12321 );
12322 assert_eq!(snapshot(&[owner._root.path()]), before);
12323
12324 let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12326 .expect("the agent's lock");
12327 let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12328 assert!(
12329 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12330 "{refused:?}"
12331 );
12332 assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12333 }
12334
12335 #[test]
12336 fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12337 let (owner, earlier) = a_machine_with_a_stale_second_account();
12338 owner.operations().stop().expect("stopped");
12341 record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12342 record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12343
12344 let status = second_account(&earlier, &owner).status().expect("a status");
12345 assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12346 let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12347 for subject in [
12348 "runtime",
12349 "github credential",
12350 CREDENTIAL_UNREADABLE_SUBJECT,
12351 ] {
12352 assert!(subjects.contains(&subject), "{subject}: {status}");
12353 }
12354 let rendered = status.to_string();
12355 for advice in DESTRUCTIVE_ADVICE {
12356 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12357 }
12358 for problem in status.problems() {
12359 assert!(problem.detail.contains("Act on it"), "{problem}");
12360 }
12361 }
12362
12363 #[test]
12364 fn no_command_of_this_account_changes_another_installations_service() {
12365 let (owner, earlier) = a_machine_with_a_stale_second_account();
12366 let operations = second_account(&earlier, &owner);
12367 let foreign = |result: Result<(), ServiceError>| {
12368 assert!(
12369 matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12370 "{result:?}"
12371 );
12372 };
12373 foreign(
12374 operations
12375 .install(&request_with_directories(&earlier, StartMode::Boot))
12376 .map(drop),
12377 );
12378 foreign(operations.uninstall().map(drop));
12379 foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12380 foreign(operations.start());
12381 foreign(operations.stop().map(drop));
12382
12383 let registration = owner
12384 .controls
12385 .control(StartMode::Boot)
12386 .unwrap()
12387 .query(&ServiceIdentity::product())
12388 .unwrap()
12389 .expect("the owner's registration is still there");
12390 assert_eq!(
12391 ServiceDirectories::from_command_line(®istration.command_line),
12392 Some(ServiceDirectories::of(&owner.paths)),
12393 "and still runs against the owner's directories"
12394 );
12395 assert!(registration.running, "and was never stopped");
12396 assert!(
12397 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12398 "the refused uninstall kept this account's record"
12399 );
12400 }
12401
12402 #[test]
12403 fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12404 let root = tempfile::tempdir().unwrap();
12407 let config = root.path().join("Application Support");
12408 let paths = AppPaths::from_directories(
12409 &config,
12410 config.join("state"),
12411 config.join("runtime"),
12412 config.join("logs"),
12413 );
12414 paths.create_all().unwrap();
12415 std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12416 std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12417 let operations = ServiceOperations::with_controls(
12418 paths.clone(),
12419 ServiceIdentity::product(),
12420 std::sync::Arc::new(RecordingControls::new()),
12421 );
12422
12423 let forgotten = operations.forget_local(Utc::now()).expect("archived");
12424
12425 assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12426 for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12427 assert!(kept.is_dir(), "{} was moved", kept.display());
12428 }
12429 assert!(paths.logs_dir().join("runner-manager.log").is_file());
12430 }
12431
12432 #[test]
12433 fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12434 for (detail, diagnosis) in [
12435 (
12436 "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12437 service install --start-at boot`; if the service manager denies replacement",
12438 "x holds an automatic registration, but the daemon is stopped",
12439 ),
12440 (
12441 "store -- MISMATCH. Run `auth login` again so the token is stored",
12442 "store -- MISMATCH",
12443 ),
12444 (
12445 "unit differs, so the daemon runs under outdated settings. Re-register it with \
12446 `sudo runner-manager service install --start-at boot`",
12447 "unit differs, so the daemon runs under outdated settings",
12448 ),
12449 (
12450 "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12451 "it starts no runner; the log has it",
12452 ),
12453 (
12454 "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12455 install`",
12456 "C:\\a\\runner-manager.exe -- STALE: moved",
12457 ),
12458 (
12459 "a new builder: run `runner-manager service install` here, or not",
12460 "the installation's own records report this",
12461 ),
12462 ] {
12463 assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12464 }
12465 }
12466}