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 protected: Vec<PathBuf>,
984}
985
986impl InstallRequest {
987 #[must_use]
989 pub fn new(start_mode: StartMode) -> Self {
990 Self {
991 start_mode,
992 binary: None,
993 source_binary: None,
994 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
995 restart: RestartPolicy::default(),
996 on_demand: false,
997 protected: Vec::new(),
998 }
999 }
1000
1001 #[must_use]
1004 pub fn protecting(mut self, paths: Vec<PathBuf>) -> Self {
1005 self.protected = paths;
1006 self
1007 }
1008
1009 #[must_use]
1019 pub const fn started_on_demand(mut self) -> Self {
1020 self.on_demand = true;
1021 self
1022 }
1023
1024 #[must_use]
1031 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1032 self.binary = Some(binary.into());
1033 self
1034 }
1035
1036 #[must_use]
1053 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1054 self.source_binary = Some(source.into());
1055 self
1056 }
1057
1058 #[must_use]
1060 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1061 where
1062 I: IntoIterator<Item = S>,
1063 S: Into<OsString>,
1064 {
1065 self.arguments = arguments.into_iter().map(Into::into).collect();
1066 self
1067 }
1068
1069 #[must_use]
1072 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1073 self.restart = restart;
1074 self
1075 }
1076
1077 #[must_use]
1079 pub const fn start_mode(&self) -> StartMode {
1080 self.start_mode
1081 }
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Eq)]
1091pub struct InstallPlan {
1092 identity: ServiceIdentity,
1093 start_mode: StartMode,
1094 binary: PathBuf,
1095 source_binary: Option<PathBuf>,
1096 arguments: Vec<OsString>,
1097 account: ServiceAccount,
1098 restart: RestartPolicy,
1099 directories: ServiceDirectories,
1100 secret_guard: Option<PathBuf>,
1101 on_demand: bool,
1102 protected: Vec<PathBuf>,
1103}
1104
1105impl InstallPlan {
1106 pub fn resolve(
1114 identity: ServiceIdentity,
1115 request: &InstallRequest,
1116 directories: ServiceDirectories,
1117 ) -> Result<Self, ServiceError> {
1118 let binary = match &request.binary {
1119 Some(named) => absolute(named)?,
1120 None => running_executable()?,
1121 };
1122 if !binary.is_file() {
1123 return Err(ServiceError::BinaryMissing { path: binary });
1124 }
1125 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1130 .ok()
1131 .map(|store| store.guard());
1132 Ok(Self {
1133 identity,
1134 start_mode: request.start_mode,
1135 binary,
1136 source_binary: request.source_binary.clone(),
1137 arguments: request.arguments.clone(),
1138 account: ServiceAccount::for_start_mode(request.start_mode),
1139 restart: request.restart,
1140 directories,
1141 secret_guard,
1142 on_demand: request.on_demand,
1143 protected: request.protected.clone(),
1144 })
1145 }
1146
1147 #[must_use]
1154 pub fn unchecked(
1155 identity: ServiceIdentity,
1156 start_mode: StartMode,
1157 binary: impl Into<PathBuf>,
1158 directories: ServiceDirectories,
1159 ) -> Self {
1160 Self {
1161 identity,
1162 start_mode,
1163 binary: binary.into(),
1164 source_binary: None,
1165 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1166 account: ServiceAccount::for_start_mode(start_mode),
1167 restart: RestartPolicy::default(),
1168 directories,
1169 secret_guard: None,
1170 on_demand: false,
1171 protected: Vec::new(),
1172 }
1173 }
1174
1175 #[must_use]
1177 pub fn with_protected_paths(mut self, paths: Vec<PathBuf>) -> Self {
1178 self.protected = paths;
1179 self
1180 }
1181
1182 #[must_use]
1184 pub fn protected_paths(&self) -> &[PathBuf] {
1185 &self.protected
1186 }
1187
1188 #[must_use]
1191 pub const fn started_on_demand(mut self) -> Self {
1192 self.on_demand = true;
1193 self
1194 }
1195
1196 #[must_use]
1198 pub const fn is_on_demand(&self) -> bool {
1199 self.on_demand
1200 }
1201
1202 #[must_use]
1209 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1210 self.secret_guard = Some(guard.into());
1211 self
1212 }
1213
1214 #[must_use]
1216 pub fn secret_guard(&self) -> Option<&Path> {
1217 self.secret_guard.as_deref()
1218 }
1219
1220 #[must_use]
1222 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1223 self.restart = restart;
1224 self
1225 }
1226
1227 #[must_use]
1229 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1230 where
1231 I: IntoIterator<Item = S>,
1232 S: Into<OsString>,
1233 {
1234 self.arguments = arguments.into_iter().map(Into::into).collect();
1235 self
1236 }
1237
1238 #[must_use]
1240 pub const fn identity(&self) -> &ServiceIdentity {
1241 &self.identity
1242 }
1243
1244 #[must_use]
1246 pub const fn start_mode(&self) -> StartMode {
1247 self.start_mode
1248 }
1249
1250 #[must_use]
1252 pub fn binary(&self) -> &Path {
1253 &self.binary
1254 }
1255
1256 #[must_use]
1262 pub fn source_binary(&self) -> Option<&Path> {
1263 self.source_binary.as_deref()
1264 }
1265
1266 #[must_use]
1268 pub fn arguments(&self) -> &[OsString] {
1269 &self.arguments
1270 }
1271
1272 #[must_use]
1274 pub const fn account(&self) -> &ServiceAccount {
1275 &self.account
1276 }
1277
1278 #[must_use]
1280 pub const fn restart(&self) -> RestartPolicy {
1281 self.restart
1282 }
1283
1284 #[must_use]
1286 pub const fn directories(&self) -> &ServiceDirectories {
1287 &self.directories
1288 }
1289
1290 #[must_use]
1292 pub fn command_line(&self) -> String {
1293 let mut out = quote_argument(&self.binary.to_string_lossy());
1294 for argument in &self.arguments {
1295 out.push(' ');
1296 out.push_str("e_argument(&argument.to_string_lossy()));
1297 }
1298 out
1299 }
1300}
1301
1302fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1310 let reversal = change.revert();
1311 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1312}
1313
1314fn rolled_back<T>(
1327 retained: Option<String>,
1328 rollback: Result<T, ServiceError>,
1329 operation: &'static str,
1330 identity: &ServiceIdentity,
1331 cause: ServiceError,
1332) -> ServiceError {
1333 let left_behind = match (rollback.err(), retained) {
1334 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1335 (Some(rollback), None) => Some(rollback.to_string()),
1336 (None, retained) => retained,
1337 };
1338 match left_behind {
1339 Some(rollback) => ServiceError::Rollback {
1340 operation,
1341 name: identity.name().to_string(),
1342 cause: cause.to_string(),
1343 rollback,
1344 },
1345 None => cause,
1346 }
1347}
1348
1349fn undo_runner_root(
1352 change: &RootAccessChange,
1353 operation: &'static str,
1354 identity: &ServiceIdentity,
1355 cause: ServiceError,
1356) -> ServiceError {
1357 rolled_back(
1358 retained_runner_root(change),
1359 Ok(()),
1360 operation,
1361 identity,
1362 cause,
1363 )
1364}
1365
1366fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1380 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1381 detail: format!("{} could not be made absolute: {error}", path.display()),
1382 })
1383}
1384
1385fn running_executable() -> Result<PathBuf, ServiceError> {
1387 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1388 detail: error.to_string(),
1389 })?;
1390 absolute(&raw)
1391}
1392
1393pub(crate) fn quote_argument(argument: &str) -> String {
1402 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1403 return argument.to_string();
1404 }
1405 let mut out = String::with_capacity(argument.len() + 2);
1406 out.push('"');
1407 let mut backslashes = 0usize;
1408 for c in argument.chars() {
1409 match c {
1410 '\\' => {
1411 backslashes += 1;
1412 out.push('\\');
1413 }
1414 '"' => {
1415 for _ in 0..=backslashes {
1418 out.push('\\');
1419 }
1420 out.push('"');
1421 backslashes = 0;
1422 }
1423 other => {
1424 backslashes = 0;
1425 out.push(other);
1426 }
1427 }
1428 }
1429 for _ in 0..backslashes {
1431 out.push('\\');
1432 }
1433 out.push('"');
1434 out
1435}
1436
1437#[must_use]
1448pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1449 split_command_line(command_line)
1450 .into_iter()
1451 .next()
1452 .filter(|executable| !executable.is_empty())
1453 .map(PathBuf::from)
1454}
1455
1456#[must_use]
1462pub fn split_command_line(command_line: &str) -> Vec<String> {
1463 let mut arguments = Vec::new();
1464 let mut current = String::new();
1465 let mut started = false;
1466 let mut quoted = false;
1467 let mut backslashes = 0usize;
1468 for c in command_line.chars() {
1469 match c {
1470 '\\' => {
1471 backslashes += 1;
1472 started = true;
1473 }
1474 '"' => {
1475 current.extend(std::iter::repeat_n('\\', backslashes / 2));
1476 if backslashes.is_multiple_of(2) {
1477 quoted = !quoted;
1478 } else {
1479 current.push('"');
1480 }
1481 backslashes = 0;
1482 started = true;
1483 }
1484 ' ' | '\t' if !quoted => {
1485 current.extend(std::iter::repeat_n('\\', backslashes));
1486 backslashes = 0;
1487 if started {
1488 arguments.push(std::mem::take(&mut current));
1489 started = false;
1490 }
1491 }
1492 other => {
1493 current.extend(std::iter::repeat_n('\\', backslashes));
1494 backslashes = 0;
1495 current.push(other);
1496 started = true;
1497 }
1498 }
1499 }
1500 current.extend(std::iter::repeat_n('\\', backslashes));
1501 if started {
1502 arguments.push(current);
1503 }
1504 arguments
1505}
1506
1507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1513pub enum DefinitionKind {
1514 WindowsService,
1518 WindowsScheduledTask,
1520 LaunchdPlist,
1522 SystemdUnit,
1524}
1525
1526impl DefinitionKind {
1527 #[must_use]
1529 pub const fn manager(self) -> &'static str {
1530 match self {
1531 Self::WindowsService => "the Windows Service Control Manager",
1532 Self::WindowsScheduledTask => "Windows Task Scheduler",
1533 Self::LaunchdPlist => "launchd",
1534 Self::SystemdUnit => "systemd",
1535 }
1536 }
1537}
1538
1539impl fmt::Display for DefinitionKind {
1540 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1541 f.write_str(self.manager())
1542 }
1543}
1544
1545#[derive(Debug, Clone, PartialEq, Eq)]
1552pub struct ServiceDefinition {
1553 kind: DefinitionKind,
1554 text: String,
1555 install_path: Option<PathBuf>,
1556}
1557
1558impl ServiceDefinition {
1559 #[must_use]
1561 pub const fn kind(&self) -> DefinitionKind {
1562 self.kind
1563 }
1564
1565 #[must_use]
1567 pub fn text(&self) -> &str {
1568 &self.text
1569 }
1570
1571 #[must_use]
1578 pub fn install_path(&self) -> Option<&Path> {
1579 self.install_path.as_deref()
1580 }
1581
1582 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1595 Ok(match host_definition_kind(plan.start_mode()) {
1596 DefinitionKind::WindowsService => Self::windows_service(plan),
1597 DefinitionKind::WindowsScheduledTask => {
1598 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1599 }
1600 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1601 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1602 })
1603 }
1604
1605 #[must_use]
1607 pub fn windows_service(plan: &InstallPlan) -> Self {
1608 Self {
1609 kind: DefinitionKind::WindowsService,
1610 text: windows_service_descriptor(plan),
1611 install_path: None,
1612 }
1613 }
1614
1615 #[must_use]
1617 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1618 Self {
1619 kind: DefinitionKind::WindowsScheduledTask,
1620 text: windows_scheduled_task_xml(plan, principal),
1621 install_path: None,
1622 }
1623 }
1624
1625 #[must_use]
1630 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1631 let file = format!("{}.plist", plan.identity().launchd_label());
1632 let install_path = match plan.start_mode() {
1633 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1634 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1635 };
1636 Self {
1637 kind: DefinitionKind::LaunchdPlist,
1638 text: launchd_plist(plan),
1639 install_path,
1640 }
1641 }
1642
1643 #[must_use]
1651 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1652 Self {
1653 kind,
1654 text: text.into(),
1655 install_path: None,
1656 }
1657 }
1658
1659 #[must_use]
1664 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1665 let file = plan.identity().systemd_unit();
1666 let install_path = match plan.start_mode() {
1667 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1668 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1669 };
1670 Self {
1671 kind: DefinitionKind::SystemdUnit,
1672 text: systemd_unit(plan),
1673 install_path,
1674 }
1675 }
1676}
1677
1678pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1680pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1682pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1684pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1686
1687const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1690
1691pub const START_LIMIT_BURST: u32 = 5;
1699
1700#[must_use]
1721pub fn systemd_unit(plan: &InstallPlan) -> String {
1722 let identity = plan.identity();
1723 let restart = plan.restart();
1724 let directories = plan.directories();
1725
1726 let mut out = String::new();
1727 out.push_str("[Unit]\n");
1728 out.push_str(&format!("Description={}\n", identity.display_name()));
1729 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1730 out.push_str("After=network-online.target\n");
1731 out.push_str("Wants=network-online.target\n");
1732 out.push_str(&format!(
1735 "StartLimitIntervalSec={}\n",
1736 restart.reset_after().as_secs()
1737 ));
1738 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1739
1740 out.push_str("\n[Service]\n");
1741 out.push_str("Type=simple\n");
1742 out.push_str("KillMode=process\n");
1746 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1747 out.push_str(&format!(
1748 "WorkingDirectory={}\n",
1749 directories.state.display()
1750 ));
1751 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1752 out.push_str("Restart=on-failure\n");
1753 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1754
1755 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1756 for directive in systemd_hardening(plan.start_mode()) {
1757 out.push_str(directive);
1758 out.push('\n');
1759 }
1760 let quoted = |paths: &[String]| {
1761 paths
1762 .iter()
1763 .map(|path| quote_argument(path))
1764 .collect::<Vec<_>>()
1765 .join(" ")
1766 };
1767 let permitted = permitted_paths(DefinitionKind::SystemdUnit, plan);
1768 out.push_str(&format!("ReadWritePaths={}\n", quoted(&permitted)));
1769 if plan.start_mode() == StartMode::Boot {
1774 let under_home: Vec<String> = permitted
1775 .iter()
1776 .filter(|path| home_root_of(Path::new(path.as_str())).is_some())
1777 .cloned()
1778 .collect();
1779 if !under_home.is_empty() {
1780 out.push_str(&format!("BindPaths={}\n", quoted(&under_home)));
1781 }
1782 }
1783 if !plan.protected_paths().is_empty() {
1784 out.push_str(&format!(
1785 "InaccessiblePaths={}\n",
1786 inaccessible_entries(plan).join(" ")
1787 ));
1788 }
1789
1790 out.push_str("\n[Install]\n");
1791 out.push_str(match plan.start_mode() {
1792 StartMode::Boot => "WantedBy=multi-user.target\n",
1793 StartMode::Login => "WantedBy=default.target\n",
1794 });
1795 out
1796}
1797
1798const SYSTEMD_HARDENING: [&str; 12] = [
1821 "NoNewPrivileges=yes",
1822 "CapabilityBoundingSet=",
1823 "AmbientCapabilities=",
1824 "PrivateTmp=yes",
1825 "PrivateDevices=yes",
1826 "ProtectSystem=strict",
1827 "ProtectKernelTunables=yes",
1828 "ProtectKernelModules=yes",
1829 "ProtectControlGroups=yes",
1830 "RestrictNamespaces=yes",
1831 "RestrictRealtime=yes",
1832 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1833];
1834
1835pub const BOOT_CAPABILITIES: &str = boot_capabilities!();
1849
1850macro_rules! boot_capabilities {
1853 () => {
1854 "CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_SETGID CAP_SETUID"
1855 };
1856}
1857use boot_capabilities;
1858
1859fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1868 let mut directives = SYSTEMD_HARDENING.to_vec();
1869 if mode == StartMode::Boot {
1870 if let Some(directive) = directives
1871 .iter_mut()
1872 .find(|directive| **directive == "CapabilityBoundingSet=")
1873 {
1874 *directive = concat!("CapabilityBoundingSet=", boot_capabilities!());
1875 }
1876 directives.push("ProtectHome=tmpfs");
1877 }
1878 directives
1879}
1880
1881fn inaccessible_entries(plan: &InstallPlan) -> Vec<String> {
1884 plan.protected_paths()
1885 .iter()
1886 .map(|path| quote_argument(&format!("-{}", path.to_string_lossy())))
1887 .collect()
1888}
1889
1890#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1902#[serde(deny_unknown_fields)]
1903pub struct ProtectedPaths {
1904 #[serde(default)]
1905 pub paths: Vec<PathBuf>,
1906}
1907
1908impl ProtectedPaths {
1909 pub const FILE: &'static str = "protected-paths.json";
1911
1912 #[must_use]
1914 pub const fn supported() -> bool {
1915 cfg!(all(unix, not(target_os = "macos")))
1916 }
1917
1918 pub fn load(config_dir: &Path) -> std::io::Result<Self> {
1924 crate::settings_file::load(&config_dir.join(Self::FILE))
1925 }
1926
1927 pub fn save(&self, config_dir: &Path) -> std::io::Result<()> {
1933 crate::settings_file::save(&config_dir.join(Self::FILE), self)
1934 }
1935
1936 #[must_use]
1942 pub fn refusal(path: &Path, own: &[PathBuf]) -> Option<String> {
1943 const SYSTEM: [&str; 16] = [
1944 "/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/opt", "/proc",
1945 "/run", "/sbin", "/sys", "/tmp", "/usr", "/var",
1946 ];
1947 if !path.is_absolute()
1948 || path
1949 .components()
1950 .any(|component| matches!(component, std::path::Component::ParentDir))
1951 {
1952 return Some(format!(
1953 "{} is not an absolute path without `..`",
1954 path.display()
1955 ));
1956 }
1957 if SYSTEM.iter().any(|system| path == Path::new(system)) {
1958 return Some(format!(
1959 "{} is one every job needs to run; protect a directory inside it instead",
1960 path.display()
1961 ));
1962 }
1963 if let Some(needed) = own
1964 .iter()
1965 .find(|needed| needed.starts_with(path) || path.starts_with(needed))
1966 {
1967 return Some(format!(
1968 "{} holds or lies inside {}, which the service itself uses",
1969 path.display(),
1970 needed.display()
1971 ));
1972 }
1973 None
1974 }
1975}
1976
1977const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1980
1981fn home_root_of(path: &Path) -> Option<&'static str> {
1983 SYSTEMD_HOME_ROOTS
1984 .iter()
1985 .copied()
1986 .find(|root| path.starts_with(root))
1987}
1988
1989#[cfg(any(all(test, unix), target_os = "linux"))]
1992fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1993 let occupied = match std::fs::read_dir(path) {
1994 Ok(mut entries) => entries.next().is_some(),
1995 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1996 Err(error) => return Err(error),
1997 };
1998 if occupied {
1999 let name = path.file_name().map_or_else(
2000 || std::borrow::Cow::Borrowed("directory"),
2001 |name| name.to_string_lossy(),
2002 );
2003 std::fs::rename(
2004 path,
2005 path.with_file_name(format!("{name}.before-move-{stamp}")),
2006 )?;
2007 }
2008 Ok(())
2009}
2010
2011#[cfg(any(all(test, unix), target_os = "linux"))]
2015fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
2016 use std::os::unix::fs::DirBuilderExt as _;
2017
2018 std::fs::DirBuilder::new()
2019 .recursive(true)
2020 .mode(0o700)
2021 .create(to)?;
2022 for entry in std::fs::read_dir(from)? {
2023 let entry = entry?;
2024 if skip.is_some_and(|skip| entry.file_name() == skip) {
2025 continue;
2026 }
2027 let source = entry.path();
2028 let target = to.join(entry.file_name());
2029 let kind = entry.file_type()?;
2030 if kind.is_symlink() {
2031 std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
2032 } else if kind.is_dir() {
2033 copy_tree(&source, &target, None)?;
2034 } else {
2035 std::fs::copy(&source, &target)?;
2036 }
2037 }
2038 Ok(())
2039}
2040
2041#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2048fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
2049 if let Some(root) = home_root_of(path) {
2050 return Some(format!(
2051 "{} is under {root}, which a boot service cannot see",
2052 path.display()
2053 ));
2054 }
2055 match owner {
2056 Some(0) => None,
2057 Some(uid) => Some(format!(
2058 "{} belongs to uid {uid}, and a boot service runs as root with no capability \
2059 to open another account's directory",
2060 path.display()
2061 )),
2062 None => Some(format!("{} cannot be inspected", path.display())),
2063 }
2064}
2065
2066pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
2090
2091#[must_use]
2098pub fn launchd_plist(plan: &InstallPlan) -> String {
2099 let identity = plan.identity();
2100 let directories = plan.directories();
2101 let mut out = String::new();
2102 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
2103 out.push_str(
2104 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
2105 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
2106 );
2107 out.push_str("<plist version=\"1.0\">\n<dict>\n");
2108 out.push_str(&plist_string("Label", &identity.launchd_label()));
2109
2110 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
2111 out.push_str(&format!(
2112 " <string>{}</string>\n",
2113 xml_escape(&plan.binary().to_string_lossy())
2114 ));
2115 for argument in plan.arguments() {
2116 out.push_str(&format!(
2117 " <string>{}</string>\n",
2118 xml_escape(&argument.to_string_lossy())
2119 ));
2120 }
2121 out.push_str(" </array>\n");
2122
2123 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
2124 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
2125 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
2126 out.push_str(" </dict>\n");
2127 out.push_str(&format!(
2128 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
2129 plan.restart().delay().as_secs()
2130 ));
2131 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
2132 out.push_str(&plist_string(
2133 "WorkingDirectory",
2134 &directories.state.to_string_lossy(),
2135 ));
2136 out.push_str(&plist_string(
2137 "StandardOutPath",
2138 &directories
2139 .logs
2140 .join("runner-manager.launchd.out.log")
2141 .to_string_lossy(),
2142 ));
2143 out.push_str(&plist_string(
2144 "StandardErrorPath",
2145 &directories
2146 .logs
2147 .join("runner-manager.launchd.err.log")
2148 .to_string_lossy(),
2149 ));
2150
2151 match plan.start_mode() {
2152 StartMode::Boot => {
2153 out.push_str(&plist_string(
2156 "UserName",
2157 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
2158 .as_str(),
2159 ));
2160 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
2162 }
2163 StartMode::Login => {
2164 }
2168 }
2169
2170 out.push_str("</dict>\n</plist>\n");
2171 out
2172}
2173
2174fn plist_string(key: &str, value: &str) -> String {
2176 format!(
2177 " <key>{}</key>\n <string>{}</string>\n",
2178 xml_escape(key),
2179 xml_escape(value)
2180 )
2181}
2182
2183#[derive(Debug, Clone, PartialEq, Eq)]
2191pub struct TaskPrincipal {
2192 user_id: String,
2193}
2194
2195impl TaskPrincipal {
2196 pub fn current() -> Result<Self, ServiceError> {
2204 let user = std::env::var("USERNAME")
2205 .ok()
2206 .filter(|value| !value.trim().is_empty());
2207 let Some(user) = user else {
2208 return Err(ServiceError::Control {
2209 operation: "identify the account for",
2210 name: SERVICE_NAME.to_string(),
2211 manager: "Windows Task Scheduler",
2212 detail: "this session reports no %USERNAME%, so there is no principal to \
2213 register a logon-triggered task for"
2214 .to_string(),
2215 });
2216 };
2217 let domain = std::env::var("USERDOMAIN")
2218 .ok()
2219 .filter(|value| !value.trim().is_empty());
2220 Ok(Self {
2221 user_id: match domain {
2222 Some(domain) => format!("{domain}\\{user}"),
2223 None => user,
2224 },
2225 })
2226 }
2227
2228 #[must_use]
2230 pub fn named(user_id: impl Into<String>) -> Self {
2231 Self {
2232 user_id: user_id.into(),
2233 }
2234 }
2235
2236 #[must_use]
2238 pub fn user_id(&self) -> &str {
2239 &self.user_id
2240 }
2241}
2242
2243#[must_use]
2250pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2251 let identity = plan.identity();
2252 let user = xml_escape(principal.user_id());
2253 let arguments = plan
2254 .arguments()
2255 .iter()
2256 .map(|argument| quote_argument(&argument.to_string_lossy()))
2257 .collect::<Vec<_>>()
2258 .join(" ");
2259 let mut out = String::new();
2260 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2261 out.push_str(
2262 "<Task version=\"1.4\" \
2263 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2264 );
2265 out.push_str(" <RegistrationInfo>\n");
2266 out.push_str(&format!(
2267 " <Description>{}</Description>\n",
2268 xml_escape(identity.description())
2269 ));
2270 out.push_str(&format!(
2271 " <URI>\\{}</URI>\n",
2272 xml_escape(identity.name())
2273 ));
2274 out.push_str(" </RegistrationInfo>\n");
2275
2276 out.push_str(" <Triggers>\n <LogonTrigger>\n");
2277 out.push_str(" <Enabled>true</Enabled>\n");
2278 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2279 out.push_str(" </LogonTrigger>\n </Triggers>\n");
2280
2281 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
2282 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2283 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
2284 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
2285 out.push_str(" </Principal>\n </Principals>\n");
2286
2287 out.push_str(" <Settings>\n");
2288 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2292 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2293 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2294 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
2295 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
2296 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2297 out.push_str(" <IdleSettings>\n");
2298 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2299 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
2300 out.push_str(" </IdleSettings>\n");
2301 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2302 out.push_str(" <Enabled>true</Enabled>\n");
2303 out.push_str(" <Hidden>false</Hidden>\n");
2304 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2305 out.push_str(" <WakeToRun>false</WakeToRun>\n");
2306 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2308 out.push_str(" <Priority>7</Priority>\n");
2309 out.push_str(" <RestartOnFailure>\n");
2310 out.push_str(&format!(
2311 " <Interval>{}</Interval>\n",
2312 iso8601_minutes(
2313 plan.restart()
2314 .effective_delay(DefinitionKind::WindowsScheduledTask)
2315 )
2316 ));
2317 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
2318 out.push_str(" </RestartOnFailure>\n");
2319 out.push_str(" </Settings>\n");
2320
2321 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
2322 out.push_str(&format!(
2323 " <Command>{}</Command>\n",
2324 xml_escape(&plan.binary().to_string_lossy())
2325 ));
2326 if !arguments.is_empty() {
2327 out.push_str(&format!(
2328 " <Arguments>{}</Arguments>\n",
2329 xml_escape(&arguments)
2330 ));
2331 }
2332 out.push_str(&format!(
2333 " <WorkingDirectory>{}</WorkingDirectory>\n",
2334 xml_escape(&plan.directories().state.to_string_lossy())
2335 ));
2336 out.push_str(" </Exec>\n </Actions>\n");
2337 out.push_str("</Task>\n");
2338 out
2339}
2340
2341fn iso8601_minutes(duration: Duration) -> String {
2344 format!("PT{}M", duration.as_secs() / 60)
2345}
2346
2347pub(crate) fn xml_escape(value: &str) -> String {
2355 let mut out = String::with_capacity(value.len());
2356 for c in value.chars() {
2357 match c {
2358 '&' => out.push_str("&"),
2359 '<' => out.push_str("<"),
2360 '>' => out.push_str(">"),
2361 '"' => out.push_str("""),
2362 '\'' => out.push_str("'"),
2363 other => out.push(other),
2364 }
2365 }
2366 out
2367}
2368
2369fn xml_unescape(value: &str) -> String {
2375 value
2376 .replace("<", "<")
2377 .replace(">", ">")
2378 .replace(""", "\"")
2379 .replace("'", "'")
2380 .replace("&", "&")
2381}
2382
2383#[derive(Debug, Clone, PartialEq, Eq)]
2392pub struct WindowsServiceSpec {
2393 pub name: String,
2395 pub display_name: String,
2397 pub description: String,
2399 pub automatic_start: bool,
2401 pub account: Option<String>,
2404 pub command_line: String,
2406 pub restart: RestartPolicy,
2408}
2409
2410#[must_use]
2412pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2413 WindowsServiceSpec {
2414 name: plan.identity().name().to_string(),
2415 display_name: plan.identity().display_name().to_string(),
2416 description: plan.identity().description().to_string(),
2417 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2422 account: match ServiceAccount::for_definition(
2423 DefinitionKind::WindowsService,
2424 plan.start_mode(),
2425 ) {
2426 ServiceAccount::LocalSystem => None,
2429 other => Some(other.as_str().to_string()),
2430 },
2431 command_line: plan.command_line(),
2432 restart: plan.restart(),
2433 }
2434}
2435
2436#[must_use]
2439fn windows_service_descriptor(plan: &InstallPlan) -> String {
2440 let spec = windows_service_spec(plan);
2441 let mut out = String::new();
2442 out.push_str("[windows-service]\n");
2443 out.push_str(&format!("Name={}\n", spec.name));
2444 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2445 out.push_str(&format!("Description={}\n", spec.description));
2446 out.push_str("ServiceType=OWN_PROCESS\n");
2450 out.push_str(&format!(
2451 "StartType={}\n",
2452 if spec.automatic_start {
2453 "AutoStart"
2454 } else {
2455 "OnDemand"
2456 }
2457 ));
2458 out.push_str("ErrorControl=Normal\n");
2459 out.push_str(&format!(
2460 "Account={}\n",
2461 spec.account
2462 .as_deref()
2463 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2464 ));
2465 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2466 out.push_str(&format!(
2467 "FailureActionRestartDelaySecs={}\n",
2468 spec.restart.delay().as_secs()
2469 ));
2470 out.push_str(&format!(
2471 "FailureActionsResetPeriodSecs={}\n",
2472 spec.restart.reset_after().as_secs()
2473 ));
2474 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2478 out.push_str(&format!(
2479 "ReadWritePaths={}\n",
2480 plan.directories()
2481 .all()
2482 .iter()
2483 .map(|path| quote_argument(&path.to_string_lossy()))
2484 .collect::<Vec<_>>()
2485 .join(" ")
2486 ));
2487 out
2488}
2489
2490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2496pub enum FindingKind {
2497 Excess,
2500 Shortfall,
2503}
2504
2505impl fmt::Display for FindingKind {
2506 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2507 f.write_str(match self {
2508 Self::Excess => "excess",
2509 Self::Shortfall => "shortfall",
2510 })
2511 }
2512}
2513
2514#[derive(Debug, Clone, PartialEq, Eq)]
2516pub struct PrivilegeFinding {
2517 pub kind: FindingKind,
2519 pub subject: String,
2521 pub detail: String,
2523}
2524
2525impl fmt::Display for PrivilegeFinding {
2526 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2527 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2528 }
2529}
2530
2531#[derive(Debug, Clone, PartialEq, Eq)]
2545pub struct PrivilegeReview {
2546 kind: DefinitionKind,
2547 account: ServiceAccount,
2548 controls: Vec<String>,
2549 findings: Vec<PrivilegeFinding>,
2550}
2551
2552impl PrivilegeReview {
2553 #[must_use]
2555 pub fn is_least_privilege(&self) -> bool {
2556 !self
2557 .findings
2558 .iter()
2559 .any(|finding| finding.kind == FindingKind::Excess)
2560 }
2561
2562 #[must_use]
2564 pub fn findings(&self) -> &[PrivilegeFinding] {
2565 &self.findings
2566 }
2567
2568 #[must_use]
2571 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2572 self.findings
2573 .iter()
2574 .filter(|finding| finding.kind == FindingKind::Excess)
2575 .collect()
2576 }
2577
2578 #[must_use]
2584 pub fn controls(&self) -> &[String] {
2585 &self.controls
2586 }
2587
2588 #[must_use]
2590 pub const fn account(&self) -> &ServiceAccount {
2591 &self.account
2592 }
2593
2594 #[must_use]
2596 pub const fn kind(&self) -> DefinitionKind {
2597 self.kind
2598 }
2599}
2600
2601impl fmt::Display for PrivilegeReview {
2602 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2603 writeln!(
2604 f,
2605 "{} runs as {} ({})",
2606 self.kind,
2607 self.account,
2608 self.account.justification()
2609 )?;
2610 for control in &self.controls {
2611 writeln!(f, " confirmed {control}")?;
2612 }
2613 for finding in &self.findings {
2614 writeln!(f, " {finding}")?;
2615 }
2616 if self.is_least_privilege() {
2617 write!(f, " verdict least privilege")
2618 } else {
2619 write!(
2620 f,
2621 " verdict NOT least privilege: {} excess(es)",
2622 self.excesses().len()
2623 )
2624 }
2625 }
2626}
2627
2628#[must_use]
2634pub fn review_least_privilege(
2635 definition: &ServiceDefinition,
2636 plan: &InstallPlan,
2637) -> PrivilegeReview {
2638 let mut controls = Vec::new();
2639 let mut findings = Vec::new();
2640 match definition.kind() {
2641 DefinitionKind::SystemdUnit => {
2642 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2643 }
2644 DefinitionKind::LaunchdPlist => {
2645 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2646 }
2647 DefinitionKind::WindowsScheduledTask => {
2648 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2649 }
2650 DefinitionKind::WindowsService => {
2651 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2652 }
2653 }
2654 PrivilegeReview {
2655 kind: definition.kind(),
2656 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2661 controls,
2662 findings,
2663 }
2664}
2665
2666fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2668 let mut permitted = plan
2669 .directories()
2670 .all()
2671 .iter()
2672 .map(|path| path.to_string_lossy().into_owned())
2673 .collect::<Vec<_>>();
2674 if kind == DefinitionKind::SystemdUnit
2675 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2676 {
2677 permitted.push(secret_directory.to_string_lossy().into_owned());
2678 }
2679 permitted
2680}
2681
2682fn same_path_text(left: &str, right: &str) -> bool {
2689 if cfg!(windows) {
2690 left.eq_ignore_ascii_case(right)
2691 } else {
2692 left == right
2693 }
2694}
2695
2696fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2699 match kind {
2700 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2701 left.eq_ignore_ascii_case(right)
2702 }
2703 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2704 }
2705}
2706
2707fn review_writable_paths(
2709 kind: DefinitionKind,
2710 subject: &str,
2711 listed: &[String],
2712 plan: &InstallPlan,
2713 controls: &mut Vec<String>,
2714 findings: &mut Vec<PrivilegeFinding>,
2715) {
2716 let permitted = permitted_paths(kind, plan);
2717 for entry in listed {
2718 if !permitted
2719 .iter()
2720 .any(|allowed| same_path_for(kind, allowed, entry))
2721 {
2722 findings.push(PrivilegeFinding {
2723 kind: FindingKind::Excess,
2724 subject: subject.to_string(),
2725 detail: format!(
2726 "{entry} is writable but is not one of this registration's required paths"
2727 ),
2728 });
2729 }
2730 }
2731 for allowed in &permitted {
2732 if !listed
2733 .iter()
2734 .any(|entry| same_path_for(kind, allowed, entry))
2735 {
2736 findings.push(PrivilegeFinding {
2737 kind: FindingKind::Shortfall,
2738 subject: subject.to_string(),
2739 detail: format!(
2740 "{allowed} is one of this registration's directories but is not writable, \
2741 so the daemon cannot use it"
2742 ),
2743 });
2744 }
2745 }
2746 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2747 controls.push(format!(
2748 "{subject} names exactly the required application-data and credential paths"
2749 ));
2750 }
2751}
2752
2753fn review_capabilities(
2756 expected: &str,
2757 wanted: &str,
2758 actual: &str,
2759 controls: &mut Vec<String>,
2760 findings: &mut Vec<PrivilegeFinding>,
2761) {
2762 let subject = "CapabilityBoundingSet";
2763 if actual.trim_start().starts_with('~') {
2765 findings.push(PrivilegeFinding {
2766 kind: FindingKind::Excess,
2767 subject: subject.to_string(),
2768 detail: format!("is `{actual}`, which keeps every capability it does not name"),
2769 });
2770 return;
2771 }
2772 let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2773 let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2774 let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2775 let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2776 if !extra.is_empty() {
2777 findings.push(PrivilegeFinding {
2778 kind: FindingKind::Excess,
2779 subject: subject.to_string(),
2780 detail: format!(
2781 "keeps {}, which the requirement does not ask for",
2782 extra.join(" ")
2783 ),
2784 });
2785 }
2786 if !missing.is_empty() {
2787 findings.push(PrivilegeFinding {
2788 kind: FindingKind::Shortfall,
2789 subject: subject.to_string(),
2790 detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2791 });
2792 }
2793 if extra.is_empty() && missing.is_empty() {
2794 controls.push(expected.to_string());
2795 }
2796}
2797
2798fn review_inbound_surface(
2806 text: &str,
2807 markers: &[(&str, &str)],
2808 controls: &mut Vec<String>,
2809 findings: &mut Vec<PrivilegeFinding>,
2810) {
2811 let mut clean = true;
2812 for (marker, detail) in markers {
2813 if text.contains(marker) {
2814 clean = false;
2815 findings.push(PrivilegeFinding {
2816 kind: FindingKind::Excess,
2817 subject: (*marker).to_string(),
2818 detail: (*detail).to_string(),
2819 });
2820 }
2821 }
2822 if clean {
2823 controls.push(
2824 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2825 );
2826 }
2827}
2828
2829fn review_systemd(
2830 text: &str,
2831 plan: &InstallPlan,
2832 controls: &mut Vec<String>,
2833 findings: &mut Vec<PrivilegeFinding>,
2834) {
2835 let directives = ini_directives(text, "Service");
2836 for expected in systemd_hardening(plan.start_mode()) {
2837 let (key, value) = expected
2838 .split_once('=')
2839 .expect("every hardening directive is written as key=value");
2840 match directives.get(key) {
2841 Some(actual) if key == "CapabilityBoundingSet" => {
2842 review_capabilities(expected, value, actual, controls, findings);
2843 }
2844 Some(actual) if actual == value => controls.push((*expected).to_string()),
2845 Some(actual) => findings.push(PrivilegeFinding {
2846 kind: FindingKind::Excess,
2847 subject: key.to_string(),
2848 detail: format!(
2849 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2850 requirement does not ask for"
2851 ),
2852 }),
2853 None => findings.push(PrivilegeFinding {
2854 kind: FindingKind::Excess,
2855 subject: key.to_string(),
2856 detail: format!(
2857 "is absent, so the unit inherits systemd's default rather than `{value}`"
2858 ),
2859 }),
2860 }
2861 }
2862
2863 match directives.get("ReadWritePaths") {
2864 Some(value) => {
2865 let listed = split_quoted(value);
2866 review_writable_paths(
2867 DefinitionKind::SystemdUnit,
2868 "ReadWritePaths",
2869 &listed,
2870 plan,
2871 controls,
2872 findings,
2873 );
2874 }
2875 None => findings.push(PrivilegeFinding {
2876 kind: FindingKind::Shortfall,
2877 subject: "ReadWritePaths".to_string(),
2878 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2879 .to_string(),
2880 }),
2881 }
2882
2883 if let Some(value) = directives.get("BindPaths") {
2887 let permitted = permitted_paths(DefinitionKind::SystemdUnit, plan);
2888 for entry in split_quoted(value) {
2889 if !permitted
2890 .iter()
2891 .any(|allowed| same_path_for(DefinitionKind::SystemdUnit, allowed, &entry))
2892 {
2893 findings.push(PrivilegeFinding {
2894 kind: FindingKind::Excess,
2895 subject: "BindPaths".to_string(),
2896 detail: format!(
2897 "{entry} is bound into the unit but is not one of this registration's \
2898 required paths"
2899 ),
2900 });
2901 }
2902 }
2903 }
2904
2905 if !plan.protected_paths().is_empty() {
2908 let listed = directives
2909 .get("InaccessiblePaths")
2910 .map(|value| split_quoted(value))
2911 .unwrap_or_default();
2912 let missing = plan
2913 .protected_paths()
2914 .iter()
2915 .filter(|path| {
2916 let entry = format!("-{}", path.to_string_lossy());
2917 !listed.contains(&entry)
2918 })
2919 .map(|path| path.display().to_string())
2920 .collect::<Vec<_>>();
2921 if missing.is_empty() {
2922 controls.push("InaccessiblePaths keeps every protected path from every job".into());
2923 } else {
2924 findings.push(PrivilegeFinding {
2925 kind: FindingKind::Excess,
2926 subject: "InaccessiblePaths".to_string(),
2927 detail: format!("no longer hides {}", missing.join(", ")),
2928 });
2929 }
2930 }
2931
2932 if directives.contains_key("PrivateUsers")
2935 && directives.get("PrivateUsers") == Some(&"no".to_string())
2936 {
2937 findings.push(PrivilegeFinding {
2938 kind: FindingKind::Excess,
2939 subject: "PrivateUsers".to_string(),
2940 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2941 .to_string(),
2942 });
2943 }
2944
2945 review_inbound_surface(
2946 text,
2947 &[
2948 (
2949 "ListenStream=",
2950 "asks systemd to open a listening socket for this service, which \
2951 07-security.md rule 2 forbids the product to have",
2952 ),
2953 (
2954 "ListenDatagram=",
2955 "asks systemd to open a listening socket for this service, which \
2956 07-security.md rule 2 forbids the product to have",
2957 ),
2958 ],
2959 controls,
2960 findings,
2961 );
2962}
2963
2964fn review_launchd(
2965 text: &str,
2966 plan: &InstallPlan,
2967 controls: &mut Vec<String>,
2968 findings: &mut Vec<PrivilegeFinding>,
2969) {
2970 match plist_string_value(text, "ProcessType") {
2974 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2975 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2976 }
2977 found => findings.push(PrivilegeFinding {
2978 kind: FindingKind::Shortfall,
2979 subject: "ProcessType".to_string(),
2980 detail: format!(
2981 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2982 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2983 when it next starts with no runner; `service status` says how to restart it",
2984 found.map_or_else(
2985 || "absent (launchd's `Standard`)".to_string(),
2986 |value| format!("`{value}`")
2987 )
2988 ),
2989 }),
2990 }
2991
2992 match plan.start_mode() {
2993 StartMode::Boot => {
2994 if plist_bool_value(text, "SessionCreate") == Some(true) {
2995 findings.push(PrivilegeFinding {
2996 kind: FindingKind::Excess,
2997 subject: "SessionCreate".to_string(),
2998 detail: "asks launchd to create a security session for a job that runs \
2999 outside every login session and has no use for one"
3000 .to_string(),
3001 });
3002 } else {
3003 controls.push("SessionCreate is not requested".to_string());
3004 }
3005 match plist_string_value(text, "UserName").as_deref() {
3006 Some("root") => {
3007 controls.push("UserName=root, stated rather than inherited".to_string())
3008 }
3009 Some(other) => findings.push(PrivilegeFinding {
3010 kind: FindingKind::Shortfall,
3011 subject: "UserName".to_string(),
3012 detail: format!(
3013 "is `{other}`, which cannot unlock the System Keychain: \
3014 /var/db/SystemKey is root-only, so the daemon would start and then \
3015 find no credential"
3016 ),
3017 }),
3018 None => findings.push(PrivilegeFinding {
3019 kind: FindingKind::Shortfall,
3020 subject: "UserName".to_string(),
3021 detail: "is absent, so the account is launchd's implicit default and this \
3022 review cannot confirm it"
3023 .to_string(),
3024 }),
3025 }
3026 }
3027 StartMode::Login => {
3028 if let Some(named) = plist_string_value(text, "UserName") {
3029 findings.push(PrivilegeFinding {
3030 kind: FindingKind::Excess,
3031 subject: "UserName".to_string(),
3032 detail: format!(
3033 "names `{named}` in a LaunchAgent, which already runs as the operator; \
3034 naming an account here asks launchd for a switch a login-mode \
3035 registration has no reason to want"
3036 ),
3037 });
3038 } else {
3039 controls
3040 .push("no UserName: the agent runs as the operator and no other".to_string());
3041 }
3042 }
3043 }
3044
3045 review_inbound_surface(
3046 text,
3047 &[
3048 (
3049 "<key>Sockets</key>",
3050 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
3051 forbids the product to have",
3052 ),
3053 (
3054 "<key>MachServices</key>",
3055 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
3056 forbids the product to have",
3057 ),
3058 ],
3059 controls,
3060 findings,
3061 );
3062}
3063
3064fn review_scheduled_task(
3065 text: &str,
3066 controls: &mut Vec<String>,
3067 findings: &mut Vec<PrivilegeFinding>,
3068) {
3069 match xml_value(text, "RunLevel").as_deref() {
3070 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
3071 Some(other) => findings.push(PrivilegeFinding {
3072 kind: FindingKind::Excess,
3073 subject: "RunLevel".to_string(),
3074 detail: format!(
3075 "is `{other}`, so the task runs with an elevated token whenever the operator is \
3076 an administrator"
3077 ),
3078 }),
3079 None => findings.push(PrivilegeFinding {
3080 kind: FindingKind::Excess,
3081 subject: "RunLevel".to_string(),
3082 detail: "is absent, so Task Scheduler decides the token rather than the definition"
3083 .to_string(),
3084 }),
3085 }
3086
3087 match xml_value(text, "LogonType").as_deref() {
3088 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
3089 Some(other) => findings.push(PrivilegeFinding {
3090 kind: FindingKind::Excess,
3091 subject: "LogonType".to_string(),
3092 detail: format!(
3093 "is `{other}`, which asks Windows to store or synthesise a credential for this \
3094 task; an interactive token needs neither"
3095 ),
3096 }),
3097 None => findings.push(PrivilegeFinding {
3098 kind: FindingKind::Shortfall,
3099 subject: "LogonType".to_string(),
3100 detail: "is absent, so this review cannot confirm that no credential is stored"
3101 .to_string(),
3102 }),
3103 }
3104}
3105
3106fn review_windows_service(
3107 text: &str,
3108 plan: &InstallPlan,
3109 controls: &mut Vec<String>,
3110 findings: &mut Vec<PrivilegeFinding>,
3111) {
3112 let directives = ini_directives(text, "windows-service");
3113
3114 match directives.get("ServiceType").map(String::as_str) {
3115 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
3116 Some(other) => findings.push(PrivilegeFinding {
3117 kind: FindingKind::Excess,
3118 subject: "ServiceType".to_string(),
3119 detail: format!(
3120 "is `{other}`; an interactive or shared-process service reaches further than a \
3121 daemon that only talks to GitHub over HTTPS"
3122 ),
3123 }),
3124 None => findings.push(PrivilegeFinding {
3125 kind: FindingKind::Shortfall,
3126 subject: "ServiceType".to_string(),
3127 detail: "is absent, so this review cannot confirm the service is not interactive"
3128 .to_string(),
3129 }),
3130 }
3131
3132 match directives.get("Account").map(String::as_str) {
3136 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
3137 controls.push(format!(
3138 "Account={account}: the only stock account the machine-scoped store's DACL \
3139 (SY, BA, OW) admits"
3140 ));
3141 }
3142 Some(other) => findings.push(PrivilegeFinding {
3143 kind: FindingKind::Shortfall,
3144 subject: "Account".to_string(),
3145 detail: format!(
3146 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
3147 daemon would start and then find no credential. Widening that DACL is not this \
3148 registration's to do: an ACE reaching `{other}` would also reach every other \
3149 service running under it"
3150 ),
3151 }),
3152 None => findings.push(PrivilegeFinding {
3153 kind: FindingKind::Shortfall,
3154 subject: "Account".to_string(),
3155 detail: "is absent, so this review cannot confirm which account was registered"
3156 .to_string(),
3157 }),
3158 }
3159
3160 match directives.get("ReadWritePaths") {
3161 Some(value) => {
3162 let listed = split_quoted(value);
3163 review_writable_paths(
3164 DefinitionKind::WindowsService,
3165 "ReadWritePaths",
3166 &listed,
3167 plan,
3168 controls,
3169 findings,
3170 );
3171 }
3172 None => findings.push(PrivilegeFinding {
3173 kind: FindingKind::Shortfall,
3174 subject: "ReadWritePaths".to_string(),
3175 detail: "is absent, so the directories the service was installed against are not \
3176 recorded"
3177 .to_string(),
3178 }),
3179 }
3180}
3181
3182fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
3190 let mut out = BTreeMap::new();
3191 let mut inside = false;
3192 for line in text.lines() {
3193 let line = line.trim();
3194 if line.starts_with('[') && line.ends_with(']') {
3195 inside = &line[1..line.len() - 1] == section;
3196 continue;
3197 }
3198 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
3199 continue;
3200 }
3201 if let Some((key, value)) = line.split_once('=') {
3202 out.insert(key.trim().to_string(), value.trim().to_string());
3203 }
3204 }
3205 out
3206}
3207
3208#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3215fn systemd_exec_command_line(value: &str) -> String {
3216 let value = value.trim();
3217 let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
3218 if !value[..value.len() - command.len()].contains('@') {
3219 return command.to_string();
3220 }
3221 let mut words = split_command_line(command);
3222 if words.len() > 1 {
3223 words.remove(1);
3224 }
3225 words
3226 .iter()
3227 .map(|word| quote_argument(word))
3228 .collect::<Vec<_>>()
3229 .join(" ")
3230}
3231
3232#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3236fn systemd_timespan(value: &str) -> Option<Duration> {
3237 let mut rest = value.trim();
3238 if rest.is_empty() || rest == "infinity" {
3239 return None;
3240 }
3241 let mut seconds = 0f64;
3242 while !rest.is_empty() {
3243 let digits = rest
3244 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3245 .unwrap_or(rest.len());
3246 if digits == 0 {
3247 return None;
3248 }
3249 let number: f64 = rest[..digits].parse().ok()?;
3250 rest = rest[digits..].trim_start();
3251 let letters = rest
3252 .find(|c: char| !c.is_alphabetic())
3253 .unwrap_or(rest.len());
3254 let scale = match &rest[..letters] {
3255 "" | "s" | "sec" | "second" | "seconds" => 1.0,
3256 "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3257 "ms" | "msec" => 1e-3,
3258 "m" | "min" | "minute" | "minutes" => 60.0,
3259 "h" | "hr" | "hour" | "hours" => 3_600.0,
3260 "d" | "day" | "days" => 86_400.0,
3261 "w" | "week" | "weeks" => 604_800.0,
3262 "M" | "month" | "months" => 2_629_800.0,
3263 "y" | "year" | "years" => 31_557_600.0,
3264 _ => return None,
3265 };
3266 seconds += number * scale;
3267 rest = rest[letters..].trim_start();
3268 }
3269 Duration::try_from_secs_f64(seconds).ok()
3270}
3271
3272fn split_quoted(value: &str) -> Vec<String> {
3275 let mut out = Vec::new();
3276 let mut rest = value.trim();
3277 while !rest.is_empty() {
3278 if rest.starts_with('"') {
3279 if let Some(parsed) = executable_from_command_line(rest) {
3282 out.push(parsed.to_string_lossy().into_owned());
3283 }
3284 let mut depth = 0usize;
3286 let mut end = rest.len();
3287 for (index, c) in rest.char_indices() {
3288 match c {
3289 '\\' => depth += 1,
3290 '"' => {
3291 if depth.is_multiple_of(2) && index > 0 {
3292 end = index + 1;
3293 break;
3294 }
3295 depth = 0;
3296 }
3297 _ => depth = 0,
3298 }
3299 }
3300 rest = rest[end..].trim_start();
3301 } else {
3302 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3303 out.push(rest[..end].to_string());
3304 rest = rest[end..].trim_start();
3305 }
3306 }
3307 out
3308}
3309
3310pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3322 let open = format!("<{tag}>");
3323 let close = format!("</{tag}>");
3324 let start = text.find(&open)? + open.len();
3325 let end = text[start..].find(&close)? + start;
3326 Some(xml_unescape(text[start..end].trim()))
3327}
3328
3329#[cfg(any(windows, test))]
3335fn windows_login_task_starts_automatically(document: &str) -> bool {
3336 document.contains("<LogonTrigger>")
3337 && xml_value(document, "Enabled").as_deref() != Some("false")
3338}
3339
3340fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3342 let marker = format!("<key>{key}</key>");
3343 let start = text.find(&marker)? + marker.len();
3344 Some(text[start..].trim_start())
3345}
3346
3347pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3348 let rest = plist_value_after_key(text, key)?;
3349 if !rest.starts_with("<string>") {
3350 return None;
3351 }
3352 xml_value(rest, "string")
3353}
3354
3355fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3356 let rest = plist_value_after_key(text, key)?;
3357 if rest.starts_with("<true/>") {
3358 Some(true)
3359 } else if rest.starts_with("<false/>") {
3360 Some(false)
3361 } else {
3362 None
3363 }
3364}
3365
3366pub const RECORD_SCHEMA_VERSION: u32 = 1;
3377
3378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3390pub struct InstallRecord {
3391 pub schema_version: u32,
3393 pub service_name: String,
3395 pub manager: String,
3397 pub start_mode: StartMode,
3399 pub account: ServiceAccount,
3401 pub binary: PathBuf,
3403 #[serde(default)]
3411 pub source_binary: Option<PathBuf>,
3412 pub arguments: Vec<String>,
3414 pub restart_delay_secs: u64,
3416 pub restart_reset_secs: u64,
3418 pub log_file: PathBuf,
3420 #[serde(default)]
3428 pub starts_on_demand: bool,
3429 pub definition_path: Option<PathBuf>,
3431 #[serde(default)]
3434 pub protected_paths: Vec<PathBuf>,
3435 pub installed_at: DateTime<Utc>,
3437 pub installed_by_version: String,
3439 pub directories: ServiceDirectories,
3444}
3445
3446impl InstallRecord {
3447 #[must_use]
3449 pub fn path(paths: &AppPaths) -> PathBuf {
3450 paths.config_dir().join(RECORD_FILE)
3451 }
3452
3453 #[must_use]
3455 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3456 Self {
3457 schema_version: RECORD_SCHEMA_VERSION,
3458 service_name: plan.identity().name().to_string(),
3459 manager: definition.kind().manager().to_string(),
3460 start_mode: plan.start_mode(),
3461 account: plan.account().clone(),
3462 binary: plan.binary().to_path_buf(),
3463 arguments: plan
3464 .arguments()
3465 .iter()
3466 .map(|argument| argument.to_string_lossy().into_owned())
3467 .collect(),
3468 restart_delay_secs: plan.restart().delay().as_secs(),
3469 restart_reset_secs: plan.restart().reset_after().as_secs(),
3470 starts_on_demand: plan.is_on_demand(),
3471 source_binary: plan.source_binary().map(Path::to_path_buf),
3472 log_file: plan.directories().log_file(),
3473 definition_path: definition.install_path().map(Path::to_path_buf),
3474 protected_paths: plan.protected_paths().to_vec(),
3475 installed_at: at,
3476 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3477 directories: plan.directories().clone(),
3478 }
3479 }
3480
3481 #[must_use]
3489 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3490 let plan = InstallPlan::unchecked(
3491 identity,
3492 self.start_mode,
3493 self.binary.clone(),
3494 self.directories.clone(),
3495 )
3496 .with_arguments(self.arguments.clone())
3497 .with_restart(self.restart())
3498 .with_protected_paths(self.protected_paths.clone());
3499 let plan = if self.starts_on_demand {
3500 plan.started_on_demand()
3501 } else {
3502 plan
3503 };
3504 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3505 Ok(store) => plan.with_secret_guard(store.guard()),
3506 Err(_) => plan,
3507 }
3508 }
3509
3510 #[must_use]
3514 pub fn restart(&self) -> RestartPolicy {
3515 RestartPolicy::new(
3516 Duration::from_secs(self.restart_delay_secs),
3517 Duration::from_secs(self.restart_reset_secs),
3518 )
3519 .unwrap_or_default()
3520 }
3521
3522 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3533 let path = Self::path(paths);
3534 let text = match std::fs::read_to_string(&path) {
3535 Ok(text) => text,
3536 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3537 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3540 return Err(ServiceError::RecordNotPermitted {
3541 path,
3542 detail: error.to_string(),
3543 });
3544 }
3545 Err(error) => {
3546 return Err(ServiceError::Record {
3547 operation: "read",
3548 path,
3549 detail: error.to_string(),
3550 });
3551 }
3552 };
3553 let record: Self =
3554 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3555 path: path.clone(),
3556 detail: error.to_string(),
3557 })?;
3558 if record.schema_version != RECORD_SCHEMA_VERSION {
3559 return Err(ServiceError::RecordUnreadable {
3560 path,
3561 detail: format!(
3562 "it declares schema version {} and this build reads version {}",
3563 record.schema_version, RECORD_SCHEMA_VERSION
3564 ),
3565 });
3566 }
3567 Ok(Some(record))
3568 }
3569
3570 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3576 use std::io::Write as _;
3577
3578 let path = Self::path(paths);
3579 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3580 operation: "encode",
3581 path: path.clone(),
3582 detail: error.to_string(),
3583 })?;
3584 if let Some(parent) = path.parent() {
3585 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3586 operation: "write",
3587 path: path.clone(),
3588 detail: error.to_string(),
3589 })?;
3590 }
3591 let parent = path.parent().ok_or_else(|| ServiceError::Record {
3592 operation: "write",
3593 path: path.clone(),
3594 detail: "the record path has no parent directory".to_string(),
3595 })?;
3596 let mut temporary =
3597 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3598 operation: "write",
3599 path: path.clone(),
3600 detail: error.to_string(),
3601 })?;
3602 temporary
3603 .write_all(text.as_bytes())
3604 .and_then(|()| temporary.as_file().sync_all())
3605 .map_err(|error| ServiceError::Record {
3606 operation: "write",
3607 path: path.clone(),
3608 detail: error.to_string(),
3609 })?;
3610 #[cfg(unix)]
3626 {
3627 use std::os::unix::fs::PermissionsExt as _;
3628
3629 temporary
3630 .as_file()
3631 .set_permissions(std::fs::Permissions::from_mode(0o644))
3632 .map_err(|error| ServiceError::Record {
3633 operation: "write",
3634 path: path.clone(),
3635 detail: error.to_string(),
3636 })?;
3637 }
3638 temporary
3639 .persist(&path)
3640 .map(|_| ())
3641 .map_err(|error| ServiceError::Record {
3642 operation: "write",
3643 path,
3644 detail: error.error.to_string(),
3645 })
3646 }
3647
3648 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3654 let path = Self::path(paths);
3655 match std::fs::remove_file(&path) {
3656 Ok(()) => Ok(true),
3657 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3658 Err(error) => Err(ServiceError::Record {
3659 operation: "remove",
3660 path,
3661 detail: error.to_string(),
3662 }),
3663 }
3664 }
3665}
3666
3667const CONTACT_SCHEMA_VERSION: u32 = 1;
3673
3674#[derive(Debug, Clone, Serialize, Deserialize)]
3675struct ContactRecord {
3676 schema_version: u32,
3677 last_success: DateTime<Utc>,
3678}
3679
3680pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3701 write_state_record(
3702 &contact_path(paths),
3703 &ContactRecord {
3704 schema_version: CONTACT_SCHEMA_VERSION,
3705 last_success: at,
3706 },
3707 )
3708}
3709
3710pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3722 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3723}
3724
3725#[must_use]
3727pub fn contact_path(paths: &AppPaths) -> PathBuf {
3728 paths.state_dir().join(CONTACT_FILE)
3729}
3730
3731pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3734 let failed = |detail: String| ServiceError::Record {
3735 operation: "write",
3736 path: path.to_path_buf(),
3737 detail,
3738 };
3739 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3740 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3741 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3742 let temporary = path.with_extension("toml.new");
3743 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3744 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3745}
3746
3747pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3750 path: &Path,
3751) -> Result<Option<T>, ServiceError> {
3752 let failed = |detail: String| ServiceError::Record {
3753 operation: "read",
3754 path: path.to_path_buf(),
3755 detail,
3756 };
3757 let text = match std::fs::read_to_string(path) {
3758 Ok(text) => text,
3759 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3760 Err(error) => return Err(failed(error.to_string())),
3761 };
3762 toml::from_str(&text)
3763 .map(Some)
3764 .map_err(|error| failed(error.to_string()))
3765}
3766
3767pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3769 match std::fs::remove_file(path) {
3770 Ok(()) => Ok(()),
3771 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3772 Err(error) => Err(ServiceError::Record {
3773 operation: "remove",
3774 path: path.to_path_buf(),
3775 detail: error.to_string(),
3776 }),
3777 }
3778}
3779
3780#[derive(Debug, Serialize, Deserialize)]
3785#[serde(deny_unknown_fields)]
3786struct CredentialRejectionRecord {
3787 schema_version: u32,
3788 rejected_since: DateTime<Utc>,
3789}
3790
3791pub fn record_github_credential_rejected(
3807 paths: &AppPaths,
3808 at: DateTime<Utc>,
3809) -> Result<(), ServiceError> {
3810 if github_credential_rejected_since(paths)
3811 .ok()
3812 .flatten()
3813 .is_some()
3814 {
3815 return Ok(());
3816 }
3817 write_state_record(
3818 &credential_rejection_path(paths),
3819 &CredentialRejectionRecord {
3820 schema_version: CONTACT_SCHEMA_VERSION,
3821 rejected_since: at,
3822 },
3823 )
3824}
3825
3826pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3832 remove_state_record(&credential_rejection_path(paths))
3833}
3834
3835pub fn github_credential_rejected_since(
3841 paths: &AppPaths,
3842) -> Result<Option<DateTime<Utc>>, ServiceError> {
3843 Ok(
3844 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3845 .map(|record| record.rejected_since),
3846 )
3847}
3848
3849#[must_use]
3851pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3852 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3853}
3854
3855#[derive(Debug, Serialize, Deserialize)]
3856#[serde(deny_unknown_fields)]
3857struct CredentialUnreadableRecord {
3858 schema_version: u32,
3859 unreadable_since: DateTime<Utc>,
3860}
3861
3862pub fn record_credential_unreadable(
3881 paths: &AppPaths,
3882 at: DateTime<Utc>,
3883) -> Result<(), ServiceError> {
3884 if credential_unreadable_since(paths).ok().flatten().is_some() {
3885 return Ok(());
3886 }
3887 write_state_record(
3888 &credential_unreadable_path(paths),
3889 &CredentialUnreadableRecord {
3890 schema_version: CONTACT_SCHEMA_VERSION,
3891 unreadable_since: at,
3892 },
3893 )
3894}
3895
3896pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3902 remove_state_record(&credential_unreadable_path(paths))
3903}
3904
3905pub fn credential_unreadable_since(
3912 paths: &AppPaths,
3913) -> Result<Option<DateTime<Utc>>, ServiceError> {
3914 Ok(
3915 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3916 .map(|record| record.unreadable_since),
3917 )
3918}
3919
3920#[must_use]
3922pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3923 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3924}
3925
3926#[must_use]
3934pub fn sign_in_instruction(mode: StartMode) -> String {
3935 let (command, place) = match mode {
3936 StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3937 StartMode::Boot => (
3938 "runner-manager auth login --start-at boot".to_owned(),
3939 " from an elevated terminal",
3940 ),
3941 StartMode::Login => (
3942 "runner-manager auth login --start-at login".to_owned(),
3943 MACOS_LOGIN_PLACE,
3944 ),
3945 };
3946 format!("`{command}`{place}")
3947}
3948
3949#[must_use]
3958pub fn sudo_command(arguments: &str) -> String {
3959 let executable = if cfg!(test) {
3962 None
3963 } else {
3964 std::env::current_exe().ok()
3965 };
3966 format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3967}
3968
3969fn sudo_program(executable: Option<&Path>) -> String {
3971 const SECURE_PATH: [&str; 7] = [
3974 "/usr/local/sbin",
3975 "/usr/local/bin",
3976 "/usr/sbin",
3977 "/usr/bin",
3978 "/sbin",
3979 "/bin",
3980 "/snap/bin",
3981 ];
3982 let Some(executable) = executable else {
3983 return "runner-manager".to_owned();
3984 };
3985 if executable
3986 .parent()
3987 .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3988 {
3989 return "runner-manager".to_owned();
3990 }
3991 let text = executable.to_string_lossy();
3992 if text
3993 .chars()
3994 .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3995 {
3996 text.into_owned()
3997 } else {
3998 format!("'{}'", text.replace('\'', "'\\''"))
3999 }
4000}
4001
4002pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
4005 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
4006 keychain)"
4007} else {
4008 ""
4009};
4010
4011#[derive(Debug, Clone, PartialEq, Eq)]
4028pub struct DefinitionDrift {
4029 pub path: PathBuf,
4031 pub installed_by_version: String,
4033 pub rendered: String,
4035 pub start_mode: StartMode,
4037 pub manager: DefinitionKind,
4039 pub writable: Vec<PathBuf>,
4046}
4047
4048impl DefinitionDrift {
4049 pub fn create_writable(&self) -> std::io::Result<()> {
4057 create_writable_directories(&self.writable)
4058 }
4059}
4060
4061pub fn create_writable_directories(directories: &[PathBuf]) -> std::io::Result<()> {
4070 for directory in directories {
4071 if let Some(parent) = directory.parent() {
4072 std::fs::create_dir_all(parent)?;
4073 }
4074 match std::fs::create_dir(directory) {
4075 Ok(()) => {
4076 #[cfg(unix)]
4077 {
4078 use std::os::unix::fs::PermissionsExt as _;
4079 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
4080 }
4081 }
4082 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4083 if !directory.is_dir() {
4084 return Err(std::io::Error::new(
4085 std::io::ErrorKind::AlreadyExists,
4086 format!("{} exists and is not a directory", directory.display()),
4087 ));
4088 }
4089 }
4090 Err(error) => return Err(error),
4091 }
4092 }
4093 Ok(())
4094}
4095
4096fn definition_drift(
4104 controls: &dyn ControlFactory,
4105 identity: &ServiceIdentity,
4106 record: &InstallRecord,
4107) -> Option<DefinitionDrift> {
4108 let compare = match host_definition_kind(record.start_mode) {
4109 DefinitionKind::SystemdUnit => systemd_definition_drift,
4110 DefinitionKind::LaunchdPlist => launchd_definition_drift,
4111 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
4112 };
4113 let path = record.definition_path.clone()?;
4114 let installed = controls.installed_definition(&path)?;
4115 compare(identity, record, path, &installed)
4116}
4117
4118fn launchd_definition_drift(
4127 identity: &ServiceIdentity,
4128 record: &InstallRecord,
4129 path: PathBuf,
4130 installed: &str,
4131) -> Option<DefinitionDrift> {
4132 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
4133 || DefinitionDrift {
4134 path,
4135 installed_by_version: record.installed_by_version.clone(),
4136 rendered: launchd_plist(&record.plan(identity.clone())),
4137 start_mode: record.start_mode,
4138 manager: DefinitionKind::LaunchdPlist,
4139 writable: Vec::new(),
4140 },
4141 )
4142}
4143
4144fn systemd_definition_drift(
4145 identity: &ServiceIdentity,
4146 record: &InstallRecord,
4147 path: PathBuf,
4148 installed: &str,
4149) -> Option<DefinitionDrift> {
4150 let plan = record.plan(identity.clone());
4151 let rendered = systemd_unit(&plan);
4152 (installed != rendered).then(|| DefinitionDrift {
4153 path,
4154 installed_by_version: record.installed_by_version.clone(),
4155 rendered,
4156 start_mode: record.start_mode,
4157 manager: DefinitionKind::SystemdUnit,
4158 writable: permitted_paths(DefinitionKind::SystemdUnit, &plan)
4159 .into_iter()
4160 .map(PathBuf::from)
4161 .collect(),
4162 })
4163}
4164
4165const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
4170
4171#[derive(Debug, Clone, Serialize, Deserialize)]
4172struct RootRefusalFile {
4173 schema_version: u32,
4174 #[serde(default)]
4176 refusals: BTreeMap<String, RootRefusalEntry>,
4177}
4178
4179#[derive(Debug, Clone, Serialize, Deserialize)]
4180struct RootRefusalEntry {
4181 at: DateTime<Utc>,
4182 kind: String,
4183 root: String,
4184 detail: String,
4185}
4186
4187#[derive(Debug, Clone, PartialEq, Eq)]
4189pub struct RunnerRootRefusal {
4190 pub policy: String,
4192 pub at: DateTime<Utc>,
4194 pub kind: String,
4196 pub root: String,
4198 pub detail: String,
4201}
4202
4203pub fn record_runner_root_refusal(
4237 paths: &AppPaths,
4238 policy: &str,
4239 at: DateTime<Utc>,
4240 kind: &str,
4241 root: &str,
4242 detail: &str,
4243) -> Result<(), ServiceError> {
4244 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
4245 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
4246 refusals: BTreeMap::new(),
4247 });
4248 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
4249 file.refusals.insert(
4250 policy.to_owned(),
4251 RootRefusalEntry {
4252 at,
4253 kind: kind.to_owned(),
4254 root: root.to_owned(),
4255 detail: detail.to_owned(),
4256 },
4257 );
4258 write_refusal_file(paths, &file)
4259}
4260
4261pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
4272 let Some(mut file) = read_refusal_file(paths)? else {
4273 return Ok(());
4274 };
4275 if file.refusals.remove(policy).is_none() {
4276 return Ok(());
4277 }
4278 if file.refusals.is_empty() {
4279 let path = root_refusal_path(paths);
4280 return match std::fs::remove_file(&path) {
4281 Ok(()) => Ok(()),
4282 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4283 Err(error) => Err(ServiceError::Record {
4284 operation: "remove",
4285 path,
4286 detail: error.to_string(),
4287 }),
4288 };
4289 }
4290 write_refusal_file(paths, &file)
4291}
4292
4293pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4305 Ok(read_refusal_file(paths)?
4306 .map(|file| {
4307 file.refusals
4308 .into_iter()
4309 .map(|(policy, entry)| RunnerRootRefusal {
4310 policy,
4311 at: entry.at,
4312 kind: entry.kind,
4313 root: entry.root,
4314 detail: entry.detail,
4315 })
4316 .collect()
4317 })
4318 .unwrap_or_default())
4319}
4320
4321fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4322 read_state_record(&root_refusal_path(paths))
4323}
4324
4325fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4326 write_state_record(&root_refusal_path(paths), file)
4327}
4328
4329#[must_use]
4331pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4332 paths.state_dir().join(ROOT_REFUSAL_FILE)
4333}
4334
4335#[derive(Debug, Clone, PartialEq, Eq)]
4346pub enum BinaryPath {
4347 Current {
4349 path: PathBuf,
4351 },
4352 Missing {
4359 recorded: PathBuf,
4361 },
4362 NotExecutable {
4365 recorded: PathBuf,
4367 detail: String,
4369 },
4370 Diverged {
4372 recorded: PathBuf,
4374 registered: PathBuf,
4376 },
4377}
4378
4379impl BinaryPath {
4380 #[must_use]
4382 pub const fn is_error(&self) -> bool {
4383 !matches!(self, Self::Current { .. })
4384 }
4385
4386 #[must_use]
4388 pub fn recorded(&self) -> &Path {
4389 match self {
4390 Self::Current { path } => path,
4391 Self::Missing { recorded }
4392 | Self::NotExecutable { recorded, .. }
4393 | Self::Diverged { recorded, .. } => recorded,
4394 }
4395 }
4396}
4397
4398impl fmt::Display for BinaryPath {
4399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4400 match self {
4401 Self::Current { path } => write!(f, "{}", path.display()),
4402 Self::Missing { recorded } => write!(
4403 f,
4404 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4405 package manager that moved the binary is the usual cause; an `npm i -g` \
4406 installation moves with the active Node version. Run `service install` again \
4407 from the binary that is now installed.",
4408 recorded.display()
4409 ),
4410 Self::NotExecutable { recorded, detail } => write!(
4411 f,
4412 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4413 from the installed binary.",
4414 recorded.display()
4415 ),
4416 Self::Diverged {
4417 recorded,
4418 registered,
4419 } => write!(
4420 f,
4421 "{} -- STALE: the service manager is registered to start {} instead. Something \
4422 has edited the registration since it was installed. Run `service uninstall` and \
4423 `service install`; neither touches configuration, secrets, or the cache.",
4424 recorded.display(),
4425 registered.display()
4426 ),
4427 }
4428 }
4429}
4430
4431#[must_use]
4439pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4440 match std::fs::metadata(recorded) {
4441 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4442 return BinaryPath::Missing {
4443 recorded: recorded.to_path_buf(),
4444 };
4445 }
4446 Err(error) => {
4447 return BinaryPath::NotExecutable {
4448 recorded: recorded.to_path_buf(),
4449 detail: format!("it cannot be inspected ({error})"),
4450 };
4451 }
4452 Ok(metadata) if !metadata.is_file() => {
4453 return BinaryPath::NotExecutable {
4454 recorded: recorded.to_path_buf(),
4455 detail: "what is there is not a file".to_string(),
4456 };
4457 }
4458 Ok(_) => {}
4459 }
4460 if let Some(registered) = registered
4461 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
4462 {
4463 return BinaryPath::Diverged {
4464 recorded: recorded.to_path_buf(),
4465 registered: registered.to_path_buf(),
4466 };
4467 }
4468 BinaryPath::Current {
4469 path: recorded.to_path_buf(),
4470 }
4471}
4472
4473#[derive(Debug, Clone, PartialEq, Eq)]
4479pub struct Registration {
4480 pub manager: DefinitionKind,
4485 pub start_mode: StartMode,
4487 pub command_line: String,
4489 pub account: Option<String>,
4496 pub running: bool,
4498 pub starts_automatically: bool,
4505 pub restart_delay: Option<Duration>,
4514}
4515
4516impl Registration {
4517 #[must_use]
4519 pub fn binary(&self) -> Option<PathBuf> {
4520 executable_from_command_line(&self.command_line)
4521 }
4522}
4523
4524pub trait ServiceControl: fmt::Debug {
4530 fn manager(&self) -> DefinitionKind;
4532
4533 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4539
4540 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4551
4552 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4558
4559 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4566
4567 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4573}
4574
4575pub trait ControlFactory: fmt::Debug + Send + Sync {
4581 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4587
4588 fn installed_definition(&self, _path: &Path) -> Option<String> {
4592 None
4593 }
4594
4595 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4600 None
4601 }
4602}
4603
4604#[derive(Debug, Clone, Copy, Default)]
4606pub struct HostControls;
4607
4608#[derive(Debug, Clone)]
4614pub struct Installed {
4615 pub plan: InstallPlan,
4617 pub definition: ServiceDefinition,
4619 pub record: InstallRecord,
4621 pub review: PrivilegeReview,
4623 pub runner_root: RootAccessSummary,
4628 pub replaced_existing: bool,
4636}
4637
4638#[derive(Debug, Clone, PartialEq, Eq)]
4640pub struct Uninstalled {
4641 pub removed_registration: bool,
4643 pub removed_record: bool,
4645 pub removed_definition: Option<PathBuf>,
4647 pub preserved: Vec<PathBuf>,
4654}
4655
4656impl fmt::Display for Uninstalled {
4657 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4658 if self.removed_registration {
4659 writeln!(f, "The service registration was removed.")?;
4660 } else {
4661 writeln!(f, "There was no service registration to remove.")?;
4662 }
4663 writeln!(f, "Nothing else was deleted. These are untouched:")?;
4664 for path in &self.preserved {
4665 writeln!(f, " {}", path.display())?;
4666 }
4667 write!(
4668 f,
4669 "The stored GitHub token is untouched too; `auth logout` is what purges it."
4670 )
4671 }
4672}
4673
4674#[derive(Debug, Clone, PartialEq, Eq)]
4676pub struct StartModeChange {
4677 pub from: StartMode,
4679 pub to: StartMode,
4681 pub changed: bool,
4683 pub store_scope: crate::secrets::SecretScope,
4686 pub runner_root: RootAccessSummary,
4692}
4693
4694impl fmt::Display for StartModeChange {
4695 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4696 if !self.changed {
4697 return write!(f, "The service already starts at {}.", self.to);
4698 }
4699 write!(
4700 f,
4701 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4702 if the token was stored under the other scope, run `auth login` again. {}",
4703 self.to, self.from, self.store_scope, self.runner_root
4704 )
4705 }
4706}
4707
4708#[derive(Debug, Clone)]
4716pub struct ServiceOperations {
4717 paths: AppPaths,
4718 identity: ServiceIdentity,
4719 controls: std::sync::Arc<dyn ControlFactory>,
4720 runner_root: Option<LocalAbsolutePath>,
4721}
4722
4723impl ServiceOperations {
4724 #[must_use]
4726 pub fn on_this_host(paths: AppPaths) -> Self {
4727 Self::with_controls(
4728 paths,
4729 ServiceIdentity::product(),
4730 std::sync::Arc::new(HostControls),
4731 )
4732 }
4733
4734 #[must_use]
4742 pub fn with_controls(
4743 paths: AppPaths,
4744 identity: ServiceIdentity,
4745 controls: std::sync::Arc<dyn ControlFactory>,
4746 ) -> Self {
4747 Self {
4748 paths,
4749 identity,
4750 controls,
4751 runner_root: None,
4752 }
4753 }
4754
4755 #[must_use]
4774 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4775 self.runner_root = Some(root);
4776 self
4777 }
4778
4779 #[must_use]
4781 pub const fn paths(&self) -> &AppPaths {
4782 &self.paths
4783 }
4784
4785 #[must_use]
4787 pub const fn identity(&self) -> &ServiceIdentity {
4788 &self.identity
4789 }
4790
4791 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4811 self.paths
4812 .create_all()
4813 .map_err(|source| ServiceError::Paths {
4814 source: Box::new(source),
4815 })?;
4816
4817 let _guard = self.refuse_while_an_agent_runs()?;
4820 let adopted = self.adopt_boot_installation(request.start_mode())?;
4821 if !adopted {
4825 self.refuse_foreign("replace")?;
4826 }
4827
4828 let (replacing, previous_was_running) = match self.find_registration()? {
4857 Some((existing, registration)) if existing == request.start_mode() => {
4858 (true, registration.running)
4859 }
4860 Some((existing, _)) => {
4861 return Err(ServiceError::AlreadyInstalled {
4862 name: self.identity.name().to_string(),
4863 existing,
4864 requested: request.start_mode(),
4865 });
4866 }
4867 None => (false, false),
4868 };
4869
4870 let plan = InstallPlan::resolve(
4871 self.identity.clone(),
4872 request,
4873 ServiceDirectories::of(&self.paths),
4874 )?;
4875
4876 let control = self.controls.control(plan.start_mode())?;
4881
4882 let root = self.prepare_runner_root(plan.start_mode())?;
4888
4889 let previous = if replacing {
4894 InstallRecord::read(&self.paths).ok().flatten()
4895 } else {
4896 None
4897 };
4898 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4899 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4900 }
4901
4902 let definition = match control.install(&plan) {
4903 Ok(definition) => definition,
4904 Err(cause) => {
4905 let restored =
4908 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4909 return Err(rolled_back(
4910 retained_runner_root(&root),
4911 restored,
4912 "install",
4913 &self.identity,
4914 cause,
4915 ));
4916 }
4917 };
4918 let review = review_least_privilege(&definition, &plan);
4919 let record = InstallRecord::of(&plan, &definition, Utc::now());
4920 if let Err(cause) = record.write(&self.paths) {
4921 let rollback = control
4922 .uninstall(&self.identity)
4923 .map(|_| ())
4924 .and_then(|()| {
4925 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4926 });
4927 return Err(rolled_back(
4928 retained_runner_root(&root),
4929 rollback,
4930 "install",
4931 &self.identity,
4932 cause,
4933 ));
4934 }
4935
4936 if let Err(cause) = control.start(&self.identity) {
4943 let remove_new = control.uninstall(&self.identity).map(|_| ());
4944 let restore_registration = remove_new.and_then(|()| {
4945 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4946 });
4947 let restore_record = restore_registration.and_then(|()| match &previous {
4948 Some(previous) => previous.write(&self.paths),
4949 None => InstallRecord::remove(&self.paths).map(|_| ()),
4950 });
4951 return Err(rolled_back(
4952 retained_runner_root(&root),
4953 restore_record,
4954 "install and start",
4955 &self.identity,
4956 cause,
4957 ));
4958 }
4959 Ok(Installed {
4960 plan,
4961 definition,
4962 record,
4963 review,
4964 runner_root: root.summary().clone(),
4965 replaced_existing: replacing,
4966 })
4967 }
4968
4969 fn reinstate(
4980 &self,
4981 control: &dyn ServiceControl,
4982 previous: Option<&InstallRecord>,
4983 was_running: bool,
4984 ) -> Result<(), ServiceError> {
4985 let Some(record) = previous else {
4986 return Ok(());
4987 };
4988 control.install(&record.plan(self.identity.clone()))?;
4989 if was_running {
4990 control.start(&self.identity)?;
4991 }
4992 Ok(())
4993 }
4994
4995 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
5004 let record = InstallRecord::read(&self.paths).ok().flatten();
5005 let removed_definition = record
5006 .as_ref()
5007 .and_then(|record| record.definition_path.clone());
5008
5009 let mut removed_registration = false;
5010 let mut foreign = None;
5017 for mode in [StartMode::Boot, StartMode::Login] {
5018 let control = self.controls.control(mode)?;
5019 if let Some(directories) = control
5020 .query(&self.identity)
5021 .ok()
5022 .flatten()
5023 .and_then(|registration| self.foreign_directories(®istration))
5024 {
5025 foreign = Some(directories);
5026 continue;
5027 }
5028 if control.uninstall(&self.identity)? {
5029 removed_registration = true;
5030 }
5031 }
5032 if !removed_registration && let Some(directories) = foreign {
5033 return Err(self.foreign_refusal("uninstall", directories));
5034 }
5035 let removed_record = InstallRecord::remove(&self.paths)?;
5036 Ok(Uninstalled {
5037 removed_registration,
5038 removed_record,
5039 removed_definition: removed_definition.filter(|_| removed_registration),
5040 preserved: self
5041 .paths
5042 .all()
5043 .iter()
5044 .map(|(_, path)| (*path).to_path_buf())
5045 .collect(),
5046 })
5047 }
5048
5049 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
5071 let Some(record) = InstallRecord::read(&self.paths)? else {
5072 return Err(ServiceError::NotInstalled {
5073 name: self.identity.name().to_string(),
5074 operation: "switch the start mode of",
5075 });
5076 };
5077 let from = record.start_mode;
5078 if from == to {
5079 return Ok(StartModeChange {
5083 from,
5084 to,
5085 changed: false,
5086 store_scope: crate::secrets::SecretScope::for_start_mode(to),
5087 runner_root: RootAccessSummary::NotApplicable,
5088 });
5089 }
5090
5091 self.refuse_foreign("switch the start mode of")?;
5094
5095 #[cfg(windows)]
5096 let arguments = {
5097 let mut arguments = record.arguments.clone();
5098 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
5099 if to == StartMode::Boot {
5100 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
5101 }
5102 arguments
5103 };
5104 #[cfg(not(windows))]
5105 let arguments = record.arguments.clone();
5106 let plan = InstallPlan::unchecked(
5107 self.identity.clone(),
5108 to,
5109 record.binary.clone(),
5110 record.directories.clone(),
5111 )
5112 .with_arguments(arguments)
5113 .with_restart(record.restart());
5114 let plan = if record.starts_on_demand {
5115 plan.started_on_demand()
5116 } else {
5117 plan
5118 };
5119 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
5120 Ok(store) => plan.with_secret_guard(store.guard()),
5121 Err(_) => plan,
5122 };
5123
5124 let target = self.controls.control(to)?;
5131 let previous = self.controls.control(from)?;
5136
5137 let root = self.prepare_runner_root(to)?;
5145
5146 let definition = match target.install(&plan) {
5147 Ok(definition) => definition,
5148 Err(cause) => {
5149 return Err(undo_runner_root(
5150 &root,
5151 "switch start mode",
5152 &self.identity,
5153 cause,
5154 ));
5155 }
5156 };
5157 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
5158 if let Err(cause) = next_record.write(&self.paths) {
5159 return Err(rolled_back(
5160 retained_runner_root(&root),
5161 target.uninstall(&self.identity),
5162 "switch start mode",
5163 &self.identity,
5164 cause,
5165 ));
5166 }
5167
5168 if let Err(cause) = previous.uninstall(&self.identity) {
5172 let target_rollback = target.uninstall(&self.identity);
5173 let record_rollback = record.write(&self.paths);
5174 return Err(rolled_back(
5175 retained_runner_root(&root),
5176 target_rollback.and(record_rollback),
5177 "switch start mode",
5178 &self.identity,
5179 cause,
5180 ));
5181 }
5182 Ok(StartModeChange {
5183 from,
5184 to,
5185 changed: true,
5186 store_scope: crate::secrets::SecretScope::for_start_mode(to),
5187 runner_root: root.summary().clone(),
5188 })
5189 }
5190
5191 pub fn start(&self) -> Result<(), ServiceError> {
5197 let mode = self.own_registration_mode("start")?;
5198 self.controls.control(mode)?.start(&self.identity)
5199 }
5200
5201 fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
5209 match self.registration_for_status()? {
5210 None => Err(ServiceError::NotInstalled {
5211 name: self.identity.name().to_string(),
5212 operation,
5213 }),
5214 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5215 Some((registration, None)) => Ok(registration.start_mode),
5216 }
5217 }
5218
5219 pub fn stop(&self) -> Result<bool, ServiceError> {
5225 let mode = self.own_registration_mode("stop")?;
5226 self.controls.control(mode)?.stop(&self.identity)
5227 }
5228
5229 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
5260 match self.registration_for_status()? {
5261 Some((registration, Some(directories))) => {
5262 Ok(self.foreign_status(registration, directories))
5263 }
5264 found => {
5265 let (record, record_refused) = match InstallRecord::read(&self.paths) {
5266 Ok(record) => (record, None),
5267 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
5268 Err(error) => return Err(error),
5269 };
5270 self.status_at(
5271 &self.paths,
5272 record,
5273 record_refused.as_ref(),
5274 found.map(|(registration, _)| registration),
5275 )
5276 }
5277 }
5278 }
5279
5280 fn status_at(
5283 &self,
5284 paths: &AppPaths,
5285 record: Option<InstallRecord>,
5286 record_refused: Option<&ServiceError>,
5287 found: Option<Registration>,
5288 ) -> Result<ServiceStatus, ServiceError> {
5289 let last_github_contact = last_github_contact(paths)?;
5290 let drift = record
5291 .as_ref()
5292 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5293 let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5294 let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5295 let probed = self.controls.unattended_login();
5296 let unattended = record.as_ref().and_then(|record| {
5297 let account = probed.as_ref().map_or_else(String::new, |_| {
5300 found
5301 .as_ref()
5302 .and_then(|registration| registration.account.clone())
5303 .unwrap_or_else(|| service_account_name(&record.account))
5304 });
5305 unattended_gap(record.start_mode, &account, probed.as_ref())
5306 });
5307 let now = Utc::now();
5308 let installed = record.is_some() || found.is_some();
5309 let status = ServiceStatus::compose(
5310 self.identity.clone(),
5311 record,
5312 record_refused,
5313 found,
5314 last_github_contact,
5315 paths,
5316 )
5317 .with_definition_drift(drift)
5318 .with_credential_rejection(credential_rejected_since)
5319 .with_credential_unreadable(credential_unreadable_since)
5320 .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5321 .with_unattended_gap(unattended)
5322 .with_host_unfit(
5323 crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5324 );
5325 let restart = status.restart_command();
5326 Ok(status.with_stall(
5327 crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5328 ))
5329 }
5330
5331 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5338 Ok(InstallRecord::read(&self.paths)?
5339 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5340 }
5341
5342 pub fn boot_unit_writable_directories(&self) -> Result<Vec<PathBuf>, ServiceError> {
5349 Ok(InstallRecord::read(&self.paths)?
5350 .filter(|record| {
5351 record.start_mode == StartMode::Boot
5352 && host_definition_kind(record.start_mode) == DefinitionKind::SystemdUnit
5353 })
5354 .map(|record| {
5355 permitted_paths(
5356 DefinitionKind::SystemdUnit,
5357 &record.plan(self.identity.clone()),
5358 )
5359 .into_iter()
5360 .map(PathBuf::from)
5361 .collect()
5362 })
5363 .unwrap_or_default())
5364 }
5365
5366 #[cfg(target_os = "macos")]
5388 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5389 let root = unsafe { libc::getuid() } == 0;
5395 if root != (drift.start_mode == StartMode::Boot) {
5396 return Err(ServiceError::Control {
5397 operation: "rewrite",
5398 name: self.identity.name().to_string(),
5399 manager: "launchd",
5400 detail: format!(
5401 "the install record says --start-at {}, but this daemon runs as {}; the plist \
5402 is left as it is",
5403 drift.start_mode,
5404 if root { "root" } else { "the operator" }
5405 ),
5406 });
5407 }
5408 replace_definition(self.identity.name(), drift)?;
5409 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5413 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5414 let _ = record.write(&self.paths);
5415 }
5416 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5417 }
5418
5419 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5429 #[cfg(not(windows))]
5430 {
5431 let _ = (mode, &self.runner_root);
5434 Ok(RootAccessChange::not_applicable())
5435 }
5436 #[cfg(windows)]
5437 {
5438 let wrap = |source| ServiceError::RunnerRoot {
5439 source: Box::new(source),
5440 };
5441 if let Some(root) = self
5459 .runner_root
5460 .as_ref()
5461 .filter(|_| self.identity.is_fixture() || cfg!(test))
5462 {
5463 let admission = RootAdmission::of_this_account().map_err(wrap)?;
5464 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5465 .map_err(wrap);
5466 }
5467
5468 let admission = match ServiceAccount::for_start_mode(mode) {
5469 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5472 ServiceAccount::InvokingUser | ServiceAccount::Root => {
5476 RootAdmission::of_this_account().map_err(wrap)?
5477 }
5478 };
5479 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5480 }
5481 }
5482
5483 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5485 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5486 |source| match source {
5487 held @ LockError::Held { .. } => ServiceError::LockHeld {
5488 source: Box::new(held),
5489 },
5490 other => ServiceError::LockUnreadable {
5491 source: Box::new(other),
5492 },
5493 },
5494 )
5495 }
5496
5497 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5499 for mode in [StartMode::Boot, StartMode::Login] {
5500 let control = self.controls.control(mode)?;
5501 if let Some(registration) = control.query(&self.identity)? {
5502 return Ok(Some((registration.start_mode, registration)));
5503 }
5504 }
5505 Ok(None)
5506 }
5507
5508 fn registration_for_status(
5517 &self,
5518 ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5519 let Some((mode, found)) = self.find_registration()? else {
5520 return Ok(None);
5521 };
5522 let Some(directories) = self.foreign_directories(&found) else {
5523 return Ok(Some((found, None)));
5524 };
5525 let other = match mode {
5526 StartMode::Boot => StartMode::Login,
5527 StartMode::Login => StartMode::Boot,
5528 };
5529 let own = self
5530 .controls
5531 .control(other)
5532 .ok()
5533 .and_then(|control| control.query(&self.identity).ok().flatten())
5534 .filter(|registration| self.foreign_directories(registration).is_none());
5535 Ok(Some(match own {
5536 Some(own) => (own, None),
5537 None => (found, Some(directories)),
5538 }))
5539 }
5540
5541 pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5559 #[cfg(target_os = "linux")]
5560 return self.adopt_boot_installation_on_linux(mode);
5561 #[cfg(not(target_os = "linux"))]
5562 {
5563 let _ = mode;
5564 Ok(false)
5565 }
5566 }
5567
5568 #[cfg(target_os = "linux")]
5569 fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5570 if mode != StartMode::Boot {
5571 return Ok(false);
5572 }
5573 self.refuse_overridden_account()?;
5574 if !crate::host_fitness::is_elevated() {
5575 return Ok(false);
5576 }
5577 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5578 return Ok(false);
5579 };
5580 let Some(old) = self.foreign_directories(&found) else {
5581 return Ok(false);
5582 };
5583 self.controls
5584 .control(StartMode::Boot)?
5585 .stop(&self.identity)?;
5586 if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5591 let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5592 for (from, to, skip) in [
5593 (&old.config, self.paths.config_dir(), None),
5594 (&old.state, self.paths.state_dir(), Some("bin")),
5596 ] {
5597 set_aside(to, &stamp)
5598 .and_then(|()| copy_tree(from, to, skip))
5599 .map_err(|error| ServiceError::Control {
5600 operation: "move the data of",
5601 name: self.identity.name().to_string(),
5602 manager: "systemd",
5603 detail: format!(
5604 "cannot copy {} to {}: {error}",
5605 from.display(),
5606 to.display()
5607 ),
5608 })?;
5609 }
5610 }
5611 Ok(true)
5612 }
5613
5614 #[cfg(target_os = "linux")]
5621 fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5622 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5623 return Ok(());
5624 };
5625 match found.account.as_deref() {
5626 Some(account) if account != ServiceAccount::Root.as_str() => {
5627 Err(ServiceError::Control {
5628 operation: "install",
5629 name: self.identity.name().to_string(),
5630 manager: "systemd",
5631 detail: format!(
5632 "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5633 a boot service must run as root to use {}. Remove `User=` and `Group=` \
5634 from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5635 self.identity.systemd_unit(),
5636 crate::paths::SYSTEM_DATA_ROOT,
5637 ),
5638 })
5639 }
5640 _ => Ok(()),
5641 }
5642 }
5643
5644 fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5647 match self.registration_for_status()? {
5648 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5649 _ => Ok(()),
5650 }
5651 }
5652
5653 fn foreign_refusal(
5656 &self,
5657 operation: &'static str,
5658 directories: ServiceDirectories,
5659 ) -> ServiceError {
5660 let foreign = self.foreign_service_at(directories);
5661 ServiceError::ForeignInstallation {
5662 name: self.identity.name().to_string(),
5663 operation,
5664 installed_by: foreign.installed_by(),
5665 act_as: foreign.act_as(),
5666 }
5667 }
5668
5669 fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5672 ServiceDirectories::from_command_line(®istration.command_line)
5673 .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5674 }
5675
5676 #[must_use]
5681 pub fn foreign_service(&self) -> Option<ForeignService> {
5682 let (_, directories) = self.registration_for_status().ok()??;
5683 Some(self.foreign_service_at(directories?))
5684 }
5685
5686 fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5689 let root = directories.root();
5690 let home = host_home();
5691 let this_account = home
5694 .as_deref()
5695 .filter(|home| contains_directory(home, &root))
5696 .and_then(|_| {
5697 if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5698 Some(format!(
5699 "with `--data-dir {}`",
5700 quote_argument(&root.to_string_lossy())
5701 ))
5702 } else {
5703 AppPaths::discover()
5704 .ok()
5705 .filter(|standard| directories == ServiceDirectories::of(standard))
5706 .map(|_| "without `--data-dir`".to_owned())
5707 }
5708 });
5709 ForeignService {
5710 owner: data_root_owner(&root, home.as_deref()),
5711 this_account,
5712 root,
5713 directories,
5714 local_root: ServiceDirectories::of(&self.paths).root(),
5715 local_record: InstallRecord::path(&self.paths),
5716 unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5717 unreadable: None,
5718 }
5719 }
5720
5721 fn foreign_status(
5728 &self,
5729 registration: Registration,
5730 directories: ServiceDirectories,
5731 ) -> ServiceStatus {
5732 let service_paths = directories.paths();
5733 let mut foreign = self.foreign_service_at(directories);
5734 let unreadable = match InstallRecord::read(&service_paths) {
5735 Ok(Some(record)) => {
5736 match self.status_at(
5737 &service_paths,
5738 Some(record),
5739 None,
5740 Some(registration.clone()),
5741 ) {
5742 Ok(status) => return status.with_foreign(foreign),
5743 Err(error) => error.to_string(),
5744 }
5745 }
5746 Ok(None) => format!(
5747 "there is no install record at {}",
5748 InstallRecord::path(&service_paths).display()
5749 ),
5750 Err(
5755 ServiceError::RecordNotPermitted { path, detail }
5756 | ServiceError::RecordUnreadable { path, detail }
5757 | ServiceError::Record { path, detail, .. },
5758 ) => format!("this account cannot read {} ({detail})", path.display()),
5759 Err(error) => error.to_string(),
5760 };
5761 foreign.unreadable = Some(unreadable);
5762 ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5763 }
5764
5765 pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5789 let config = self.paths.config_dir();
5790 let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5791 let mut service_root = None;
5792 for mode in [StartMode::Boot, StartMode::Login] {
5793 let registration = self
5794 .controls
5795 .control(mode)
5796 .and_then(|control| control.query(&self.identity))
5797 .map_err(|error| {
5798 in_use(format!(
5799 "whether a {mode} registration runs against it could not be read ({error})"
5800 ))
5801 })?;
5802 let Some(registration) = registration else {
5803 continue;
5804 };
5805 let Some(directories) = self.foreign_directories(®istration) else {
5806 return Err(in_use(format!(
5807 "{} holds a registration that runs against {}. `service status` describes it",
5808 registration.manager,
5809 config.display()
5810 )));
5811 };
5812 if let Some(overlap) = directories.all().into_iter().find(|directory| {
5813 contains_directory(directory, config) || contains_directory(config, directory)
5814 }) {
5815 return Err(in_use(format!(
5816 "the machine's service runs against {}, which overlaps {}",
5817 overlap.display(),
5818 config.display()
5819 )));
5820 }
5821 service_root.get_or_insert_with(|| directories.root());
5822 }
5823 let _lock =
5824 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5825 in_use(format!(
5826 "an agent may be running against these directories: {source}"
5827 ))
5828 })?;
5829 let failed = |operation: &'static str, path: &Path| {
5830 let path = path.to_path_buf();
5831 move |error: std::io::Error| ServiceError::Record {
5832 operation,
5833 path,
5834 detail: error.to_string(),
5835 }
5836 };
5837 let kept = [
5839 self.paths.state_dir(),
5840 self.paths.runtime_dir(),
5841 self.paths.logs_dir(),
5842 ];
5843 let mut entries = Vec::new();
5844 for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5845 let entry = entry.map_err(failed("list", config))?.path();
5846 let archived = entry
5847 .file_name()
5848 .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5849 if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5850 entries.push(entry);
5851 }
5852 }
5853 if entries.is_empty() {
5854 return Ok(ForgottenLocal {
5855 archive: None,
5856 moved: entries,
5857 service_root,
5858 });
5859 }
5860 entries.sort();
5861 let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5862 std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5863 let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5864 for (index, entry) in entries.iter().enumerate() {
5865 if let Err(error) = std::fs::rename(entry, inside(entry)) {
5866 let stranded: Vec<String> = entries[..index]
5867 .iter()
5868 .rev()
5869 .filter(|done| std::fs::rename(inside(done), done).is_err())
5870 .map(|done| inside(done).display().to_string())
5871 .collect();
5872 let _ = std::fs::remove_dir(&archive);
5873 let mut detail = error.to_string();
5874 if !stranded.is_empty() {
5875 detail.push_str(&format!(
5876 "; these could not be put back and are still in the archive: {}",
5877 stranded.join(", ")
5878 ));
5879 }
5880 return Err(ServiceError::Record {
5881 operation: "archive",
5882 path: entry.clone(),
5883 detail,
5884 });
5885 }
5886 }
5887 Ok(ForgottenLocal {
5888 archive: Some(archive),
5889 moved: entries,
5890 service_root,
5891 })
5892 }
5893}
5894
5895pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5898
5899#[derive(Debug, Clone, PartialEq, Eq)]
5901pub struct ForgottenLocal {
5902 pub archive: Option<PathBuf>,
5904 pub moved: Vec<PathBuf>,
5906 pub service_root: Option<PathBuf>,
5909}
5910
5911impl fmt::Display for ForgottenLocal {
5912 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5913 let Some(archive) = &self.archive else {
5914 return write!(
5915 f,
5916 "Nothing to archive: this account's configuration directory is empty."
5917 );
5918 };
5919 writeln!(f, "Archived this account's unused local configuration")?;
5920 writeln!(f, " archive {}", archive.display())?;
5921 for path in &self.moved {
5922 writeln!(f, " moved {}", path.display())?;
5923 }
5924 if let Some(root) = &self.service_root {
5925 writeln!(
5926 f,
5927 " service data root {} (untouched)",
5928 root.display()
5929 )?;
5930 }
5931 write!(
5932 f,
5933 " restore move everything in the archive back into its parent \
5934 directory"
5935 )
5936 }
5937}
5938
5939fn same_directory(left: &Path, right: &Path) -> bool {
5942 let text = |path: &Path| {
5943 path.to_string_lossy()
5944 .trim_end_matches(['/', '\\'])
5945 .to_owned()
5946 };
5947 same_path_text(&text(left), &text(right))
5948 || matches!(
5949 (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5950 (Ok(left), Ok(right)) if left == right
5951 )
5952}
5953
5954const REMEDY_OPENINGS: [&str; 8] = [
5957 ". Run ",
5958 ". If nothing is waiting",
5959 ". Re-register",
5960 ". Switch the start mode",
5961 " Sign in again",
5962 ". Fix: ",
5963 "; to do it now",
5964 "; if the service manager denies",
5965];
5966
5967fn diagnosis_only(detail: &str) -> String {
5976 let cut = REMEDY_OPENINGS
5977 .iter()
5978 .filter_map(|opening| detail.find(opening))
5979 .min()
5980 .unwrap_or(detail.len());
5981 let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5982 let names_a_change = |text: &str| {
5983 [
5984 "service install",
5985 "service uninstall",
5986 "auth login",
5987 "launchctl",
5988 "systemctl",
5989 ]
5990 .iter()
5991 .any(|command| text.contains(command))
5992 };
5993 if !names_a_change(diagnosis) {
5994 return diagnosis.to_owned();
5995 }
5996 let end = [", ", "; ", ". "]
5998 .iter()
5999 .filter_map(|separator| diagnosis.find(separator))
6000 .min()
6001 .unwrap_or(diagnosis.len());
6002 let first = &diagnosis[..end];
6003 if names_a_change(first) {
6004 "the installation's own records report this".to_owned()
6005 } else {
6006 first.to_owned()
6007 }
6008}
6009
6010fn path_key(path: &Path) -> Vec<String> {
6013 path.components()
6014 .map(|component| {
6015 let text = component.as_os_str().to_string_lossy();
6016 if cfg!(windows) {
6017 text.to_lowercase()
6018 } else {
6019 text.into_owned()
6020 }
6021 })
6022 .collect()
6023}
6024
6025fn contains_directory(outer: &Path, inner: &Path) -> bool {
6028 path_key(inner).starts_with(&path_key(outer))
6029 || matches!(
6030 (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
6031 (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
6032 )
6033}
6034
6035#[must_use]
6040pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
6041 let target = path_key(root);
6042 if cfg!(windows) {
6043 if target
6044 .windows(3)
6045 .any(|run| run == ["system32", "config", "systemprofile"])
6046 {
6047 return Some("LocalSystem".to_owned());
6048 }
6049 } else if [
6050 "/root",
6051 "/var/root",
6052 "/private/var/root",
6053 crate::paths::SYSTEM_DATA_ROOT,
6054 ]
6055 .iter()
6056 .any(|home| root.starts_with(home))
6057 {
6058 return Some("root".to_owned());
6059 }
6060 let users = path_key(home?.parent()?);
6061 if users.len() < 2 {
6064 return None;
6065 }
6066 if target.len() > users.len() && target[..users.len()] == users[..] {
6067 root.components()
6068 .nth(users.len())
6069 .map(|name| name.as_os_str().to_string_lossy().into_owned())
6070 } else {
6071 None
6072 }
6073}
6074
6075#[derive(Debug, Clone, PartialEq, Eq)]
6089pub struct ForeignService {
6090 pub directories: ServiceDirectories,
6092 pub root: PathBuf,
6094 pub owner: Option<String>,
6096 pub this_account: Option<String>,
6101 pub local_root: PathBuf,
6103 pub local_record: PathBuf,
6105 pub unused_local_record: Option<InstallRecord>,
6107 pub unreadable: Option<String>,
6110}
6111
6112impl ForeignService {
6113 #[must_use]
6115 pub fn where_to_look(&self) -> String {
6116 if let Some(reach) = &self.this_account {
6117 return reach.clone();
6118 }
6119 let elevated = if cfg!(windows) {
6120 "from an elevated terminal (Run as administrator)"
6121 } else {
6122 "with sudo"
6123 };
6124 match &self.owner {
6125 Some(owner) if owner != "LocalSystem" && owner != "root" => {
6126 format!("{elevated}, or signed in as {owner}")
6127 }
6128 _ => elevated.to_owned(),
6129 }
6130 }
6131
6132 #[must_use]
6137 pub fn act_as(&self) -> String {
6138 if let Some(reach) = &self.this_account {
6139 return reach.clone();
6140 }
6141 match &self.owner {
6142 Some(owner) if owner != "LocalSystem" && owner != "root" => {
6143 format!("signed in as {owner}")
6144 }
6145 _ => format!(
6146 "from the account whose data root {} is",
6147 self.root.display()
6148 ),
6149 }
6150 }
6151
6152 #[must_use]
6155 pub const fn is_unread(&self) -> bool {
6156 self.unreadable.is_some()
6157 }
6158
6159 #[must_use]
6161 pub fn installed_by(&self) -> String {
6162 match &self.owner {
6163 Some(owner) => format!("{owner}'s data root {}", self.root.display()),
6164 None => format!("the data root {}", self.root.display()),
6165 }
6166 }
6167
6168 #[must_use]
6170 pub fn label(&self) -> String {
6171 match &self.owner {
6172 Some(owner) => format!("{} ({owner})", self.root.display()),
6173 None => self.root.display().to_string(),
6174 }
6175 }
6176
6177 #[must_use]
6179 pub fn unused_local_note(&self) -> Option<String> {
6180 let record = self.unused_local_record.as_ref()?;
6181 Some(format!(
6182 "unused local configuration from an earlier install: {} records a {} registration \
6183 made by runner-manager {} on {}, and no service on this machine runs against it. \
6184 `runner-manager host forget-local` moves it aside; it never touches the service's \
6185 own directories or any secret.",
6186 self.local_record.display(),
6187 record.start_mode,
6188 record.installed_by_version,
6189 record.installed_at.format("%Y-%m-%d"),
6190 ))
6191 }
6192}
6193
6194#[derive(Debug, Clone, PartialEq, Eq)]
6200pub struct StatusProblem {
6201 pub subject: &'static str,
6203 pub detail: String,
6205}
6206
6207impl fmt::Display for StatusProblem {
6208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6209 write!(f, "{}: {}", self.subject, self.detail)
6210 }
6211}
6212
6213#[derive(Debug, Clone)]
6222pub struct ServiceStatus {
6223 identity: ServiceIdentity,
6224 record: Option<InstallRecord>,
6225 registration: Option<Registration>,
6226 binary: Option<BinaryPath>,
6227 log_file: PathBuf,
6228 store: Option<crate::secrets::ActiveStore>,
6229 last_github_contact: Option<DateTime<Utc>>,
6230 runner_root: Option<(PathBuf, RootAccessReport)>,
6231 credential_rejected_since: Option<DateTime<Utc>>,
6232 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
6233 unattended_gap: Option<UnattendedGap>,
6234 foreign: Option<ForeignService>,
6235 problems: Vec<StatusProblem>,
6236 notes: Vec<String>,
6237}
6238
6239#[derive(Debug, Clone, PartialEq, Eq)]
6242pub struct UnattendedGap {
6243 pub reason: String,
6245 pub remedy: String,
6247}
6248
6249#[must_use]
6252pub fn service_account_name(account: &ServiceAccount) -> String {
6253 match account {
6254 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
6256 .ok()
6257 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
6258 .or_else(crate::unattended_login::current_account)
6259 .unwrap_or_else(|| "the operator".to_owned()),
6260 other => other.as_str().to_owned(),
6261 }
6262}
6263
6264fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
6267 registration
6268 .and_then(|registration| registration.account.as_deref())
6269 .unwrap_or(recorded)
6270}
6271
6272#[cfg(any(windows, test))]
6276fn windows_service_account(name: &str) -> String {
6277 if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
6278 ServiceAccount::LocalSystem.as_str().to_string()
6279 } else {
6280 name.to_string()
6281 }
6282}
6283
6284#[cfg(any(windows, test))]
6287fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
6288 user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
6289}
6290
6291#[must_use]
6300pub fn unattended_gap(
6301 mode: StartMode,
6302 account: &str,
6303 found: Option<&crate::unattended_login::UnattendedLogin>,
6304) -> Option<UnattendedGap> {
6305 let Some(found) = found else {
6306 return (mode == StartMode::Login).then(|| UnattendedGap {
6307 reason: "This registration starts at login, so the agent does not run until the \
6308 operator signs in; this host does not resume work after an unattended \
6309 reboot."
6310 .to_owned(),
6311 remedy: "For machine-on operation, move the service and credential to `--start-at \
6312 boot` from an elevated terminal."
6313 .to_owned(),
6314 });
6315 };
6316 let resume = crate::unattended_login::resume(found, mode, account);
6317 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6318 return None;
6321 }
6322 Some(UnattendedGap {
6323 reason: resume.explain(account)?,
6324 remedy: resume.remedy().unwrap_or_else(|| {
6325 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6326 FileVault can be read."
6327 .to_owned()
6328 }),
6329 })
6330}
6331
6332impl ServiceStatus {
6333 fn compose(
6338 identity: ServiceIdentity,
6339 record: Option<InstallRecord>,
6340 record_refused: Option<&ServiceError>,
6341 registration: Option<Registration>,
6342 last_github_contact: Option<DateTime<Utc>>,
6343 paths: &AppPaths,
6344 ) -> Self {
6345 let mut problems = Vec::new();
6346 let mut notes = Vec::new();
6347
6348 if let Some(refusal) = record_refused {
6349 problems.push(StatusProblem {
6354 subject: "install record",
6355 detail: refusal.to_string(),
6356 });
6357 }
6358
6359 let log_file = record.as_ref().map_or_else(
6360 || ServiceDirectories::of(paths).log_file(),
6361 |record| record.log_file.clone(),
6362 );
6363
6364 let binary = record.as_ref().map(|record| {
6365 inspect_binary(
6366 &record.binary,
6367 registration
6368 .as_ref()
6369 .and_then(Registration::binary)
6370 .as_deref(),
6371 )
6372 });
6373 if let Some(state) = &binary
6374 && state.is_error()
6375 {
6376 problems.push(StatusProblem {
6377 subject: "binary",
6378 detail: state.to_string(),
6379 });
6380 }
6381
6382 match (&record, ®istration) {
6383 (Some(_), None) => problems.push(StatusProblem {
6384 subject: "registration",
6385 detail: "this host has a service record but no service manager knows the \
6386 registration. Run `service install` again; it deletes nothing."
6387 .to_string(),
6388 }),
6389 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6394 subject: "record",
6395 detail: format!(
6396 "{} holds a registration for this service but there is no install record, so \
6397 the path it was installed from and the directories it was installed against \
6398 are unknown. Run `service uninstall` and `service install`.",
6399 found.manager
6400 ),
6401 }),
6402 (None, Some(_)) => {}
6403 (Some(record), Some(found)) => {
6404 if record.start_mode != found.start_mode {
6405 problems.push(StatusProblem {
6406 subject: "start mode",
6407 detail: format!(
6408 "the record says {} and {} holds a {} registration. Switch the start \
6409 mode again to make them agree.",
6410 record.start_mode, found.manager, found.start_mode
6411 ),
6412 });
6413 }
6414 if record.start_mode == StartMode::Boot && !found.starts_automatically {
6415 problems.push(StatusProblem {
6416 subject: "start mode",
6417 detail: format!(
6418 "{} holds the registration but will not start it by itself, so this \
6419 host does not resume work after a reboot.",
6420 found.manager
6421 ),
6422 });
6423 }
6424 if let Some(actual) = found.restart_delay {
6425 let expected = record.restart().effective_delay(found.manager);
6426 if actual != expected {
6427 problems.push(StatusProblem {
6428 subject: "restart policy",
6429 detail: format!(
6430 "the record says the service restarts after {}s and {} reports \
6431 {}s. Something has edited the registration since it was \
6432 installed.",
6433 expected.as_secs(),
6434 found.manager,
6435 actual.as_secs()
6436 ),
6437 });
6438 } else if expected != record.restart().delay() {
6439 notes.push(format!(
6444 "{} expresses the restart delay in whole minutes, so the {}s asked \
6445 for is enforced as {}s. The service therefore never restarts faster \
6446 than the configured bound.",
6447 found.manager,
6448 record.restart().delay().as_secs(),
6449 expected.as_secs()
6450 ));
6451 }
6452 }
6453 if found.starts_automatically && !found.running {
6454 problems.push(StatusProblem {
6455 subject: "runtime",
6456 detail: format!(
6457 "{} holds an automatic registration, but the daemon is stopped. Run \
6458 `runner-manager service install --start-at {}`; if the service \
6459 manager denies replacement, retry from an elevated terminal. If it \
6460 stops again, inspect {}.",
6461 found.manager,
6462 found.start_mode,
6463 log_file.display()
6464 ),
6465 });
6466 }
6467 }
6468 (None, None) => {}
6469 }
6470
6471 let store = record.as_ref().and_then(|record| {
6476 let registered_mode = registration
6477 .as_ref()
6478 .map_or(record.start_mode, |found| found.start_mode);
6479 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6480 .ok()
6481 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6482 });
6483 if let Some(store) = &store
6484 && !store.agrees_with_start_mode()
6485 {
6486 problems.push(StatusProblem {
6487 subject: "secret store",
6488 detail: format!(
6489 "{store}. Run `auth login` again so the token is stored where the registered \
6490 start mode can read it."
6491 ),
6492 });
6493 }
6494
6495 if last_github_contact.is_none() {
6496 notes.push(
6497 "GitHub has not been reached successfully since this host's state directory was \
6498 created."
6499 .to_string(),
6500 );
6501 }
6502
6503 let runner_root = crate::runner_root::default_runner_root(paths)
6510 .ok()
6511 .map(|root| {
6512 let path = root.as_path().to_path_buf();
6513 let report = crate::runner_root_access::report(&path);
6514 (path, report)
6515 });
6516 if let Some((
6526 path,
6527 RootAccessReport::Present {
6528 broad_write: true, ..
6529 },
6530 )) = &runner_root
6531 {
6532 notes.push(format!(
6533 "the platform default runner root {} can be written by ordinary local users, so \
6534 it is not a safe place to run jobs. `service install` refuses it rather than \
6535 tightening it, because the contents of a directory anybody could write cannot \
6536 be trusted: remove or empty it, or choose another root with `runner-manager \
6537 host set-runtime-root --path <PATH>`.",
6538 path.display()
6539 ));
6540 }
6541
6542 match runner_root_refusals(paths) {
6565 Ok(refusals) => {
6566 for refusal in refusals {
6567 notes.push(format!(
6568 "policy {} started no runner: its runner root {} refused the launch \
6569 ({}), last at {}. {} This clears when that policy next places a \
6570 runner.",
6571 refusal.policy,
6572 refusal.root,
6573 refusal.kind,
6574 refusal.at.to_rfc3339(),
6575 refusal.detail,
6576 ));
6577 }
6578 }
6579 Err(error) => notes.push(format!(
6580 "whether the agent could use its runner roots could not be read: {error}"
6581 )),
6582 }
6583
6584 Self {
6585 identity,
6586 record,
6587 registration,
6588 binary,
6589 log_file,
6590 store,
6591 last_github_contact,
6592 runner_root,
6593 credential_rejected_since: None,
6594 launches_blocked: None,
6595 unattended_gap: None,
6596 foreign: None,
6597 problems,
6598 notes,
6599 }
6600 }
6601
6602 fn with_foreign(mut self, foreign: ForeignService) -> Self {
6613 let act_as = foreign.act_as();
6614 for problem in &mut self.problems {
6615 problem.detail = format!(
6616 "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6617 own directories, not the service's.",
6618 diagnosis_only(&problem.detail)
6619 );
6620 }
6621 if let Some(note) = foreign.unused_local_note() {
6622 self.notes.push(note);
6623 }
6624 self.foreign = Some(foreign);
6625 self
6626 }
6627
6628 fn foreign_unread(
6636 identity: ServiceIdentity,
6637 registration: Registration,
6638 foreign: ForeignService,
6639 ) -> Self {
6640 let mut problems = Vec::new();
6641 let look = foreign.where_to_look();
6642 if registration.starts_automatically && !registration.running {
6643 problems.push(StatusProblem {
6644 subject: "runtime",
6645 detail: format!(
6646 "{} holds an automatic registration for the service installed from {}, but \
6647 it is stopped. Run `runner-manager service status` {look} to see why; do not \
6648 reinstall it from this account.",
6649 registration.manager,
6650 foreign.installed_by(),
6651 ),
6652 });
6653 }
6654 if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6655 problems.push(StatusProblem {
6656 subject: "start mode",
6657 detail: format!(
6658 "{} holds the boot registration installed from {} but will not start it by \
6659 itself. Run `runner-manager service status` {look}.",
6660 registration.manager,
6661 foreign.installed_by(),
6662 ),
6663 });
6664 }
6665 let mut notes = Vec::new();
6666 if let Some(reason) = &foreign.unreadable {
6667 notes.push(format!(
6668 "its health is not known here: {reason}. Run `runner-manager service status` \
6669 {look} to see it."
6670 ));
6671 }
6672 notes.extend(foreign.unused_local_note());
6673 Self {
6674 identity,
6675 record: None,
6676 log_file: foreign.directories.log_file(),
6677 registration: Some(registration),
6678 binary: None,
6679 store: None,
6680 last_github_contact: None,
6681 runner_root: None,
6682 credential_rejected_since: None,
6683 launches_blocked: None,
6684 unattended_gap: None,
6685 foreign: Some(foreign),
6686 problems,
6687 notes,
6688 }
6689 }
6690
6691 #[must_use]
6694 pub const fn foreign(&self) -> Option<&ForeignService> {
6695 self.foreign.as_ref()
6696 }
6697
6698 #[must_use]
6701 pub fn is_unread(&self) -> bool {
6702 self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6703 }
6704
6705 #[must_use]
6707 pub fn verdict(&self) -> String {
6708 if !self.is_healthy() {
6709 return "NOT healthy".to_owned();
6710 }
6711 match &self.foreign {
6712 Some(foreign) if foreign.is_unread() => format!(
6713 "{}; installed from {}, so its health is read {}",
6714 if self.is_running() {
6715 "running"
6716 } else {
6717 "not running"
6718 },
6719 foreign.installed_by(),
6720 foreign.where_to_look()
6721 ),
6722 _ => "healthy".to_owned(),
6723 }
6724 }
6725
6726 #[must_use]
6728 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6729 if let Some(drift) = drift {
6730 if drift.manager == DefinitionKind::LaunchdPlist {
6731 let restart = match drift.start_mode {
6735 StartMode::Boot => format!(
6736 "sudo launchctl kickstart -k system/{}",
6737 self.identity.launchd_label()
6738 ),
6739 StartMode::Login => format!(
6740 "launchctl kickstart -k gui/$(id -u)/{}",
6741 self.identity.launchd_label()
6742 ),
6743 };
6744 self.problems.push(StatusProblem {
6745 subject: "definition",
6746 detail: format!(
6747 "{} was written by runner-manager {} with a `ProcessType` other than \
6748 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6749 daemon starts, so their jobs run below normal priority. The daemon \
6750 rewrites it and reloads itself when it starts with no runner, which it \
6751 does after every upgrade; to do it now, restart the service while no \
6752 job is running: `{restart}`. Configuration and credentials are kept.",
6753 drift.path.display(),
6754 drift.installed_by_version,
6755 ),
6756 });
6757 return self;
6758 }
6759 let repair = match drift.start_mode {
6760 StartMode::Boot => format!(
6761 "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6762 `runner-manager wsl install --distribution <name>` from Windows)",
6763 sudo_command("service install --start-at boot")
6764 ),
6765 StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6766 service install --start-at login`"
6767 .to_owned(),
6768 };
6769 self.problems.push(StatusProblem {
6770 subject: "definition",
6771 detail: format!(
6772 "{} was written by runner-manager {} and differs from what {} renders, so \
6773 the daemon runs under outdated settings (for example a credential frozen \
6774 at service start). Re-register it with {repair}; configuration and \
6775 credentials are kept.",
6776 drift.path.display(),
6777 drift.installed_by_version,
6778 env!("CARGO_PKG_VERSION"),
6779 ),
6780 });
6781 }
6782 self
6783 }
6784
6785 #[must_use]
6787 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6788 if let Some(since) = since {
6789 self.credential_rejected_since = Some(since);
6790 self.problems.push(StatusProblem {
6791 subject: "github credential",
6792 detail: format!(
6793 "GitHub has rejected the service's credential since {}, so the service \
6794 cannot see queued jobs or start runners. Run `runner-manager auth login` \
6795 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6796 from Windows).",
6797 since.to_rfc3339()
6798 ),
6799 });
6800 }
6801 self
6802 }
6803
6804 #[must_use]
6806 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6807 self.credential_rejected_since
6808 }
6809
6810 #[must_use]
6813 pub fn with_launches_blocked(
6814 mut self,
6815 blocked: Option<crate::launch_health::LaunchesBlocked>,
6816 ) -> Self {
6817 if let Some(blocked) = &blocked {
6818 self.problems.push(StatusProblem {
6819 subject: LAUNCHES_BLOCKED_SUBJECT,
6820 detail: format!(
6821 "the service has started no runner {blocked}. Fix: {}.",
6822 blocked.remedy
6823 ),
6824 });
6825 }
6826 self.launches_blocked = blocked;
6827 self
6828 }
6829
6830 #[must_use]
6832 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6833 self.launches_blocked.as_ref()
6834 }
6835
6836 #[must_use]
6839 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6840 if let Some(since) = since {
6841 let mode = self.start_mode().unwrap_or_default();
6842 self.problems.push(StatusProblem {
6843 subject: CREDENTIAL_UNREADABLE_SUBJECT,
6844 detail: format!(
6845 "the service has been unable to read its stored GitHub credential since {}, \
6846 so it starts no runner; the diagnostic log has the store's own answer.{} \
6847 Sign in again: {}.",
6848 since.to_rfc3339(),
6849 if cfg!(target_os = "macos") {
6850 " That is what a keychain item written by a different build looks like."
6851 } else {
6852 ""
6853 },
6854 sign_in_instruction(mode)
6855 ),
6856 });
6857 }
6858 self
6859 }
6860
6861 #[must_use]
6869 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6870 self.runner_root
6871 .as_ref()
6872 .map(|(path, report)| (path.as_path(), report))
6873 }
6874
6875 #[must_use]
6877 pub const fn is_installed(&self) -> bool {
6878 self.registration.is_some() || self.record.is_some()
6879 }
6880
6881 #[must_use]
6883 pub fn is_running(&self) -> bool {
6884 self.registration
6885 .as_ref()
6886 .is_some_and(|registration| registration.running)
6887 }
6888
6889 #[must_use]
6891 pub fn is_healthy(&self) -> bool {
6892 self.problems.is_empty()
6893 }
6894
6895 #[must_use]
6897 pub fn problems(&self) -> &[StatusProblem] {
6898 &self.problems
6899 }
6900
6901 #[must_use]
6904 pub fn notes(&self) -> &[String] {
6905 &self.notes
6906 }
6907
6908 #[must_use]
6913 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6914 if let Some(unfit) = unfit {
6915 self.problems.push(StatusProblem {
6916 subject: HOST_UNFIT_SUBJECT,
6917 detail: format!(
6918 "the service has started no runner since {}, because a required host check \
6919 fails from its own session: {}",
6920 unfit.since.to_rfc3339(),
6921 unfit.describe()
6922 ),
6923 });
6924 }
6925 self
6926 }
6927
6928 #[must_use]
6932 pub fn with_stall(mut self, stall: Option<String>) -> Self {
6933 if let Some(detail) = stall {
6934 self.problems.push(StatusProblem {
6935 subject: DAEMON_STALLED_SUBJECT,
6936 detail,
6937 });
6938 }
6939 self
6940 }
6941
6942 #[must_use]
6945 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6946 if let Some(gap) = &gap {
6947 self.notes.push(gap.reason.clone());
6948 }
6949 self.unattended_gap = gap;
6950 self
6951 }
6952
6953 #[must_use]
6956 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6957 self.unattended_gap.as_ref()
6958 }
6959
6960 #[must_use]
6963 pub fn restart_command(&self) -> String {
6964 self.identity
6965 .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6966 }
6967
6968 #[must_use]
6972 pub fn start_mode(&self) -> Option<StartMode> {
6973 self.record
6974 .as_ref()
6975 .map(|record| record.start_mode)
6976 .or_else(|| {
6977 self.registration
6978 .as_ref()
6979 .filter(|_| self.is_unread())
6980 .map(|registration| registration.start_mode)
6981 })
6982 }
6983
6984 #[must_use]
6988 pub fn service_binary(&self) -> Option<PathBuf> {
6989 self.record
6990 .as_ref()
6991 .map(|record| record.binary.clone())
6992 .or_else(|| {
6993 self.registration
6994 .as_ref()
6995 .filter(|_| self.is_unread())
6996 .and_then(Registration::binary)
6997 })
6998 }
6999
7000 #[must_use]
7003 pub const fn binary(&self) -> Option<&BinaryPath> {
7004 self.binary.as_ref()
7005 }
7006
7007 #[must_use]
7009 pub fn log_file(&self) -> &Path {
7010 &self.log_file
7011 }
7012
7013 #[must_use]
7015 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
7016 self.last_github_contact
7017 }
7018
7019 #[must_use]
7022 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
7023 self.store.as_ref()
7024 }
7025
7026 #[must_use]
7028 pub const fn record(&self) -> Option<&InstallRecord> {
7029 self.record.as_ref()
7030 }
7031
7032 #[must_use]
7034 pub const fn registration(&self) -> Option<&Registration> {
7035 self.registration.as_ref()
7036 }
7037}
7038
7039impl fmt::Display for ServiceStatus {
7040 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7041 writeln!(f, "Service: {}", self.identity)?;
7042 match (&self.record, &self.registration) {
7043 (None, None) => {
7044 writeln!(
7045 f,
7046 " installed no. `service install` registers `{} {}`.",
7047 SERVICE_NAME,
7048 DAEMON_ARGUMENTS.join(" ")
7049 )?;
7050 }
7051 _ => {
7052 let manager = self
7053 .registration
7054 .as_ref()
7055 .map(|registration| registration.manager.manager());
7056 writeln!(
7057 f,
7058 " installed {}",
7059 manager.unwrap_or("yes, but no service manager knows it")
7060 )?;
7061 writeln!(
7062 f,
7063 " state {}",
7064 if self.is_running() {
7065 "running"
7066 } else {
7067 "not running"
7068 }
7069 )?;
7070 }
7071 }
7072 if let Some(foreign) = &self.foreign {
7073 writeln!(
7074 f,
7075 " installed from {} -- another installation's, not this account's ({})",
7076 foreign.installed_by(),
7077 foreign.local_root.display()
7078 )?;
7079 if foreign.is_unread()
7081 && let Some(registration) = &self.registration
7082 {
7083 writeln!(f, " start mode {}", registration.start_mode)?;
7084 if let Some(account) = ®istration.account {
7085 writeln!(f, " account {account}")?;
7086 }
7087 if let Some(binary) = self.service_binary() {
7088 writeln!(f, " binary {}", binary.display())?;
7089 }
7090 }
7091 }
7092 if let Some(record) = &self.record {
7093 writeln!(f, " start mode {}", record.start_mode)?;
7094 let recorded = record.account.to_string();
7095 let account = registered_account(self.registration.as_ref(), &recorded);
7096 writeln!(f, " account {account}")?;
7097 writeln!(f, " restart on failure {}", record.restart())?;
7098 writeln!(
7099 f,
7100 " arguments {}",
7101 record.arguments.join(" ")
7102 )?;
7103 }
7104 if let Some(binary) = &self.binary {
7105 writeln!(f, " binary {binary}")?;
7106 }
7107 writeln!(f, " diagnostic log {}", self.log_file.display())?;
7108 if let Some((path, report)) = &self.runner_root {
7109 writeln!(f, " default runner root {}", path.display())?;
7116 if *report != RootAccessReport::NotApplicable {
7117 writeln!(f, " default root access {report}")?;
7118 }
7119 }
7120 if let Some(store) = &self.store {
7121 writeln!(f, " secret store {store}")?;
7122 }
7123 if !self.is_unread() {
7126 writeln!(
7127 f,
7128 " last GitHub contact {}",
7129 match self.last_github_contact {
7130 Some(at) => at.to_rfc3339(),
7131 None => "never".to_string(),
7132 }
7133 )?;
7134 }
7135 for note in &self.notes {
7136 writeln!(f, " note {note}")?;
7137 }
7138 for problem in &self.problems {
7139 writeln!(f, " ERROR {problem}")?;
7140 }
7141 write!(f, " verdict {}", self.verdict())
7142 }
7143}
7144
7145#[derive(Debug, Clone, Default)]
7162pub struct RecordingControls {
7163 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7164}
7165
7166#[derive(Debug, Default)]
7167struct RecordingState {
7168 registrations: BTreeMap<(StartMode, String), Registration>,
7169 definitions: BTreeMap<String, ServiceDefinition>,
7170 calls: Vec<String>,
7171 #[cfg(test)]
7172 install_failures: BTreeMap<StartMode, String>,
7173 #[cfg(test)]
7174 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
7175}
7176
7177#[cfg(test)]
7178#[derive(Debug, Clone)]
7179enum TestInstallSideEffect {
7180 HideDirectory { directory: PathBuf, hidden: PathBuf },
7181}
7182
7183impl RecordingControls {
7184 #[must_use]
7186 pub fn new() -> Self {
7187 Self::default()
7188 }
7189
7190 #[must_use]
7193 pub fn calls(&self) -> Vec<String> {
7194 self.state.lock().expect("not poisoned").calls.clone()
7195 }
7196
7197 #[must_use]
7199 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
7200 self.state
7201 .lock()
7202 .expect("not poisoned")
7203 .registrations
7204 .iter()
7205 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
7206 .collect()
7207 }
7208
7209 #[must_use]
7211 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
7212 self.state
7213 .lock()
7214 .expect("not poisoned")
7215 .definitions
7216 .get(name)
7217 .cloned()
7218 }
7219
7220 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
7230 let mut state = self.state.lock().expect("not poisoned");
7231 if let Some((_, registration)) = state
7232 .registrations
7233 .iter_mut()
7234 .find(|((_, held), _)| held == name)
7235 {
7236 edit(registration);
7237 }
7238 }
7239
7240 #[cfg(test)]
7241 fn fail_next_install(&self, mode: StartMode, detail: &str) {
7242 self.state
7243 .lock()
7244 .expect("not poisoned")
7245 .install_failures
7246 .insert(mode, detail.to_string());
7247 }
7248
7249 #[cfg(test)]
7250 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
7251 self.state
7252 .lock()
7253 .expect("not poisoned")
7254 .after_install
7255 .insert(
7256 mode,
7257 TestInstallSideEffect::HideDirectory { directory, hidden },
7258 );
7259 }
7260}
7261
7262impl ControlFactory for RecordingControls {
7263 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7264 Ok(Box::new(RecordingControl {
7265 mode,
7266 state: std::sync::Arc::clone(&self.state),
7267 }))
7268 }
7269}
7270
7271#[derive(Debug)]
7272struct RecordingControl {
7273 mode: StartMode,
7274 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7275}
7276
7277impl RecordingControl {
7278 fn note(&self, operation: &str, name: &str) {
7279 self.state
7280 .lock()
7281 .expect("not poisoned")
7282 .calls
7283 .push(format!("{operation} {name} ({})", self.mode));
7284 }
7285}
7286
7287impl ServiceControl for RecordingControl {
7288 fn manager(&self) -> DefinitionKind {
7289 host_definition_kind(self.mode)
7293 }
7294
7295 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7296 self.note("install", plan.identity().name());
7297 #[cfg(test)]
7298 if let Some(detail) = self
7299 .state
7300 .lock()
7301 .expect("not poisoned")
7302 .install_failures
7303 .remove(&self.mode)
7304 {
7305 return Err(ServiceError::Control {
7306 operation: "install",
7307 name: plan.identity().name().to_string(),
7308 manager: "recording control",
7309 detail,
7310 });
7311 }
7312 let definition = ServiceDefinition::for_host(plan)?;
7317 let mut state = self.state.lock().expect("not poisoned");
7318 state.registrations.insert(
7319 (self.mode, plan.identity().name().to_string()),
7320 Registration {
7321 manager: host_definition_kind(self.mode),
7322 start_mode: self.mode,
7323 command_line: plan.command_line(),
7324 account: (*plan.account() != ServiceAccount::InvokingUser)
7325 .then(|| plan.account().as_str().to_string()),
7326 running: false,
7327 starts_automatically: true,
7328 restart_delay: Some(plan.restart().delay()),
7329 },
7330 );
7331 state
7332 .definitions
7333 .insert(plan.identity().name().to_string(), definition.clone());
7334 #[cfg(test)]
7335 let side_effect = state.after_install.remove(&self.mode);
7336 drop(state);
7337 #[cfg(test)]
7338 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7339 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7340 std::fs::write(&directory, b"blocks recreation")
7341 .expect("test fault can block record directory recreation");
7342 }
7343 Ok(definition)
7344 }
7345
7346 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7347 self.note("uninstall", identity.name());
7348 let mut state = self.state.lock().expect("not poisoned");
7349 state.definitions.remove(identity.name());
7350 Ok(state
7351 .registrations
7352 .remove(&(self.mode, identity.name().to_string()))
7353 .is_some())
7354 }
7355
7356 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7357 self.note("query", identity.name());
7358 Ok(self
7359 .state
7360 .lock()
7361 .expect("not poisoned")
7362 .registrations
7363 .get(&(self.mode, identity.name().to_string()))
7364 .cloned())
7365 }
7366
7367 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7368 self.note("start", identity.name());
7369 let mut state = self.state.lock().expect("not poisoned");
7370 match state
7371 .registrations
7372 .get_mut(&(self.mode, identity.name().to_string()))
7373 {
7374 Some(registration) => {
7375 registration.running = true;
7376 Ok(())
7377 }
7378 None => Err(ServiceError::NotInstalled {
7379 name: identity.name().to_string(),
7380 operation: "start",
7381 }),
7382 }
7383 }
7384
7385 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7386 self.note("stop", identity.name());
7387 let mut state = self.state.lock().expect("not poisoned");
7388 match state
7389 .registrations
7390 .get_mut(&(self.mode, identity.name().to_string()))
7391 {
7392 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7393 None => Err(ServiceError::NotInstalled {
7394 name: identity.name().to_string(),
7395 operation: "stop",
7396 }),
7397 }
7398 }
7399}
7400
7401impl ControlFactory for HostControls {
7402 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7403 sys::control(mode)
7404 }
7405
7406 fn installed_definition(&self, path: &Path) -> Option<String> {
7407 std::fs::read_to_string(path).ok()
7408 }
7409
7410 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7411 crate::unattended_login::probe()
7412 }
7413}
7414
7415#[must_use]
7422pub fn host_home() -> Option<PathBuf> {
7423 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7424}
7425
7426#[cfg(unix)]
7428fn home_directory() -> Option<PathBuf> {
7429 host_home()
7430}
7431
7432fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7441 let output = std::process::Command::new(program)
7442 .args(arguments)
7443 .output()?;
7444 Ok((
7445 output.status.success(),
7446 String::from_utf8_lossy(&output.stdout).into_owned(),
7447 String::from_utf8_lossy(&output.stderr).into_owned(),
7448 ))
7449}
7450
7451#[must_use]
7454pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7455 if cfg!(windows) {
7456 match mode {
7457 StartMode::Boot => DefinitionKind::WindowsService,
7458 StartMode::Login => DefinitionKind::WindowsScheduledTask,
7459 }
7460 } else if cfg!(target_os = "macos") {
7461 DefinitionKind::LaunchdPlist
7462 } else {
7463 DefinitionKind::SystemdUnit
7464 }
7465}
7466
7467#[cfg(any(target_os = "macos", test))]
7474fn enable_launchd_registration(
7475 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7476 domain: &str,
7477 service_target: &str,
7478 plist: &Path,
7479 name: &str,
7480 elevation_remedy: &'static str,
7481) -> Result<(), ServiceError> {
7482 let (enabled, cause) = launchctl(&[
7483 std::ffi::OsStr::new("enable"),
7484 std::ffi::OsStr::new(service_target),
7485 ]);
7486 if enabled {
7487 return Ok(());
7488 }
7489
7490 let (booted_out, bootout_detail) = launchctl(&[
7491 std::ffi::OsStr::new("bootout"),
7492 std::ffi::OsStr::new(service_target),
7493 ]);
7494 let removed = std::fs::remove_file(plist);
7495 if !booted_out || removed.is_err() {
7496 return Err(ServiceError::Rollback {
7497 operation: "enable launchd registration",
7498 name: name.to_string(),
7499 cause,
7500 rollback: format!(
7501 "launchctl bootout {domain}: {}; remove {}: {}",
7502 if booted_out {
7503 "succeeded".to_string()
7504 } else {
7505 bootout_detail
7506 },
7507 plist.display(),
7508 removed
7509 .err()
7510 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7511 ),
7512 });
7513 }
7514
7515 if cause.to_ascii_lowercase().contains("permission denied") {
7516 Err(ServiceError::NeedsElevation {
7517 operation: "enable",
7518 name: name.to_string(),
7519 detail: cause,
7520 remedy: elevation_remedy,
7521 })
7522 } else {
7523 Err(ServiceError::Control {
7524 operation: "enable",
7525 name: name.to_string(),
7526 manager: "launchd",
7527 detail: cause,
7528 })
7529 }
7530}
7531
7532#[derive(Debug, Clone)]
7541pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7542
7543impl ServiceShutdown {
7544 pub async fn wait(mut self) {
7546 if !*self.0.borrow() {
7547 let _ = self.0.changed().await;
7548 }
7549 }
7550}
7551
7552#[cfg(windows)]
7559pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7560where
7561 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7562{
7563 windows_host::run(Box::new(run))
7564}
7565
7566#[cfg(windows)]
7567mod windows_host {
7568 use std::ffi::OsString;
7569 use std::sync::{Arc, Mutex, OnceLock, mpsc};
7570 use std::time::Duration;
7571
7572 use windows_service::service::{
7573 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7574 ServiceType,
7575 };
7576 use windows_service::service_control_handler::{
7577 self, ServiceControlHandlerResult, ServiceStatusHandle,
7578 };
7579
7580 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7581
7582 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7583
7584 struct Invocation {
7585 run: Runner,
7586 result: mpsc::SyncSender<Result<u8, String>>,
7587 }
7588
7589 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7590
7591 windows_service::define_windows_service!(ffi_service_main, service_main);
7592
7593 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7594 let (result_tx, result_rx) = mpsc::sync_channel(1);
7595 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7596 let mut invocation = slot
7597 .lock()
7598 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7599 if invocation.is_some() {
7600 return Err(host_error(
7601 "prepare",
7602 "the service-host slot was already used",
7603 ));
7604 }
7605 *invocation = Some(Invocation {
7606 run,
7607 result: result_tx,
7608 });
7609 drop(invocation);
7610
7611 windows_service::service_dispatcher::start("", ffi_service_main)
7612 .map_err(|error| host_error("connect", &error.to_string()))?;
7613 result_rx
7614 .recv()
7615 .map_err(|error| host_error("finish", &error.to_string()))?
7616 .map_err(|detail| host_error("run", &detail))
7617 }
7618
7619 fn service_main(_arguments: Vec<OsString>) {
7620 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7621 return;
7622 };
7623 let result = run_service(invocation.run);
7624 let _ = invocation.result.send(result);
7625 }
7626
7627 fn run_service(run: Runner) -> Result<u8, String> {
7628 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7629 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7630 let handler_status = Arc::clone(&status);
7631 let handler = move |control| match control {
7632 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7633 ServiceControl::Stop | ServiceControl::Shutdown => {
7634 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7635 let _ = handle.set_service_status(service_status(
7636 ServiceState::StopPending,
7637 ServiceControlAccept::empty(),
7638 1,
7639 Duration::from_secs(300),
7640 0,
7641 ));
7642 }
7643 let _ = stop_tx.send(true);
7644 ServiceControlHandlerResult::NoError
7645 }
7646 _ => ServiceControlHandlerResult::NotImplemented,
7647 };
7648 let handle = service_control_handler::register("", handler)
7649 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7650 *status
7651 .lock()
7652 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7653 handle
7654 .set_service_status(service_status(
7655 ServiceState::Running,
7656 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7657 0,
7658 Duration::default(),
7659 0,
7660 ))
7661 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7662
7663 let exit = run(ServiceShutdown(stop_rx));
7664 handle
7665 .set_service_status(service_status(
7666 ServiceState::Stopped,
7667 ServiceControlAccept::empty(),
7668 0,
7669 Duration::default(),
7670 u32::from(exit),
7671 ))
7672 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7673 Ok(exit)
7674 }
7675
7676 fn service_status(
7677 state: ServiceState,
7678 accepted: ServiceControlAccept,
7679 checkpoint: u32,
7680 wait_hint: Duration,
7681 exit: u32,
7682 ) -> ServiceStatus {
7683 ServiceStatus {
7684 service_type: ServiceType::OWN_PROCESS,
7685 current_state: state,
7686 controls_accepted: accepted,
7687 exit_code: if exit == 0 {
7688 ServiceExitCode::Win32(0)
7689 } else {
7690 ServiceExitCode::ServiceSpecific(exit)
7691 },
7692 checkpoint,
7693 wait_hint,
7694 process_id: None,
7695 }
7696 }
7697
7698 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7699 ServiceError::Control {
7700 operation,
7701 name: SERVICE_NAME.to_string(),
7702 manager: "the Windows Service Control Manager",
7703 detail: detail.to_string(),
7704 }
7705 }
7706}
7707
7708#[cfg(windows)]
7709mod sys {
7710 use std::ffi::{OsStr, OsString};
7720 use std::time::{Duration, Instant};
7721
7722 use runner_manager_domain::model::StartMode;
7723 use windows_service::service::{
7724 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7725 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7726 ServiceState, ServiceType,
7727 };
7728 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7729
7730 use super::{
7731 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7732 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7733 };
7734
7735 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7738 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7742 const ACCESS_DENIED: i32 = 5;
7744 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7745 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7746
7747 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7748 choose \"Run as administrator\".";
7749
7750 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7751 Ok(match mode {
7752 StartMode::Boot => Box::new(ScmControl),
7753 StartMode::Login => Box::new(TaskControl),
7754 })
7755 }
7756
7757 #[derive(Debug)]
7760 struct ScmControl;
7761
7762 fn scm_error(
7766 operation: &'static str,
7767 name: &str,
7768 error: &windows_service::Error,
7769 ) -> ServiceError {
7770 let raw = match error {
7771 windows_service::Error::Winapi(io) => io.raw_os_error(),
7772 _ => None,
7773 };
7774 let detail = match error {
7775 windows_service::Error::Winapi(io) => io.to_string(),
7776 other => other.to_string(),
7777 };
7778 if raw == Some(ACCESS_DENIED) {
7779 return ServiceError::NeedsElevation {
7780 operation,
7781 name: name.to_string(),
7782 detail,
7783 remedy: ELEVATION_REMEDY,
7784 };
7785 }
7786 ServiceError::Control {
7787 operation,
7788 name: name.to_string(),
7789 manager: "the Windows Service Control Manager",
7790 detail,
7791 }
7792 }
7793
7794 fn open_manager(
7795 access: ServiceManagerAccess,
7796 operation: &'static str,
7797 name: &str,
7798 ) -> Result<ServiceManager, ServiceError> {
7799 ServiceManager::local_computer(None::<&OsStr>, access)
7800 .map_err(|error| scm_error(operation, name, &error))
7801 }
7802
7803 impl ServiceControl for ScmControl {
7804 fn manager(&self) -> DefinitionKind {
7805 DefinitionKind::WindowsService
7806 }
7807
7808 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7809 let spec = windows_service_spec(plan);
7810 let manager = open_manager(
7811 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7812 "install",
7813 &spec.name,
7814 )?;
7815 let info = ServiceInfo {
7816 name: OsString::from(&spec.name),
7817 display_name: OsString::from(&spec.display_name),
7818 service_type: ServiceType::OWN_PROCESS,
7820 start_type: if spec.automatic_start {
7821 ServiceStartType::AutoStart
7822 } else {
7823 ServiceStartType::OnDemand
7824 },
7825 error_control: ServiceErrorControl::Normal,
7826 executable_path: plan.binary().to_path_buf(),
7827 launch_arguments: plan.arguments().to_vec(),
7828 dependencies: Vec::new(),
7829 account_name: spec.account.as_ref().map(OsString::from),
7831 account_password: None,
7834 };
7835 let service = manager
7836 .create_service(
7837 &info,
7838 ServiceAccess::CHANGE_CONFIG
7839 | ServiceAccess::QUERY_CONFIG
7840 | ServiceAccess::QUERY_STATUS
7841 | ServiceAccess::START
7842 | ServiceAccess::STOP
7843 | ServiceAccess::DELETE,
7844 )
7845 .map_err(|error| scm_error("install", &spec.name, &error))?;
7846 service
7847 .set_description(&spec.description)
7848 .map_err(|error| scm_error("describe", &spec.name, &error))?;
7849 service
7850 .update_failure_actions(ServiceFailureActions {
7851 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7852 reboot_msg: None,
7853 command: None,
7854 actions: Some(vec![
7861 ServiceAction {
7862 action_type: ServiceActionType::Restart,
7863 delay: spec.restart.delay(),
7864 };
7865 3
7866 ]),
7867 })
7868 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7869 service
7872 .set_failure_actions_on_non_crash_failures(true)
7873 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7874 Ok(ServiceDefinition::windows_service(plan))
7875 }
7876
7877 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7878 let manager =
7879 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7880 let service = match manager.open_service(
7881 identity.name(),
7882 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7883 ) {
7884 Ok(service) => Some(service),
7885 Err(error) if is_missing(&error) => return Ok(false),
7886 Err(error) if is_marked_for_delete(&error) => None,
7891 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7892 };
7893 if let Some(service) = service {
7894 if let Ok(status) = service.query_status()
7898 && status.current_state != ServiceState::Stopped
7899 {
7900 let _ = service.stop();
7901 }
7902 match service.delete() {
7903 Ok(()) => {}
7904 Err(error) if is_marked_for_delete(&error) => {}
7908 Err(error) => {
7909 return Err(scm_error("uninstall", identity.name(), &error));
7910 }
7911 }
7912 drop(service);
7913 }
7914
7915 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7922 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7923 Ok(service) => {
7924 drop(service);
7925 Ok(false)
7926 }
7927 Err(error) if is_missing(&error) => Ok(true),
7928 Err(error) if is_marked_for_delete(&error) => Ok(false),
7929 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7930 }
7931 })?;
7932 if !absent {
7933 return Err(ServiceError::Control {
7934 operation: "verify uninstall of",
7935 name: identity.name().to_string(),
7936 manager: "the Windows Service Control Manager",
7937 detail: format!(
7938 "the registration was still visible {} seconds after DeleteService; \
7939 retry `service uninstall` from an elevated prompt",
7940 DELETE_TIMEOUT.as_secs()
7941 ),
7942 });
7943 }
7944 Ok(true)
7945 }
7946
7947 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7948 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7949 let service = match manager.open_service(
7950 identity.name(),
7951 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7952 ) {
7953 Ok(service) => service,
7954 Err(error) if is_missing(&error) => return Ok(None),
7955 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7956 };
7957 let config = service
7958 .query_config()
7959 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7960 let status = service
7961 .query_status()
7962 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7963 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7964 actions
7965 .actions
7966 .and_then(|actions| actions.into_iter().next())
7967 .filter(|action| action.action_type == ServiceActionType::Restart)
7968 .map(|action| action.delay)
7969 });
7970 Ok(Some(Registration {
7971 manager: DefinitionKind::WindowsService,
7972 start_mode: StartMode::Boot,
7973 command_line: config.executable_path.to_string_lossy().into_owned(),
7977 account: config
7978 .account_name
7979 .map(|account| super::windows_service_account(&account.to_string_lossy())),
7980 running: status.current_state == ServiceState::Running,
7981 starts_automatically: config.start_type == ServiceStartType::AutoStart,
7982 restart_delay,
7983 }))
7984 }
7985
7986 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7987 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7988 let service = manager
7989 .open_service(identity.name(), ServiceAccess::START)
7990 .map_err(|error| scm_error("start", identity.name(), &error))?;
7991 service
7992 .start::<&OsStr>(&[])
7993 .map_err(|error| scm_error("start", identity.name(), &error))
7994 }
7995
7996 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7997 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7998 let service = manager
7999 .open_service(
8000 identity.name(),
8001 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
8002 )
8003 .map_err(|error| scm_error("stop", identity.name(), &error))?;
8004 let status = service
8005 .query_status()
8006 .map_err(|error| scm_error("stop", identity.name(), &error))?;
8007 if status.current_state == ServiceState::Stopped {
8008 return Ok(false);
8009 }
8010 service
8011 .stop()
8012 .map_err(|error| scm_error("stop", identity.name(), &error))?;
8013 Ok(true)
8014 }
8015 }
8016
8017 fn is_missing(error: &windows_service::Error) -> bool {
8018 matches!(error, windows_service::Error::Winapi(io)
8019 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
8020 }
8021
8022 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
8023 matches!(error, windows_service::Error::Winapi(io)
8024 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
8025 }
8026
8027 pub(super) fn wait_until_scm_absent(
8028 timeout: Duration,
8029 poll_interval: Duration,
8030 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
8031 ) -> Result<bool, ServiceError> {
8032 let deadline = Instant::now() + timeout;
8033 loop {
8034 if probe_absent()? {
8035 return Ok(true);
8036 }
8037 if Instant::now() >= deadline {
8038 return Ok(false);
8039 }
8040 std::thread::sleep(poll_interval);
8041 }
8042 }
8043
8044 #[derive(Debug)]
8047 struct TaskControl;
8048
8049 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
8050 if detail.to_ascii_lowercase().contains("access is denied") {
8051 return ServiceError::NeedsElevation {
8052 operation,
8053 name: name.to_string(),
8054 detail,
8055 remedy: ELEVATION_REMEDY,
8056 };
8057 }
8058 ServiceError::Control {
8059 operation,
8060 name: name.to_string(),
8061 manager: "Windows Task Scheduler",
8062 detail,
8063 }
8064 }
8065
8066 fn schtasks(
8067 operation: &'static str,
8068 name: &str,
8069 arguments: &[&OsStr],
8070 ) -> Result<(bool, String), ServiceError> {
8071 match run("schtasks.exe", arguments) {
8072 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
8073 Err(error) => Err(task_error(operation, name, error.to_string())),
8074 }
8075 }
8076
8077 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
8080 let mut bytes = vec![0xFF, 0xFE];
8081 for unit in text.encode_utf16() {
8082 bytes.extend_from_slice(&unit.to_le_bytes());
8083 }
8084 std::fs::write(path, bytes)
8085 }
8086
8087 impl ServiceControl for TaskControl {
8088 fn manager(&self) -> DefinitionKind {
8089 DefinitionKind::WindowsScheduledTask
8090 }
8091
8092 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8093 let name = plan.identity().name().to_string();
8094 let principal = TaskPrincipal::current()?;
8095 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
8096 let directory = tempfile::tempdir()
8097 .map_err(|error| task_error("install", &name, error.to_string()))?;
8098 let document = directory.path().join("task.xml");
8099 write_utf16(&document, definition.text())
8100 .map_err(|error| task_error("install", &name, error.to_string()))?;
8101 let (ok, message) = schtasks(
8102 "install",
8103 &name,
8104 &[
8105 OsStr::new("/Create"),
8106 OsStr::new("/TN"),
8107 OsStr::new(&name),
8108 OsStr::new("/XML"),
8109 document.as_os_str(),
8110 ],
8111 )?;
8112 if !ok {
8113 return Err(task_error("install", &name, message));
8114 }
8115 Ok(definition)
8116 }
8117
8118 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8119 if self.query(identity)?.is_none() {
8120 return Ok(false);
8121 }
8122 let name = identity.name().to_string();
8123 let (ok, message) = schtasks(
8124 "uninstall",
8125 &name,
8126 &[
8127 OsStr::new("/Delete"),
8128 OsStr::new("/TN"),
8129 OsStr::new(&name),
8130 OsStr::new("/F"),
8131 ],
8132 )?;
8133 if !ok {
8134 return Err(task_error("uninstall", &name, message));
8135 }
8136 Ok(true)
8137 }
8138
8139 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8140 let name = identity.name().to_string();
8141 let (ok, document) = schtasks(
8142 "inspect",
8143 &name,
8144 &[
8145 OsStr::new("/Query"),
8146 OsStr::new("/TN"),
8147 OsStr::new(&name),
8148 OsStr::new("/XML"),
8149 OsStr::new("ONE"),
8150 ],
8151 )?;
8152 if !ok {
8153 return Ok(None);
8159 }
8160 let command = xml_value(&document, "Command").unwrap_or_default();
8161 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
8162 let command_line = if arguments.is_empty() {
8163 super::quote_argument(&command)
8164 } else {
8165 format!("{} {arguments}", super::quote_argument(&command))
8166 };
8167 Ok(Some(Registration {
8168 manager: DefinitionKind::WindowsScheduledTask,
8169 start_mode: StartMode::Login,
8170 command_line,
8171 account: super::scheduled_task_account(xml_value(&document, "UserId")),
8172 running: task_is_running(&name),
8173 starts_automatically: super::windows_login_task_starts_automatically(&document),
8174 restart_delay: xml_value(&document, "Interval")
8175 .as_deref()
8176 .and_then(parse_iso8601),
8177 }))
8178 }
8179
8180 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8181 let name = identity.name().to_string();
8182 let (ok, message) = schtasks(
8183 "start",
8184 &name,
8185 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
8186 )?;
8187 if ok {
8188 Ok(())
8189 } else {
8190 Err(task_error("start", &name, message))
8191 }
8192 }
8193
8194 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8195 let running = self
8196 .query(identity)?
8197 .is_some_and(|registration| registration.running);
8198 if !running {
8199 return Ok(false);
8200 }
8201 let name = identity.name().to_string();
8202 let (ok, message) = schtasks(
8203 "stop",
8204 &name,
8205 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
8206 )?;
8207 if ok {
8208 Ok(true)
8209 } else {
8210 Err(task_error("stop", &name, message))
8211 }
8212 }
8213 }
8214
8215 fn task_is_running(name: &str) -> bool {
8229 let Ok((true, stdout, _)) = run(
8230 "schtasks.exe",
8231 &[
8232 OsStr::new("/Query"),
8233 OsStr::new("/TN"),
8234 OsStr::new(name),
8235 OsStr::new("/FO"),
8236 OsStr::new("CSV"),
8237 OsStr::new("/NH"),
8238 ],
8239 ) else {
8240 return false;
8241 };
8242 stdout
8243 .lines()
8244 .filter_map(|line| line.rsplit(',').next())
8245 .any(|status| {
8246 status
8247 .trim()
8248 .trim_matches('"')
8249 .eq_ignore_ascii_case("running")
8250 })
8251 }
8252
8253 fn parse_iso8601(value: &str) -> Option<Duration> {
8257 let rest = value.strip_prefix("PT")?;
8258 if let Some(minutes) = rest.strip_suffix('M') {
8259 return minutes
8260 .parse::<u64>()
8261 .ok()
8262 .map(|minutes| Duration::from_secs(minutes * 60));
8263 }
8264 rest.strip_suffix('S')?
8265 .parse::<u64>()
8266 .ok()
8267 .map(Duration::from_secs)
8268 }
8269}
8270
8271#[cfg(unix)]
8283fn write_definition(
8284 operation: &'static str,
8285 name: &str,
8286 path: &Path,
8287 text: &str,
8288 remedy: &'static str,
8289) -> Result<(), ServiceError> {
8290 if let Some(parent) = path.parent()
8291 && let Err(error) = std::fs::create_dir_all(parent)
8292 && error.kind() != std::io::ErrorKind::AlreadyExists
8293 {
8294 return Err(definition_error(operation, name, error, remedy, parent));
8295 }
8296 std::fs::write(path, text)
8297 .map_err(|error| definition_error(operation, name, error, remedy, path))
8298}
8299
8300#[cfg(all(unix, any(target_os = "macos", test)))]
8307fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
8308 use std::io::Write as _;
8309
8310 let path = drift.path.as_path();
8311 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
8312 let failed = |error: std::io::Error, at: &Path| {
8313 definition_error("rewrite", name, error, SUDO_REMEDY, at)
8314 };
8315 let mut staged =
8316 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8317 staged
8318 .write_all(drift.rendered.as_bytes())
8319 .and_then(|()| staged.as_file().sync_all())
8320 .map_err(|error| failed(error, staged.path()))?;
8321 std::fs::set_permissions(
8322 staged.path(),
8323 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8324 )
8325 .map_err(|error| failed(error, path))?;
8326 staged
8327 .persist(path)
8328 .map(drop)
8329 .map_err(|error| failed(error.error, path))
8330}
8331
8332#[cfg(unix)]
8333fn definition_error(
8334 operation: &'static str,
8335 name: &str,
8336 error: std::io::Error,
8337 remedy: &'static str,
8338 path: &Path,
8339) -> ServiceError {
8340 let detail = format!("{}: {error}", path.display());
8341 if error.kind() == std::io::ErrorKind::PermissionDenied {
8342 ServiceError::NeedsElevation {
8343 operation,
8344 name: name.to_string(),
8345 detail,
8346 remedy,
8347 }
8348 } else {
8349 ServiceError::Control {
8350 operation,
8351 name: name.to_string(),
8352 manager: "the local service manager",
8353 detail,
8354 }
8355 }
8356}
8357
8358#[cfg(unix)]
8360const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8361 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8362 agent not running until you sign in.";
8363
8364#[cfg(target_os = "macos")]
8369mod sys {
8370 use std::ffi::OsStr;
8383 use std::path::PathBuf;
8384 use std::time::Duration;
8385
8386 use runner_manager_domain::model::StartMode;
8387
8388 use super::{
8389 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8390 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8391 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8392 write_definition, xml_value,
8393 };
8394
8395 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8396 Ok(Box::new(LaunchdControl { mode }))
8397 }
8398
8399 #[derive(Debug)]
8400 struct LaunchdControl {
8401 mode: StartMode,
8402 }
8403
8404 impl LaunchdControl {
8405 fn domain(&self) -> String {
8407 match self.mode {
8408 StartMode::Boot => "system".to_string(),
8409 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8412 }
8413 }
8414
8415 fn service_target(&self, identity: &ServiceIdentity) -> String {
8416 format!("{}/{}", self.domain(), identity.launchd_label())
8417 }
8418
8419 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8422 let file = format!("{}.plist", identity.launchd_label());
8423 match self.mode {
8424 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8425 StartMode::Login => {
8426 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8427 }
8428 }
8429 }
8430
8431 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8432 ServiceError::Control {
8433 operation,
8434 name: name.to_string(),
8435 manager: "launchd",
8436 detail,
8437 }
8438 }
8439
8440 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8441 match run("launchctl", arguments) {
8442 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8443 Err(error) => (false, error.to_string()),
8444 }
8445 }
8446 }
8447
8448 impl ServiceControl for LaunchdControl {
8449 fn manager(&self) -> DefinitionKind {
8450 DefinitionKind::LaunchdPlist
8451 }
8452
8453 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8454 let name = plan.identity().name().to_string();
8455 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8456 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8457 return Err(self.failed(
8458 "install",
8459 &name,
8460 "this account has no home directory, so there is nowhere to put a \
8461 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8462 /Library/LaunchDaemons."
8463 .to_string(),
8464 ));
8465 };
8466 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8467 let target = self.domain();
8468 let (ok, message) = self.launchctl(&[
8469 OsStr::new("bootstrap"),
8470 OsStr::new(&target),
8471 path.as_os_str(),
8472 ]);
8473 if !ok {
8474 let _ = std::fs::remove_file(&path);
8478 if message.to_ascii_lowercase().contains("permission denied") {
8479 return Err(ServiceError::NeedsElevation {
8480 operation: "install",
8481 name,
8482 detail: message,
8483 remedy: SUDO_REMEDY,
8484 });
8485 }
8486 return Err(self.failed("install", &name, message));
8487 }
8488 let service_target = self.service_target(plan.identity());
8491 enable_launchd_registration(
8492 |arguments| self.launchctl(arguments),
8493 &target,
8494 &service_target,
8495 &path,
8496 &name,
8497 SUDO_REMEDY,
8498 )?;
8499 Ok(definition)
8500 }
8501
8502 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8503 let Some(path) = self.plist_path(identity) else {
8504 return Ok(false);
8505 };
8506 if !path.exists() {
8507 return Ok(false);
8508 }
8509 let target = self.service_target(identity);
8510 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8511 if !ok
8512 && !message.to_ascii_lowercase().contains("no such process")
8513 && !message.contains("113")
8514 {
8515 if message.to_ascii_lowercase().contains("permission denied") {
8516 return Err(ServiceError::NeedsElevation {
8517 operation: "uninstall",
8518 name: identity.name().to_string(),
8519 detail: message,
8520 remedy: SUDO_REMEDY,
8521 });
8522 }
8523 return Err(self.failed("uninstall", identity.name(), message));
8524 }
8525 std::fs::remove_file(&path).map_err(|error| {
8528 super::definition_error(
8529 "uninstall",
8530 identity.name(),
8531 error,
8532 SUDO_REMEDY,
8533 path.as_path(),
8534 )
8535 })?;
8536 Ok(true)
8537 }
8538
8539 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8540 let Some(path) = self.plist_path(identity) else {
8541 return Ok(None);
8542 };
8543 let Ok(document) = std::fs::read_to_string(&path) else {
8544 return Ok(None);
8545 };
8546 let target = self.service_target(identity);
8547 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8548 Ok(Some(Registration {
8549 manager: DefinitionKind::LaunchdPlist,
8550 start_mode: self.mode,
8551 command_line: program_arguments(&document),
8552 account: plist_string_value(&document, "UserName").or_else(|| {
8555 (self.mode == StartMode::Boot)
8556 .then(|| super::ServiceAccount::Root.as_str().to_string())
8557 }),
8558 running: loaded && printed.contains("state = running"),
8559 starts_automatically: document.contains("<key>RunAtLoad</key>")
8560 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8561 restart_delay: xml_value(
8562 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8563 "integer",
8564 )
8565 .and_then(|value| value.parse::<u64>().ok())
8566 .map(Duration::from_secs),
8567 }))
8568 }
8569
8570 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8571 let target = self.service_target(identity);
8572 let (ok, message) = self.launchctl(&[
8573 OsStr::new("kickstart"),
8574 OsStr::new("-k"),
8575 OsStr::new(&target),
8576 ]);
8577 if ok {
8578 Ok(())
8579 } else {
8580 Err(self.failed("start", identity.name(), message))
8581 }
8582 }
8583
8584 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8585 let running = self
8586 .query(identity)?
8587 .is_some_and(|registration| registration.running);
8588 if !running {
8589 return Ok(false);
8590 }
8591 let target = self.service_target(identity);
8592 let (ok, message) = self.launchctl(&[
8593 OsStr::new("kill"),
8594 OsStr::new("SIGTERM"),
8595 OsStr::new(&target),
8596 ]);
8597 if ok {
8598 Ok(true)
8599 } else {
8600 Err(self.failed("stop", identity.name(), message))
8601 }
8602 }
8603 }
8604
8605 pub(super) fn reload_detached(
8620 identity: &ServiceIdentity,
8621 mode: StartMode,
8622 plist: &std::path::Path,
8623 ) -> Result<(), ServiceError> {
8624 use std::os::unix::process::CommandExt;
8625
8626 const SCRIPT: &str = r#"(
8627 sleep 1
8628 /bin/launchctl bootout "$1"
8629 tries=0
8630 until /bin/launchctl bootstrap "$2" "$3"; do
8631 tries=$((tries + 1))
8632 if [ "$tries" -ge 60 ]; then
8633 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8634 exit 1
8635 fi
8636 sleep 1
8637 done
8638) </dev/null &"#;
8639
8640 let control = LaunchdControl { mode };
8641 let reinstall = match mode {
8644 StartMode::Boot => "sudo runner-manager service install --start-at boot",
8645 StartMode::Login => "runner-manager service install --start-at login",
8646 };
8647 let mut command = std::process::Command::new("/bin/sh");
8648 command
8649 .args(["-c", SCRIPT, "runner-manager-reload"])
8650 .arg(control.service_target(identity))
8651 .arg(control.domain())
8652 .arg(plist)
8653 .arg(reinstall)
8654 .stdin(std::process::Stdio::null());
8655 unsafe {
8658 command.pre_exec(|| {
8659 if libc::setsid() == -1 {
8660 return Err(std::io::Error::last_os_error());
8661 }
8662 Ok(())
8663 });
8664 }
8665 let status = command.status().map_err(|error| {
8666 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8667 })?;
8668 if status.success() {
8669 Ok(())
8670 } else {
8671 Err(control.failed(
8672 "reload",
8673 identity.name(),
8674 format!("the reload helper exited with {status}"),
8675 ))
8676 }
8677 }
8678
8679 fn program_arguments(document: &str) -> String {
8681 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8682 return String::new();
8683 };
8684 let Some(end) = rest.find("</array>") else {
8685 return String::new();
8686 };
8687 let mut out = Vec::new();
8688 let mut cursor = &rest[..end];
8689 while let Some(open) = cursor.find("<string>") {
8690 let after = &cursor[open + "<string>".len()..];
8691 let Some(close) = after.find("</string>") else {
8692 break;
8693 };
8694 out.push(quote_argument(&super::xml_unescape(&after[..close])));
8695 cursor = &after[close..];
8696 }
8697 out.join(" ")
8698 }
8699}
8700
8701#[cfg(all(unix, not(target_os = "macos")))]
8706mod sys {
8707 use std::ffi::OsStr;
8717 use std::path::PathBuf;
8718
8719 use runner_manager_domain::model::StartMode;
8720
8721 use super::{
8722 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8723 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8724 home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8725 write_definition,
8726 };
8727
8728 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8729 Ok(Box::new(SystemdControl { mode }))
8730 }
8731
8732 #[derive(Debug)]
8733 struct SystemdControl {
8734 mode: StartMode,
8735 }
8736
8737 impl SystemdControl {
8738 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8739 let file = identity.systemd_unit();
8740 match self.mode {
8741 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8742 StartMode::Login => {
8743 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8744 }
8745 }
8746 }
8747
8748 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8750 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8751 if self.mode == StartMode::Login {
8752 all.push(OsStr::new("--user"));
8753 }
8754 all.extend(arguments.iter().map(OsStr::new));
8755 match run("systemctl", &all) {
8756 Ok((ok, stdout, stderr)) => (
8757 ok,
8758 if stdout.trim().is_empty() {
8759 stderr
8760 } else {
8761 stdout
8762 },
8763 ),
8764 Err(error) => (false, error.to_string()),
8765 }
8766 }
8767
8768 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8769 ServiceError::Control {
8770 operation,
8771 name: name.to_string(),
8772 manager: "systemd",
8773 detail,
8774 }
8775 }
8776 }
8777
8778 impl ServiceControl for SystemdControl {
8779 fn manager(&self) -> DefinitionKind {
8780 DefinitionKind::SystemdUnit
8781 }
8782
8783 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8784 use std::os::unix::fs::MetadataExt as _;
8785
8786 let name = plan.identity().name().to_string();
8787 if self.mode == StartMode::Boot {
8788 let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8791 .iter()
8792 .filter_map(|path| {
8793 let path = std::path::Path::new(path);
8794 let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8795 super::boot_directory_refusal(path, owner)
8796 })
8797 .collect::<Vec<_>>();
8798 if !refusals.is_empty() {
8799 return Err(self.failed(
8800 "install",
8801 &name,
8802 format!(
8803 "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8804 directory there that root does not own is handed back with \
8805 `sudo chown -R root:root <directory>`",
8806 refusals.join("; "),
8807 super::sudo_command("service install --start-at boot"),
8808 crate::paths::SYSTEM_DATA_ROOT,
8809 crate::paths::SYSTEM_LOGS_DIR,
8810 ),
8811 ));
8812 }
8813 }
8814 if self.mode == StartMode::Boot
8818 && let Err(detail) = crate::runner_account::RunnerPool::ensure()
8819 {
8820 return Err(self.failed("install", &name, detail));
8821 }
8822 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8823 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8824 return Err(self.failed(
8825 "install",
8826 &name,
8827 "this account has no home directory, so there is nowhere to put a systemd \
8828 user unit. Use --start-at boot, which installs a system unit under \
8829 /etc/systemd/system."
8830 .to_string(),
8831 ));
8832 };
8833 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8834 let unit = plan.identity().systemd_unit();
8835 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8836 if !reloaded {
8837 let _ = std::fs::remove_file(&path);
8838 return Err(self.failed("install", &name, message));
8839 }
8840 let (enabled, message) = self.systemctl(&["enable", &unit]);
8841 if !enabled {
8842 let _ = std::fs::remove_file(&path);
8843 let _ = self.systemctl(&["daemon-reload"]);
8844 return Err(self.failed("install", &name, message));
8845 }
8846 Ok(definition)
8847 }
8848
8849 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8850 let Some(path) = self.unit_path(identity) else {
8851 return Ok(false);
8852 };
8853 if !path.exists() {
8854 return Ok(false);
8855 }
8856 let unit = identity.systemd_unit();
8857 let _ = self.systemctl(&["disable", "--now", &unit]);
8862 std::fs::remove_file(&path).map_err(|error| {
8863 super::definition_error(
8864 "uninstall",
8865 identity.name(),
8866 error,
8867 SUDO_REMEDY,
8868 path.as_path(),
8869 )
8870 })?;
8871 let _ = self.systemctl(&["daemon-reload"]);
8872 Ok(true)
8873 }
8874
8875 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8876 let Some(path) = self.unit_path(identity) else {
8877 return Ok(None);
8878 };
8879 if !path.exists() {
8880 return Ok(None);
8881 }
8882 let unit = identity.systemd_unit();
8883 let effective = match self.systemctl(&["cat", &unit]) {
8887 (true, text) => text,
8888 _ => match std::fs::read_to_string(&path) {
8889 Ok(document) => document,
8890 Err(_) => return Ok(None),
8891 },
8892 };
8893 let directives = ini_directives(&effective, "Service");
8894 let (_, shown) = self.systemctl(&[
8896 "show",
8897 "--property=ActiveState",
8898 "--property=UnitFileState",
8899 &unit,
8900 ]);
8901 let state = |key: &str| {
8902 shown
8903 .lines()
8904 .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8905 .map(str::trim)
8906 };
8907 Ok(Some(Registration {
8908 manager: DefinitionKind::SystemdUnit,
8909 start_mode: self.mode,
8910 command_line: directives
8911 .get("ExecStart")
8912 .map(|value| systemd_exec_command_line(value))
8913 .unwrap_or_default(),
8914 account: directives
8917 .get("User")
8918 .filter(|user| !user.is_empty())
8919 .cloned()
8920 .or_else(|| {
8921 (self.mode == StartMode::Boot)
8922 .then(|| super::ServiceAccount::Root.as_str().to_string())
8923 }),
8924 running: state("ActiveState") == Some("active"),
8925 starts_automatically: state("UnitFileState") == Some("enabled"),
8926 restart_delay: directives
8927 .get("RestartSec")
8928 .and_then(|value| systemd_timespan(value)),
8929 }))
8930 }
8931
8932 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8933 let unit = identity.systemd_unit();
8934 let (ok, message) = self.systemctl(&["start", &unit]);
8935 if ok {
8936 Ok(())
8937 } else {
8938 Err(self.failed("start", identity.name(), message))
8939 }
8940 }
8941
8942 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8943 let running = self
8944 .query(identity)?
8945 .is_some_and(|registration| registration.running);
8946 if !running {
8947 return Ok(false);
8948 }
8949 let unit = identity.systemd_unit();
8950 let (ok, message) = self.systemctl(&["stop", &unit]);
8951 if ok {
8952 Ok(true)
8953 } else {
8954 Err(self.failed("stop", identity.name(), message))
8955 }
8956 }
8957 }
8958}
8959
8960#[cfg(all(test, target_os = "macos"))]
8962pub(crate) mod launchd_fixture {
8963 use std::path::{Path, PathBuf};
8964
8965 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8969
8970 pub(crate) fn enabled() -> bool {
8973 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8974 if !enabled {
8975 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8976 }
8977 enabled
8978 }
8979
8980 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8988 let binary = directory.join("launch-agent-job");
8989 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8990 .expect("test binary copied");
8991 binary
8992 }
8993
8994 pub(crate) struct LoadedAgent(String);
8996
8997 impl LoadedAgent {
8998 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
9001 let domain = format!("gui/{}", unsafe { libc::getuid() });
9004 let loaded = Self(format!("{domain}/{label}"));
9006 let bootstrap = std::process::Command::new("launchctl")
9007 .arg("bootstrap")
9008 .arg(&domain)
9009 .arg(plist)
9010 .output()
9011 .expect("launchctl runs");
9012 assert!(
9013 bootstrap.status.success(),
9014 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
9015 String::from_utf8_lossy(&bootstrap.stderr)
9016 );
9017 loaded
9018 }
9019
9020 pub(crate) fn target(&self) -> &str {
9022 &self.0
9023 }
9024 }
9025
9026 impl Drop for LoadedAgent {
9027 fn drop(&mut self) {
9028 let _ = std::process::Command::new("launchctl")
9029 .args(["bootout", &self.0])
9030 .output();
9031 }
9032 }
9033}
9034
9035#[cfg(test)]
9036mod tests {
9037 use super::*;
9038
9039 use std::collections::BTreeMap;
9040
9041 #[test]
9042 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
9043 assert!(windows_login_task_starts_automatically(
9044 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
9045 ));
9046 assert!(windows_login_task_starts_automatically(
9047 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
9048 ));
9049 assert!(!windows_login_task_starts_automatically(
9050 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
9051 ));
9052 assert!(!windows_login_task_starts_automatically(
9053 "<Task><BootTrigger></BootTrigger></Task>"
9054 ));
9055 }
9056
9057 fn linux_plan(mode: StartMode) -> InstallPlan {
9064 InstallPlan::unchecked(
9065 ServiceIdentity::product(),
9066 mode,
9067 "/opt/runner-manager/bin/runner-manager",
9068 ServiceDirectories {
9069 config: PathBuf::from("/var/lib/runner-manager/config"),
9070 state: PathBuf::from("/var/lib/runner-manager/state"),
9071 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
9072 logs: PathBuf::from("/var/log/runner-manager"),
9073 },
9074 )
9075 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
9076 }
9077
9078 fn windows_plan(mode: StartMode) -> InstallPlan {
9079 InstallPlan::unchecked(
9080 ServiceIdentity::product(),
9081 mode,
9082 "C:\\Program Files\\runner-manager\\runner-manager.exe",
9083 ServiceDirectories {
9084 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
9085 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
9086 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
9087 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
9088 },
9089 )
9090 }
9091
9092 fn edited(text: &str, from: &str, to: &str) -> String {
9100 assert!(
9101 text.contains(from),
9102 "the rendered definition does not contain `{from}`, so this test would assert \
9103 nothing about a widened one"
9104 );
9105 text.replace(from, to)
9106 }
9107
9108 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
9110 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
9111 let Ok(entries) = std::fs::read_dir(directory) else {
9112 return;
9113 };
9114 for entry in entries.flatten() {
9115 let path = entry.path();
9116 if path.is_dir() {
9117 walk(&path, out);
9118 } else if let Ok(bytes) = std::fs::read(&path) {
9119 out.insert(path, bytes);
9120 }
9121 }
9122 }
9123 let mut out = BTreeMap::new();
9124 for root in roots {
9125 walk(root, &mut out);
9126 }
9127 out
9128 }
9129
9130 struct Host {
9131 _root: tempfile::TempDir,
9132 paths: AppPaths,
9133 binary: PathBuf,
9134 runner_root: LocalAbsolutePath,
9143 controls: RecordingControls,
9144 }
9145
9146 impl Host {
9147 fn new() -> Self {
9148 let root = tempfile::tempdir().expect("a temporary directory");
9149 let paths = AppPaths::rooted_at(root.path());
9150 paths.create_all().expect("the four directories");
9151 let binary = root.path().join(if cfg!(windows) {
9152 "runner-manager.exe"
9153 } else {
9154 "runner-manager"
9155 });
9156 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
9157 let runner_root = LocalAbsolutePath::new(
9158 root.path()
9159 .join("runner-root")
9160 .to_str()
9161 .expect("a unicode temporary path"),
9162 )
9163 .expect("a local absolute path");
9164 Self {
9165 _root: root,
9166 paths,
9167 binary,
9168 runner_root,
9169 controls: RecordingControls::new(),
9170 }
9171 }
9172
9173 fn operations(&self) -> ServiceOperations {
9174 ServiceOperations::with_controls(
9175 self.paths.clone(),
9176 ServiceIdentity::product(),
9177 std::sync::Arc::new(self.controls.clone()),
9178 )
9179 .with_runner_root(self.runner_root.clone())
9180 }
9181
9182 fn request(&self, mode: StartMode) -> InstallRequest {
9183 InstallRequest::new(mode).for_binary(&self.binary)
9184 }
9185 }
9186
9187 #[cfg(windows)]
9188 #[test]
9189 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
9190 let probes = std::cell::Cell::new(0);
9191 let absent =
9192 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
9193 let next = probes.get() + 1;
9194 probes.set(next);
9195 Ok(next == 3)
9196 })
9197 .expect("the simulated SCM probe succeeds");
9198
9199 assert!(absent);
9200 assert_eq!(
9201 probes.get(),
9202 3,
9203 "uninstall must recheck after transient presence instead of treating it as a leak"
9204 );
9205 }
9206
9207 #[cfg(windows)]
9208 #[test]
9209 fn windows_recognises_an_already_pending_service_deletion() {
9210 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
9211
9212 assert!(super::sys::is_marked_for_delete(&error));
9213 }
9214
9215 #[test]
9216 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
9217 let root = tempfile::tempdir().expect("a temporary directory");
9218 let plist = root.path().join("fixture.plist");
9219 std::fs::write(&plist, b"fixture").expect("a plist fixture");
9220 let calls = std::cell::RefCell::new(Vec::new());
9221
9222 let error = enable_launchd_registration(
9223 |arguments| {
9224 let call = arguments
9225 .iter()
9226 .map(|argument| argument.to_string_lossy().into_owned())
9227 .collect::<Vec<_>>();
9228 let operation = call[0].clone();
9229 calls.borrow_mut().push(call);
9230 if operation == "enable" {
9231 (false, "label remains disabled".to_string())
9232 } else {
9233 (true, String::new())
9234 }
9235 },
9236 "system",
9237 "system/com.openai.runner-manager-selftest",
9238 &plist,
9239 "runner-manager-selftest",
9240 "rerun with administrative rights",
9241 )
9242 .expect_err("enable failure must fail the install");
9243
9244 assert!(
9245 matches!(
9246 error,
9247 ServiceError::Control {
9248 operation: "enable",
9249 ..
9250 }
9251 ),
9252 "{error}"
9253 );
9254 assert_eq!(calls.borrow().len(), 2);
9255 assert_eq!(calls.borrow()[0][0], "enable");
9256 assert_eq!(calls.borrow()[1][0], "bootout");
9257 assert!(!plist.exists(), "rollback must remove the plist");
9258 }
9259
9260 #[test]
9265 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
9266 let plan = windows_plan(StartMode::Boot);
9267 let command_line = plan.command_line();
9268 assert!(
9269 command_line.starts_with('"'),
9270 "a path with a space must be quoted, got {command_line}"
9271 );
9272 assert_eq!(
9273 executable_from_command_line(&command_line).as_deref(),
9274 Some(plan.binary())
9275 );
9276 }
9277
9278 #[test]
9279 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
9280 let plan = linux_plan(StartMode::Boot);
9281 let command_line = plan.command_line();
9282 assert!(!command_line.starts_with('"'), "got {command_line}");
9283 assert_eq!(
9284 executable_from_command_line(&command_line).as_deref(),
9285 Some(plan.binary())
9286 );
9287 }
9288
9289 #[test]
9290 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
9291 let awkward = r#"C:\odd "name"\rm.exe"#;
9294 let quoted = quote_argument(awkward);
9295 assert_eq!(
9296 executable_from_command_line(&format!("{quoted} daemon run"))
9297 .as_deref()
9298 .map(Path::to_string_lossy)
9299 .as_deref(),
9300 Some(awkward)
9301 );
9302 }
9303
9304 #[test]
9305 fn an_empty_command_line_has_no_executable() {
9306 assert_eq!(executable_from_command_line(" "), None);
9307 assert_eq!(executable_from_command_line(""), None);
9308 }
9309
9310 #[test]
9311 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
9312 let awkward = r#"DOMAIN\R&D <team> "ops""#;
9313 assert_eq!(
9314 xml_escape(awkward),
9315 "DOMAIN\\R&D <team> "ops""
9316 );
9317 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
9318 }
9319
9320 #[test]
9325 fn a_restart_delay_under_the_floor_is_refused() {
9326 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9327 .expect_err("half a second is under the one-second floor");
9328 assert!(
9329 matches!(error, ServiceError::RestartDelay { .. }),
9330 "{error}"
9331 );
9332 }
9333
9334 #[test]
9335 fn a_restart_delay_over_the_ceiling_is_refused() {
9336 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9337 .expect_err("an hour is over the five-minute ceiling");
9338 assert!(
9339 matches!(error, ServiceError::RestartDelay { .. }),
9340 "{error}"
9341 );
9342 }
9343
9344 #[test]
9345 fn a_reset_window_no_longer_than_the_delay_is_refused() {
9346 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9347 .expect_err("a window equal to the delay can never elapse between restarts");
9348 assert!(
9349 matches!(error, ServiceError::RestartResetWindow { .. }),
9350 "{error}"
9351 );
9352 }
9353
9354 #[test]
9355 fn a_delay_inside_the_bound_is_accepted() {
9356 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9357 .expect("twenty seconds is inside the bound");
9358 assert_eq!(policy.delay(), Duration::from_secs(20));
9359 assert_eq!(policy.reset_after(), Duration::from_secs(300));
9360 }
9361
9362 #[test]
9367 fn a_fixture_identity_can_never_be_the_product_identity() {
9368 let fixture = ServiceIdentity::fixture("abc123");
9369 assert!(fixture.is_fixture());
9370 assert!(!ServiceIdentity::product().is_fixture());
9371 assert_ne!(fixture.name(), ServiceIdentity::product().name());
9372 assert_ne!(
9373 fixture.launchd_label(),
9374 ServiceIdentity::product().launchd_label()
9375 );
9376 assert_ne!(
9377 fixture.systemd_unit(),
9378 ServiceIdentity::product().systemd_unit()
9379 );
9380 }
9381
9382 #[test]
9383 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9384 let fixture = ServiceIdentity::fixture("A b/c:\\d");
9385 assert!(
9386 fixture
9387 .name()
9388 .chars()
9389 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9390 "got {}",
9391 fixture.name()
9392 );
9393 }
9394
9395 #[test]
9400 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9401 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9402 assert!(unit.contains("KillMode=process\n"), "{unit}");
9403 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9404 assert!(unit.contains("RestartSec=15\n"), "{unit}");
9405 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9406 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9407 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9408 }
9409
9410 #[test]
9411 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9412 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9413 assert!(
9414 !unit.contains("LoadCredential="),
9415 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9416 );
9417 }
9418
9419 #[test]
9420 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9421 let unit = systemd_unit(&linux_plan(StartMode::Login));
9422 assert!(
9423 !unit.contains("LoadCredential="),
9424 "a user unit must not name a root-owned credential file, got:\n{unit}"
9425 );
9426 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9427 }
9428
9429 #[test]
9430 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9431 let plan = linux_plan(StartMode::Boot);
9432 let unit = systemd_unit(&plan);
9433 let directives = ini_directives(&unit, "Service");
9434 let listed = split_quoted(
9435 directives
9436 .get("ReadWritePaths")
9437 .expect("the unit names its writable paths"),
9438 );
9439 assert_eq!(listed.len(), 5, "{listed:?}");
9440 for path in plan.directories().all() {
9441 assert!(
9442 listed.iter().any(|entry| entry == &path.to_string_lossy()),
9443 "{} is missing from {listed:?}",
9444 path.display()
9445 );
9446 }
9447 assert!(
9448 listed
9449 .iter()
9450 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9451 "atomic credential replacement needs its parent directory in {listed:?}"
9452 );
9453 }
9454
9455 #[test]
9456 fn the_unit_records_the_absolute_binary_path() {
9457 let plan = linux_plan(StartMode::Boot);
9458 let unit = systemd_unit(&plan);
9459 assert!(
9460 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9461 "{unit}"
9462 );
9463 }
9464
9465 #[test]
9470 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9471 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9472 assert!(
9475 plist.contains(
9476 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
9477 ),
9478 "{plist}"
9479 );
9480 assert!(
9481 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
9482 "{plist}"
9483 );
9484 }
9485
9486 #[test]
9487 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9488 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9489 assert_eq!(
9490 plist_string_value(&daemon, "UserName").as_deref(),
9491 Some("root")
9492 );
9493 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9494
9495 let agent = launchd_plist(&linux_plan(StartMode::Login));
9496 assert_eq!(
9497 plist_string_value(&agent, "UserName"),
9498 None,
9499 "a LaunchAgent already runs as the operator:\n{agent}"
9500 );
9501 }
9502
9503 #[test]
9504 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9505 for mode in [StartMode::Boot, StartMode::Login] {
9506 let plan = linux_plan(mode);
9507 let plist = launchd_plist(&plan);
9508 assert_eq!(
9509 plist_string_value(&plist, "ProcessType").as_deref(),
9510 Some("Interactive"),
9511 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9512 );
9513 let review = review_least_privilege(
9514 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9515 &plan,
9516 );
9517 assert!(
9518 review
9519 .controls()
9520 .iter()
9521 .any(|control| control == "ProcessType=Interactive"),
9522 "{review}"
9523 );
9524 assert!(
9525 !review
9526 .findings()
9527 .iter()
9528 .any(|finding| finding.subject == "ProcessType"),
9529 "{review}"
9530 );
9531 }
9532 }
9533
9534 #[test]
9538 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9539 let plan = linux_plan(StartMode::Login);
9540 let rendered = launchd_plist(&plan);
9541 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
9542 for (label, throttled) in [
9543 (
9544 "Background",
9545 edited(
9546 &rendered,
9547 interactive,
9548 "<key>ProcessType</key>\n <string>Background</string>\n",
9549 ),
9550 ),
9551 ("absent", edited(&rendered, interactive, "")),
9552 ] {
9553 let review = review_least_privilege(
9554 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9555 &plan,
9556 );
9557 assert!(review.is_least_privilege(), "{label}: {review}");
9558 assert!(
9559 review.findings().iter().any(|finding| {
9560 finding.kind == FindingKind::Shortfall
9561 && finding.subject == "ProcessType"
9562 && finding.detail.contains(label)
9563 }),
9564 "{label}: {review}"
9565 );
9566 }
9567 }
9568
9569 #[cfg(unix)]
9570 #[test]
9571 fn converging_a_unit_first_creates_the_directories_it_lets_the_service_write() {
9572 use std::os::unix::fs::PermissionsExt as _;
9573 let root = tempfile::tempdir().expect("dir");
9574 let config = root.path().join(".config").join("runner-manager");
9575 let state = root.path().join("state");
9576 std::fs::create_dir_all(&state).expect("state");
9577 let drift = DefinitionDrift {
9578 path: root.path().join("runner-manager.service"),
9579 installed_by_version: "0.4.44".into(),
9580 rendered: String::new(),
9581 start_mode: StartMode::Boot,
9582 manager: DefinitionKind::SystemdUnit,
9583 writable: vec![config.clone(), state.clone()],
9584 };
9585 drift.create_writable().expect("created");
9586 assert!(config.is_dir(), "the missing one is made");
9587 assert_eq!(
9588 std::fs::metadata(&config).unwrap().permissions().mode() & 0o777,
9589 0o700
9590 );
9591 drift.create_writable().expect("and again changes nothing");
9592
9593 std::fs::remove_dir(&state).expect("state removed");
9594 std::fs::write(&state, "").expect("a file in its place");
9595 assert!(
9596 drift.create_writable().is_err(),
9597 "a file is not a directory"
9598 );
9599 }
9600
9601 #[test]
9602 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9603 let host = Host::new();
9604 let identity = ServiceIdentity::product();
9605 let plan = InstallPlan::resolve(
9606 identity.clone(),
9607 &host.request(StartMode::Login),
9608 ServiceDirectories::of(&host.paths),
9609 )
9610 .expect("a resolvable plan");
9611 let installed = launchd_plist(&plan);
9612 let definition =
9613 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9614 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9615 record.installed_by_version = "0.4.31".into();
9616 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9617
9618 assert_eq!(
9619 launchd_definition_drift(&identity, &record, path.clone(), &installed),
9620 None
9621 );
9622 let edited_by_operator = edited(
9623 &installed,
9624 "</dict>\n</plist>\n",
9625 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
9626 );
9627 assert_eq!(
9628 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9629 None,
9630 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9631 );
9632
9633 let written_by_0_4_31 = edited(
9634 &installed,
9635 "<string>Interactive</string>",
9636 "<string>Background</string>",
9637 );
9638 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9639 .expect("a Background plist is drift");
9640 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9641 assert_eq!(drift.start_mode, StartMode::Login);
9642 assert_eq!(
9643 drift.rendered, installed,
9644 "the repair must write exactly what `service install` writes"
9645 );
9646
9647 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9648 .with_definition_drift(Some(drift));
9649 let problem = status
9650 .problems()
9651 .iter()
9652 .find(|problem| problem.subject == "definition")
9653 .expect("the drift is reported");
9654 assert!(
9657 problem.detail.contains("0.4.31")
9658 && problem.detail.contains(
9659 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9660 )
9661 && !problem.detail.contains("service install")
9662 && !problem.detail.contains("systemctl")
9663 && !problem.detail.contains("sudo"),
9664 "{}",
9665 problem.detail
9666 );
9667 }
9668
9669 #[cfg(unix)]
9670 #[test]
9671 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9672 use std::os::unix::fs::PermissionsExt as _;
9673
9674 let root = tempfile::tempdir().expect("a temporary directory");
9675 let path = root
9676 .path()
9677 .join("io.github.IvanMurzak.runner-manager.plist");
9678 std::fs::write(&path, "<string>Background</string>").expect("old plist");
9679 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9680 let drift = DefinitionDrift {
9681 path: path.clone(),
9682 installed_by_version: "0.4.31".into(),
9683 rendered: launchd_plist(&linux_plan(StartMode::Login)),
9684 start_mode: StartMode::Login,
9685 manager: DefinitionKind::LaunchdPlist,
9686 writable: Vec::new(),
9687 };
9688
9689 replace_definition("runner-manager", &drift).expect("replaced");
9690
9691 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9692 assert_eq!(
9693 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9694 0o644
9695 );
9696 assert_eq!(
9697 std::fs::read_dir(root.path()).unwrap().count(),
9698 1,
9699 "no staging file is left beside the plist"
9700 );
9701 }
9702
9703 #[cfg(target_os = "macos")]
9712 #[test]
9713 #[ignore = "run only by launchd, as the job of the detached-reload test"]
9714 fn detached_reload_job() {
9715 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9716 let tag = label
9717 .split_once("-selftest-")
9718 .map(|(_, tag)| tag.to_string())
9719 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9720 let identity = ServiceIdentity::fixture(&tag);
9721 assert_eq!(identity.launchd_label(), label);
9722 let path = std::env::current_dir()
9723 .expect("working directory")
9724 .join(format!("{label}.plist"));
9725 let installed = std::fs::read_to_string(&path).expect("own plist");
9726 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9727 let drift = DefinitionDrift {
9728 path: path.clone(),
9729 installed_by_version: "0.4.31".into(),
9730 rendered: installed.replace(
9731 "<string>Background</string>",
9732 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9733 ),
9734 start_mode: StartMode::Login,
9735 manager: DefinitionKind::LaunchdPlist,
9736 writable: Vec::new(),
9737 };
9738 replace_definition(identity.name(), &drift).expect("plist replaced");
9739 super::sys::reload_detached(&identity, StartMode::Login, &path)
9740 .expect("helper started");
9741 }
9742 std::thread::sleep(Duration::from_secs(120));
9743 }
9744
9745 #[cfg(target_os = "macos")]
9755 #[test]
9756 fn a_job_reloads_itself_from_its_rewritten_plist() {
9757 if !super::launchd_fixture::enabled() {
9758 return;
9759 }
9760 let root = tempfile::tempdir().expect("a temporary directory");
9761 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9762 let label = identity.launchd_label();
9763 let path = root.path().join(format!("{label}.plist"));
9764 let binary = super::launchd_fixture::job_binary(root.path());
9765 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9766 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9767 plist.push_str(&plist_string("Label", &label));
9768 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
9769 for argument in [
9770 binary.to_string_lossy().as_ref(),
9771 "--ignored",
9772 "--exact",
9773 "service::tests::detached_reload_job",
9774 "--test-threads=1",
9775 ] {
9776 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
9777 }
9778 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
9779 plist.push_str(&plist_string(
9780 "WorkingDirectory",
9781 &root.path().to_string_lossy(),
9782 ));
9783 plist.push_str(&plist_string("ProcessType", "Background"));
9784 plist.push_str("</dict>\n</plist>\n");
9785 std::fs::write(&path, plist).expect("plist written");
9786
9787 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9788 let spawn_type = || {
9789 let printed = std::process::Command::new("launchctl")
9790 .args(["print", loaded.target()])
9791 .output()
9792 .expect("launchctl runs");
9793 String::from_utf8_lossy(&printed.stdout)
9794 .lines()
9795 .find_map(|line| {
9796 line.trim()
9797 .strip_prefix("spawn type = ")
9798 .map(str::to_string)
9799 })
9800 };
9801 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9802
9803 let deadline = std::time::Instant::now() + Duration::from_secs(90);
9804 while spawn_type().as_deref() != Some("interactive (4)") {
9805 assert!(
9806 std::time::Instant::now() < deadline,
9807 "the job did not come back under its rewritten plist; launchd reports {:?}",
9808 spawn_type()
9809 );
9810 std::thread::sleep(Duration::from_millis(250));
9811 }
9812 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9813 assert_eq!(
9814 plist_string_value(&rewritten, "ProcessType").as_deref(),
9815 Some(LAUNCHD_PROCESS_TYPE)
9816 );
9817 }
9818
9819 #[test]
9820 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9821 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9822 assert!(
9823 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9824 "{plist}"
9825 );
9826 assert!(plist.contains("<string>daemon</string>"), "{plist}");
9827 assert!(plist.contains("<string>run</string>"), "{plist}");
9828 }
9829
9830 #[test]
9831 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9832 assert_eq!(
9833 ServiceIdentity::product().launchd_label(),
9834 "io.github.IvanMurzak.runner-manager"
9835 );
9836 }
9837
9838 #[test]
9843 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9844 let xml = windows_scheduled_task_xml(
9845 &windows_plan(StartMode::Login),
9846 &TaskPrincipal::named("HOST\\operator"),
9847 );
9848 assert!(xml.contains("<LogonTrigger>"), "{xml}");
9849 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9850 assert!(
9851 xml.contains("<LogonType>InteractiveToken</LogonType>"),
9852 "{xml}"
9853 );
9854 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9855 assert!(
9856 xml.contains("<Interval>PT1M</Interval>"),
9857 "Task Scheduler takes whole minutes only, and rejects the registration outright \
9858 for anything finer:\n{xml}"
9859 );
9860 }
9861
9862 #[test]
9863 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9864 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9869 let policy =
9870 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9871 .expect("inside the supported range");
9872 assert_eq!(
9873 policy
9874 .effective_delay(DefinitionKind::WindowsScheduledTask)
9875 .as_secs(),
9876 enforced,
9877 "a {asked}s delay must be enforced as {enforced}s"
9878 );
9879 assert!(
9880 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9881 "rounding must never shorten the bound"
9882 );
9883 }
9884 }
9885
9886 #[test]
9887 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9888 let policy = RestartPolicy::default();
9889 for kind in [
9890 DefinitionKind::WindowsService,
9891 DefinitionKind::LaunchdPlist,
9892 DefinitionKind::SystemdUnit,
9893 ] {
9894 assert_eq!(
9895 policy.effective_delay(kind),
9896 policy.delay(),
9897 "{kind:?} takes seconds and enforces exactly what it is given"
9898 );
9899 }
9900 }
9901
9902 #[test]
9903 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9904 let host = Host::new();
9905 let operations = host.operations();
9906 operations
9907 .install(&host.request(StartMode::Login))
9908 .expect("an install at login");
9909 host.controls.edit("runner-manager", |registration| {
9911 registration.manager = DefinitionKind::WindowsScheduledTask;
9912 registration.restart_delay = Some(Duration::from_secs(60));
9913 });
9914
9915 let status = operations.status().expect("a status");
9916 assert!(
9917 status.is_healthy(),
9918 "minute granularity is the manager's, not a mis-registration: {status}"
9919 );
9920 assert!(
9921 status
9922 .notes()
9923 .iter()
9924 .any(|note| note.contains("whole minutes")),
9925 "but the operator must be told why 15 became 60: {status}"
9926 );
9927
9928 host.controls.edit("runner-manager", |registration| {
9931 registration.restart_delay = Some(Duration::from_secs(1));
9932 });
9933 assert!(
9934 !operations.status().expect("a status").is_healthy(),
9935 "a one-second delay is not what any manager was asked for"
9936 );
9937 }
9938
9939 #[test]
9940 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9941 let plan = windows_plan(StartMode::Login);
9942 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9943 assert_eq!(
9944 xml_value(&xml, "Command").as_deref(),
9945 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9946 "{xml}"
9947 );
9948 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9949 }
9950
9951 #[test]
9952 fn an_account_name_holding_xml_punctuation_is_escaped() {
9953 let xml = windows_scheduled_task_xml(
9954 &windows_plan(StartMode::Login),
9955 &TaskPrincipal::named("R&D\\ops"),
9956 );
9957 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
9958 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9959 }
9960
9961 #[test]
9966 fn the_service_starts_automatically_under_the_account_the_store_admits() {
9967 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9968 let directives = ini_directives(&text, "windows-service");
9969 assert_eq!(
9970 directives.get("StartType").map(String::as_str),
9971 Some("AutoStart")
9972 );
9973 assert_eq!(
9974 directives.get("Account").map(String::as_str),
9975 Some("NT AUTHORITY\\SYSTEM")
9976 );
9977 assert_eq!(
9978 directives.get("ServiceType").map(String::as_str),
9979 Some("OWN_PROCESS")
9980 );
9981 assert_eq!(
9982 directives
9983 .get("FailureActionRestartDelaySecs")
9984 .map(String::as_str),
9985 Some("15")
9986 );
9987 assert_eq!(
9988 directives
9989 .get("FailureActionsOnNonCrashFailures")
9990 .map(String::as_str),
9991 Some("true"),
9992 "without this flag a non-zero exit is not a failure the manager restarts"
9993 );
9994 }
9995
9996 #[test]
9997 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9998 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9999 assert_eq!(spec.account, None);
10000 assert!(spec.automatic_start);
10001 assert!(
10002 spec.command_line.contains("daemon run"),
10003 "{}",
10004 spec.command_line
10005 );
10006 }
10007
10008 #[test]
10013 fn each_definition_goes_where_its_platform_expects_it() {
10014 let home = PathBuf::from("/home/op");
10015 assert_eq!(
10016 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
10017 Some(Path::new(
10018 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
10019 ))
10020 );
10021 assert_eq!(
10022 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
10023 Some(Path::new(
10024 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
10025 ))
10026 );
10027 assert_eq!(
10028 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
10029 Some(Path::new("/etc/systemd/system/runner-manager.service"))
10030 );
10031 assert_eq!(
10032 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
10033 Some(Path::new(
10034 "/home/op/.config/systemd/user/runner-manager.service"
10035 ))
10036 );
10037 assert_eq!(
10038 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
10039 None,
10040 "the Service Control Manager has no file"
10041 );
10042 }
10043
10044 #[test]
10045 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
10046 assert_eq!(
10047 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
10048 None
10049 );
10050 assert_eq!(
10051 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
10052 None
10053 );
10054 }
10055
10056 #[test]
10061 fn the_rendered_definitions_are_all_least_privilege() {
10062 let linux = linux_plan(StartMode::Boot);
10063 let windows = windows_plan(StartMode::Boot);
10064 for (definition, plan) in [
10065 (ServiceDefinition::systemd(&linux, None), &linux),
10066 (ServiceDefinition::launchd(&linux, None), &linux),
10067 (ServiceDefinition::windows_service(&windows), &windows),
10068 ] {
10069 let review = review_least_privilege(&definition, plan);
10070 assert!(
10071 review.is_least_privilege(),
10072 "{:?} should be least privilege, got:\n{review}",
10073 definition.kind()
10074 );
10075 assert!(
10076 !review.controls().is_empty(),
10077 "a review that confirms nothing proves nothing: {:?}",
10078 definition.kind()
10079 );
10080 }
10081 }
10082
10083 #[test]
10084 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
10085 let plan = windows_plan(StartMode::Login);
10086 let definition =
10087 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
10088 let review = review_least_privilege(&definition, &plan);
10089 assert!(review.is_least_privilege(), "{review}");
10090 assert!(
10091 review
10092 .controls()
10093 .iter()
10094 .any(|control| control.contains("LeastPrivilege")),
10095 "{review}"
10096 );
10097 }
10098
10099 #[test]
10100 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
10101 let plan = linux_plan(StartMode::Boot);
10102 let rendered = systemd_unit(&plan);
10103 let widened = edited(
10104 &rendered,
10105 "ReadWritePaths=/var/lib/runner-manager/config",
10106 "ReadWritePaths=/etc /var/lib/runner-manager/config",
10107 );
10108 let review = review_least_privilege(
10109 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10110 &plan,
10111 );
10112 assert!(!review.is_least_privilege(), "{review}");
10113 assert!(
10114 review
10115 .excesses()
10116 .iter()
10117 .any(|finding| finding.detail.contains("/etc")),
10118 "the review must name the directory it objects to: {review}"
10119 );
10120 }
10121
10122 #[test]
10123 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
10124 let plan = linux_plan(StartMode::Boot);
10125 let rendered = systemd_unit(&plan);
10126 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
10127 let review = review_least_privilege(
10128 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
10129 &plan,
10130 );
10131 assert!(!review.is_least_privilege(), "{review}");
10132 assert!(
10133 review
10134 .excesses()
10135 .iter()
10136 .any(|finding| finding.subject == "NoNewPrivileges"),
10137 "{review}"
10138 );
10139 }
10140
10141 fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
10143 review_least_privilege(
10144 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
10145 plan,
10146 )
10147 .findings()
10148 .iter()
10149 .any(|finding| finding.subject == subject && finding.kind == kind)
10150 }
10151
10152 #[test]
10153 fn protected_paths_are_inaccessible_and_the_review_keeps_them_so() {
10154 let plan = linux_plan(StartMode::Boot).with_protected_paths(vec![
10155 PathBuf::from("/srv/keys"),
10156 PathBuf::from("/data/my backups"),
10157 ]);
10158 let unit = systemd_unit(&plan);
10159 let directives = ini_directives(&unit, "Service");
10160 assert_eq!(
10161 split_quoted(&directives["InaccessiblePaths"]),
10162 ["-/srv/keys", "-/data/my backups"]
10163 );
10164 assert!(!reports(
10165 &unit,
10166 &plan,
10167 "InaccessiblePaths",
10168 FindingKind::Excess
10169 ));
10170 let dropped = unit.replace(
10171 &format!("InaccessiblePaths={}\n", directives["InaccessiblePaths"]),
10172 "",
10173 );
10174 assert!(reports(
10175 &dropped,
10176 &plan,
10177 "InaccessiblePaths",
10178 FindingKind::Excess
10179 ));
10180 assert!(
10181 !ini_directives(&systemd_unit(&linux_plan(StartMode::Boot)), "Service")
10182 .contains_key("InaccessiblePaths")
10183 );
10184 }
10185
10186 #[cfg(unix)]
10188 #[test]
10189 fn a_path_every_job_or_the_service_needs_cannot_be_protected() {
10190 let own = [PathBuf::from("/var/lib/runner-manager/config")];
10191 for refused in [
10192 "/",
10193 "/usr",
10194 "/etc",
10195 "/tmp",
10196 "relative",
10197 "/srv/../etc",
10198 "/var/lib",
10199 "/var/lib/runner-manager/config/x",
10200 ] {
10201 assert!(
10202 ProtectedPaths::refusal(Path::new(refused), &own).is_some(),
10203 "{refused}"
10204 );
10205 }
10206 for accepted in ["/srv/keys", "/home/ivan/.ssh", "/var/lib/postgresql"] {
10207 assert_eq!(
10208 ProtectedPaths::refusal(Path::new(accepted), &own),
10209 None,
10210 "{accepted}"
10211 );
10212 }
10213 let directory = tempfile::tempdir().unwrap();
10214 assert_eq!(
10215 ProtectedPaths::load(directory.path()).unwrap(),
10216 ProtectedPaths::default()
10217 );
10218 let list = ProtectedPaths {
10219 paths: vec![PathBuf::from("/srv/keys")],
10220 };
10221 list.save(directory.path()).unwrap();
10222 assert_eq!(ProtectedPaths::load(directory.path()).unwrap(), list);
10223 }
10224
10225 #[test]
10226 fn a_boot_unit_with_directories_under_root_binds_them_back_into_its_tmpfs_home() {
10227 let legacy = InstallPlan::unchecked(
10228 ServiceIdentity::product(),
10229 StartMode::Boot,
10230 "/root/.local/state/runner-manager/bin/runner-manager",
10231 ServiceDirectories {
10232 config: PathBuf::from("/root/.config/runner-manager"),
10233 state: PathBuf::from("/root/.local/state/runner-manager"),
10234 runtime: PathBuf::from("/root/.local/share/runner-manager/runtime"),
10235 logs: PathBuf::from("/root/.local/share/runner-manager/logs"),
10236 },
10237 )
10238 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token");
10239 let unit = systemd_unit(&legacy);
10240 let directives = ini_directives(&unit, "Service");
10241 assert_eq!(directives["ProtectHome"], "tmpfs");
10242 assert_eq!(
10243 directives["BindPaths"],
10244 "/root/.config/runner-manager /root/.local/state/runner-manager \
10245 /root/.local/share/runner-manager/runtime /root/.local/share/runner-manager/logs",
10246 "only the directories under a home tree, never the secrets outside it"
10247 );
10248 assert!(!reports(&unit, &legacy, "BindPaths", FindingKind::Excess));
10249 let widened = unit.replace("BindPaths=", "BindPaths=/root/.ssh /etc:/root/.config ");
10250 assert!(reports(&widened, &legacy, "BindPaths", FindingKind::Excess));
10251 }
10252
10253 #[test]
10254 fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
10255 for mode in [StartMode::Boot, StartMode::Login] {
10256 let unit = systemd_unit(&linux_plan(mode));
10257 assert!(
10258 !unit.contains("RestrictSUIDSGID"),
10259 "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
10260 );
10261 }
10262 }
10263
10264 #[test]
10265 fn only_a_boot_daemon_keeps_capabilities_and_boot_units_hide_home_trees() {
10266 let boot = systemd_unit(&linux_plan(StartMode::Boot));
10267 let directives = ini_directives(&boot, "Service");
10268 assert_eq!(directives["CapabilityBoundingSet"], BOOT_CAPABILITIES);
10269 assert_eq!(directives["AmbientCapabilities"], "");
10270 assert_eq!(directives["NoNewPrivileges"], "yes");
10271 assert_eq!(directives["ProtectHome"], "tmpfs");
10272 assert!(!directives.contains_key("BindPaths"));
10273
10274 let login = systemd_unit(&linux_plan(StartMode::Login));
10275 let directives = ini_directives(&login, "Service");
10276 assert_eq!(directives["CapabilityBoundingSet"], "");
10277 assert!(!directives.contains_key("ProtectHome"));
10278
10279 let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
10280 assert!(reports(
10281 &unprotected,
10282 &linux_plan(StartMode::Boot),
10283 "ProtectHome",
10284 FindingKind::Excess
10285 ));
10286 let boot_set = format!("CapabilityBoundingSet={BOOT_CAPABILITIES}\n");
10287 let inverted = boot.replace(&boot_set, "CapabilityBoundingSet=~CAP_SYS_ADMIN\n");
10288 assert!(reports(
10289 &inverted,
10290 &linux_plan(StartMode::Boot),
10291 "CapabilityBoundingSet",
10292 FindingKind::Excess
10293 ));
10294 let widened = boot.replace(
10295 &boot_set,
10296 &format!("CapabilityBoundingSet={BOOT_CAPABILITIES} CAP_SYS_ADMIN\n"),
10297 );
10298 assert!(reports(
10299 &widened,
10300 &linux_plan(StartMode::Boot),
10301 "CapabilityBoundingSet",
10302 FindingKind::Excess
10303 ));
10304 let narrowed = boot.replace(&boot_set, "CapabilityBoundingSet=CAP_SETUID\n");
10305 assert!(reports(
10306 &narrowed,
10307 &linux_plan(StartMode::Boot),
10308 "CapabilityBoundingSet",
10309 FindingKind::Shortfall
10310 ));
10311 }
10312
10313 #[test]
10314 fn status_reports_the_registered_account_and_falls_back_to_the_record() {
10315 let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
10316 for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
10317 let host = Host::new();
10318 let operations = host.operations();
10319 operations.install(&host.request(StartMode::Boot)).unwrap();
10320 host.controls.edit("runner-manager", |registration| {
10321 registration.account = registered.map(str::to_string);
10322 });
10323 let status = operations.status().unwrap().to_string();
10324 assert!(
10325 status.contains(&format!("account {shown}\n")),
10326 "{status}"
10327 );
10328 }
10329 }
10330
10331 #[cfg(target_os = "linux")]
10332 #[test]
10333 fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
10334 let host = Host::new();
10335 let operations = host.operations();
10336 operations.install(&host.request(StartMode::Boot)).unwrap();
10337 host.controls.edit("runner-manager", |registration| {
10338 registration.account = Some("ivan".to_string());
10339 });
10340 let refused = operations
10341 .install(&host.request(StartMode::Boot))
10342 .expect_err("a unit running as ivan cannot use root's tree");
10343 let message = refused.to_string();
10344 assert!(message.contains("run as ivan"), "{message}");
10345 assert!(message.contains("Remove `User=`"), "{message}");
10346 }
10347
10348 #[test]
10349 fn manager_account_spellings_match_the_record() {
10350 assert_eq!(
10351 windows_service_account("LocalSystem"),
10352 ServiceAccount::LocalSystem.as_str()
10353 );
10354 assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
10355 assert_eq!(
10356 scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
10357 None
10358 );
10359 assert_eq!(
10360 scheduled_task_account(Some("HOST\\op".into())),
10361 Some("HOST\\op".to_string())
10362 );
10363 }
10364
10365 #[test]
10366 fn exec_start_prefixes_are_not_part_of_the_binary() {
10367 for (value, expected) in [
10368 ("/opt/rm daemon run", "/opt/rm daemon run"),
10369 ("-/opt/rm daemon run", "/opt/rm daemon run"),
10370 ("+!/opt/rm daemon run", "/opt/rm daemon run"),
10371 ("!!/opt/rm daemon run", "/opt/rm daemon run"),
10372 ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
10373 ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
10374 ] {
10375 assert_eq!(systemd_exec_command_line(value), expected, "{value}");
10376 }
10377 assert_eq!(
10378 executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
10379 Some(PathBuf::from("/opt/rm"))
10380 );
10381 }
10382
10383 #[test]
10384 fn restart_sec_accepts_systemd_time_spans() {
10385 for (value, expected) in [
10386 ("15", Duration::from_secs(15)),
10387 ("15s", Duration::from_secs(15)),
10388 ("1min", Duration::from_secs(60)),
10389 ("1min 30s", Duration::from_secs(90)),
10390 ("1min30s", Duration::from_secs(90)),
10391 ("2h", Duration::from_secs(7_200)),
10392 ("500ms", Duration::from_millis(500)),
10393 ("1.5s", Duration::from_millis(1_500)),
10394 ] {
10395 assert_eq!(systemd_timespan(value), Some(expected), "{value}");
10396 }
10397 for value in ["", "infinity", "soon", "5 parsecs", "s"] {
10398 assert_eq!(systemd_timespan(value), None, "{value}");
10399 }
10400 }
10401
10402 #[cfg(unix)]
10403 #[test]
10404 fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
10405 let system = ServiceDirectories::of(&AppPaths::system());
10406 assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
10407 assert_eq!(
10408 data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
10409 Some("root")
10410 );
10411 }
10412
10413 #[test]
10414 fn a_boot_service_refuses_directories_it_could_not_open() {
10415 let var = Path::new("/var/lib/runner-manager/state");
10416 assert_eq!(boot_directory_refusal(var, Some(0)), None);
10417 let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
10418 assert!(refused.contains("uid 1000"), "{refused}");
10419 assert!(boot_directory_refusal(var, None).is_some());
10420 for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
10421 let refused =
10422 boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
10423 assert!(refused.contains("cannot see"), "{refused}");
10424 }
10425 }
10426
10427 #[cfg(unix)]
10428 #[test]
10429 fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
10430 use std::os::unix::fs::PermissionsExt as _;
10431
10432 let from = tempfile::tempdir().unwrap();
10433 let to = tempfile::tempdir().unwrap();
10434 let target = to.path().join("state");
10435 std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
10436 std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
10437 std::fs::write(from.path().join("secret"), b"x").unwrap();
10438 std::fs::set_permissions(
10439 from.path().join("secret"),
10440 std::fs::Permissions::from_mode(0o600),
10441 )
10442 .unwrap();
10443 std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
10444 std::fs::create_dir(from.path().join("bin")).unwrap();
10445
10446 copy_tree(from.path(), &target, Some("bin")).unwrap();
10447
10448 assert_eq!(
10449 std::fs::read(target.join("journal/2026/attempt")).unwrap(),
10450 b"entry"
10451 );
10452 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
10453 assert_eq!(mode(&target.join("secret")), 0o600);
10454 assert_eq!(mode(&target.join("journal")), 0o700);
10455 assert_eq!(
10456 std::fs::read_link(target.join("latest")).unwrap(),
10457 Path::new("journal")
10458 );
10459 assert!(!target.join("bin").exists());
10460
10461 std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
10463 set_aside(&target, "20261110T000000Z").unwrap();
10464 assert!(!target.exists());
10465 let aside = to.path().join("state.before-move-20261110T000000Z");
10466 assert_eq!(
10467 std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
10468 b"stale"
10469 );
10470 copy_tree(from.path(), &target, Some("bin")).unwrap();
10471 assert!(!target.join("stale.sqlite3-wal").exists());
10472 set_aside(&to.path().join("absent"), "x").unwrap();
10473 }
10474
10475 #[test]
10476 fn systemd_drop_ins_replace_base_service_directives() {
10477 let base = systemd_unit(&linux_plan(StartMode::Boot));
10478 let combined = format!(
10479 "# /etc/systemd/system/runner-manager.service\n{base}\n\
10480 # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10481 [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10482 ExecStart=/opt/runner-manager daemon run\n"
10483 );
10484 let directives = ini_directives(&combined, "Service");
10485 assert_eq!(directives["User"], "ivan");
10486 assert_eq!(directives["RestartSec"], "30");
10487 assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10488 assert_eq!(directives["ProtectSystem"], "strict");
10489 }
10490
10491 #[test]
10492 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10493 let plan = linux_plan(StartMode::Login);
10494 let rendered = systemd_unit(&plan);
10495 let widened = edited(
10496 &rendered,
10497 "CapabilityBoundingSet=\n",
10498 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10499 );
10500 let review = review_least_privilege(
10501 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10502 &plan,
10503 );
10504 assert!(!review.is_least_privilege(), "{review}");
10505 assert!(
10506 review
10507 .excesses()
10508 .iter()
10509 .any(|finding| finding.subject == "CapabilityBoundingSet"),
10510 "{review}"
10511 );
10512 }
10513
10514 #[test]
10515 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10516 let plan = linux_plan(StartMode::Boot);
10517 let rendered = systemd_unit(&plan);
10518 let widened = edited(
10519 &rendered,
10520 "[Install]",
10521 "ListenStream=127.0.0.1:9000\n\n[Install]",
10522 );
10523 let review = review_least_privilege(
10524 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10525 &plan,
10526 );
10527 assert!(
10528 !review.is_least_privilege(),
10529 "07-security.md rule 2 forbids any inbound surface: {review}"
10530 );
10531 }
10532
10533 #[test]
10534 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10535 let plan = linux_plan(StartMode::Boot);
10536 let rendered = systemd_unit(&plan);
10537 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10538 let review = review_least_privilege(
10539 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10540 &plan,
10541 );
10542 assert!(
10543 review.is_least_privilege(),
10544 "too little authority is not an excess: {review}"
10545 );
10546 assert!(
10547 review
10548 .findings()
10549 .iter()
10550 .any(|finding| finding.kind == FindingKind::Shortfall
10551 && finding.detail.contains("runtime")),
10552 "{review}"
10553 );
10554 }
10555
10556 #[test]
10557 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10558 let plan = linux_plan(StartMode::Login);
10559 let rendered = launchd_plist(&plan);
10560 let widened = edited(
10561 &rendered,
10562 "<key>ProcessType</key>",
10563 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
10564 );
10565 let review = review_least_privilege(
10566 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10567 &plan,
10568 );
10569 assert!(!review.is_least_privilege(), "{review}");
10570 assert!(
10571 review
10572 .excesses()
10573 .iter()
10574 .any(|finding| finding.subject == "UserName"),
10575 "{review}"
10576 );
10577 }
10578
10579 #[test]
10580 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10581 let plan = linux_plan(StartMode::Boot);
10582 let rendered = launchd_plist(&plan);
10583 let widened = edited(
10584 &rendered,
10585 "<key>SessionCreate</key>\n <false/>",
10586 "<key>SessionCreate</key>\n <true/>",
10587 );
10588 let review = review_least_privilege(
10589 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10590 &plan,
10591 );
10592 assert!(!review.is_least_privilege(), "{review}");
10593 assert!(
10594 review
10595 .excesses()
10596 .iter()
10597 .any(|finding| finding.subject == "SessionCreate"),
10598 "{review}"
10599 );
10600 }
10601
10602 #[test]
10603 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10604 let plan = linux_plan(StartMode::Boot);
10605 let rendered = launchd_plist(&plan);
10606 let widened = edited(
10607 &rendered,
10608 "<key>ProcessType</key>",
10609 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
10610 );
10611 let review = review_least_privilege(
10612 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10613 &plan,
10614 );
10615 assert!(!review.is_least_privilege(), "{review}");
10616 }
10617
10618 #[test]
10619 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10620 let plan = windows_plan(StartMode::Login);
10621 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10622 let widened = edited(
10623 &rendered,
10624 "<RunLevel>LeastPrivilege</RunLevel>",
10625 "<RunLevel>HighestAvailable</RunLevel>",
10626 );
10627 let review = review_least_privilege(
10628 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10629 &plan,
10630 );
10631 assert!(!review.is_least_privilege(), "{review}");
10632 assert!(
10633 review
10634 .excesses()
10635 .iter()
10636 .any(|finding| finding.subject == "RunLevel"),
10637 "{review}"
10638 );
10639 }
10640
10641 #[test]
10642 fn a_task_that_would_store_a_password_is_not_least_privilege() {
10643 let plan = windows_plan(StartMode::Login);
10644 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10645 let widened = edited(
10646 &rendered,
10647 "<LogonType>InteractiveToken</LogonType>",
10648 "<LogonType>Password</LogonType>",
10649 );
10650 let review = review_least_privilege(
10651 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10652 &plan,
10653 );
10654 assert!(!review.is_least_privilege(), "{review}");
10655 }
10656
10657 #[test]
10658 fn an_interactive_windows_service_is_not_least_privilege() {
10659 let plan = windows_plan(StartMode::Boot);
10660 let rendered = windows_service_descriptor(&plan);
10661 let widened = edited(
10662 &rendered,
10663 "ServiceType=OWN_PROCESS",
10664 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10665 );
10666 let review = review_least_privilege(
10667 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10668 &plan,
10669 );
10670 assert!(!review.is_least_privilege(), "{review}");
10671 assert!(
10672 review
10673 .excesses()
10674 .iter()
10675 .any(|finding| finding.subject == "ServiceType"),
10676 "{review}"
10677 );
10678 }
10679
10680 #[test]
10681 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10682 let plan = windows_plan(StartMode::Boot);
10683 let rendered = windows_service_descriptor(&plan);
10684 let changed = edited(
10685 &rendered,
10686 "Account=NT AUTHORITY\\SYSTEM",
10687 "Account=NT AUTHORITY\\LocalService",
10688 );
10689 let review = review_least_privilege(
10690 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10691 &plan,
10692 );
10693 assert!(review.is_least_privilege(), "{review}");
10699 assert!(
10700 review.findings().iter().any(|finding| {
10701 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10702 }),
10703 "{review}"
10704 );
10705 }
10706
10707 #[test]
10712 fn a_recorded_path_that_is_still_there_is_current() {
10713 let host = Host::new();
10714 let state = inspect_binary(&host.binary, Some(&host.binary));
10715 assert!(!state.is_error(), "{state}");
10716 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10717 }
10718
10719 #[test]
10720 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10721 let host = Host::new();
10722 let recorded = host.binary.clone();
10726 let healthy = inspect_binary(&recorded, Some(&recorded));
10727 assert!(
10728 !healthy.is_error(),
10729 "the discriminator: before the binary moves, this must be healthy"
10730 );
10731
10732 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10733
10734 let state = inspect_binary(&recorded, Some(&recorded));
10735 assert!(state.is_error(), "{state}");
10736 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10737 assert!(
10738 state.to_string().contains("npm"),
10739 "the message must name the cause an operator will not otherwise connect: {state}"
10740 );
10741 }
10742
10743 #[test]
10744 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10745 let root = tempfile::tempdir().expect("a temporary directory");
10746 let state = inspect_binary(root.path(), None);
10747 assert!(state.is_error(), "{state}");
10748 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10749 }
10750
10751 #[test]
10752 fn a_registration_naming_a_different_binary_is_a_divergence() {
10753 let host = Host::new();
10754 let other = host.binary.with_file_name("something-else");
10755 let state = inspect_binary(&host.binary, Some(&other));
10756 assert!(state.is_error(), "{state}");
10757 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10758 }
10759
10760 #[test]
10761 fn absence_is_reported_before_divergence() {
10762 let host = Host::new();
10763 let recorded = host.binary.clone();
10764 std::fs::remove_file(&recorded).expect("removable");
10765 let other = recorded.with_file_name("something-else");
10766 assert!(matches!(
10770 inspect_binary(&recorded, Some(&other)),
10771 BinaryPath::Missing { .. }
10772 ));
10773 }
10774
10775 #[test]
10780 fn the_record_round_trips_through_toml() {
10781 let host = Host::new();
10782 let plan = InstallPlan::resolve(
10783 ServiceIdentity::product(),
10784 &host.request(StartMode::Boot),
10785 ServiceDirectories::of(&host.paths),
10786 )
10787 .expect("a resolvable plan");
10788 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10789 let record = InstallRecord::of(&plan, &definition, Utc::now());
10790 record.write(&host.paths).expect("a writable record");
10791 let read = InstallRecord::read(&host.paths)
10792 .expect("a readable record")
10793 .expect("a record is there");
10794 assert_eq!(read, record);
10795 assert_eq!(read.binary, host.binary);
10796 assert!(read.binary.is_absolute());
10797 }
10798
10799 #[test]
10800 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10801 let host = Host::new();
10802 let identity = ServiceIdentity::product();
10803 let plan = InstallPlan::resolve(
10804 identity.clone(),
10805 &host.request(StartMode::Boot),
10806 ServiceDirectories::of(&host.paths),
10807 )
10808 .expect("a resolvable plan");
10809 let installed = systemd_unit(&plan);
10810 let definition =
10811 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10812 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10813 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10814
10815 assert_eq!(
10816 systemd_definition_drift(&identity, &record, path.clone(), &installed),
10817 None,
10818 "the record must rebuild exactly the unit `service install` wrote"
10819 );
10820
10821 record.installed_by_version = "0.4.7".into();
10822 let frozen = installed.replace(
10823 "[Service]\n",
10824 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10825 );
10826 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10827 .expect("an outdated unit is drift");
10828 assert_eq!(drift.installed_by_version, "0.4.7");
10829 assert_eq!(drift.rendered, installed);
10830
10831 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10832 .with_definition_drift(Some(drift));
10833 assert!(!status.is_healthy());
10834 assert!(
10835 status
10836 .problems()
10837 .iter()
10838 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10839 "{status}"
10840 );
10841 }
10842
10843 #[test]
10844 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10845 let host = Host::new();
10846 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10847 let first = Utc::now() - chrono::Duration::hours(9);
10848 record_github_credential_rejected(&host.paths, first).unwrap();
10849 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10850 let since = github_credential_rejected_since(&host.paths).unwrap();
10851 assert_eq!(since, Some(first));
10852
10853 let status = ServiceStatus::compose(
10854 ServiceIdentity::product(),
10855 None,
10856 None,
10857 None,
10858 None,
10859 &host.paths,
10860 )
10861 .with_credential_rejection(since);
10862 assert!(!status.is_healthy());
10863 assert_eq!(status.credential_rejected_since(), Some(first));
10864 assert!(status.to_string().contains("auth login"), "{status}");
10865
10866 clear_github_credential_rejection(&host.paths).unwrap();
10867 clear_github_credential_rejection(&host.paths).unwrap();
10868 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10869 }
10870
10871 #[test]
10873 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10874 use crate::launch_health::{
10875 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10876 record_launches_blocked,
10877 };
10878
10879 let host = Host::new();
10880 let compose = || {
10881 ServiceStatus::compose(
10882 ServiceIdentity::product(),
10883 None,
10884 None,
10885 None,
10886 None,
10887 &host.paths,
10888 )
10889 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10890 };
10891 assert!(
10892 !compose()
10893 .problems()
10894 .iter()
10895 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10896 );
10897 let blocked = BlockCause::FenceHeld {
10898 directory: PathBuf::from("launch-fence"),
10899 owner: None,
10900 }
10901 .blocked_since(Utc::now(), Viewpoint::Guest);
10902 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10903
10904 let status = compose();
10905 assert!(!status.is_healthy());
10906 assert_eq!(status.launches_blocked(), Some(&blocked));
10907 let problem = status
10908 .problems()
10909 .iter()
10910 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10911 .expect("reported under its own subject");
10912 assert!(problem.detail.contains("systemctl restart"), "{status}");
10913
10914 clear_launches_blocked(&host.paths).unwrap();
10915 assert_eq!(compose().launches_blocked(), None);
10916 }
10917
10918 #[test]
10921 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10922 let host = Host::new();
10923 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10924 let first = Utc::now() - chrono::Duration::minutes(40);
10925 record_credential_unreadable(&host.paths, first).unwrap();
10926 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10927 let since = credential_unreadable_since(&host.paths).unwrap();
10928 assert_eq!(since, Some(first), "a restart loop must not move the start");
10929
10930 let status = ServiceStatus::compose(
10931 ServiceIdentity::product(),
10932 None,
10933 None,
10934 None,
10935 None,
10936 &host.paths,
10937 )
10938 .with_credential_unreadable(since);
10939 assert!(!status.is_healthy());
10940 let problem = status
10941 .problems()
10942 .iter()
10943 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10944 .expect("reported under its own subject");
10945 assert!(
10947 problem
10948 .detail
10949 .contains(&sign_in_instruction(StartMode::default())),
10950 "{status}"
10951 );
10952
10953 clear_credential_unreadable(&host.paths).unwrap();
10954 clear_credential_unreadable(&host.paths).unwrap();
10955 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10956 let healthy = ServiceStatus::compose(
10957 ServiceIdentity::product(),
10958 None,
10959 None,
10960 None,
10961 None,
10962 &host.paths,
10963 )
10964 .with_credential_unreadable(None);
10965 assert!(
10966 !healthy
10967 .problems()
10968 .iter()
10969 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10970 "{healthy}"
10971 );
10972 }
10973
10974 #[test]
10975 fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10976 assert_eq!(sudo_program(None), "runner-manager");
10977 assert_eq!(
10978 sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10979 "runner-manager"
10980 );
10981 assert_eq!(
10982 sudo_program(Some(Path::new(
10983 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10984 runner-manager-linux-x64/bin/runner-manager"
10985 ))),
10986 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10987 runner-manager-linux-x64/bin/runner-manager"
10988 );
10989 assert_eq!(
10990 sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10991 "'/home/o'\\''p/my bin/runner-manager'"
10992 );
10993 assert_eq!(
10994 sudo_command("service status"),
10995 "sudo runner-manager service status"
10996 );
10997 }
10998
10999 #[test]
11000 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
11001 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
11002 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
11003 assert_eq!(
11004 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
11005 cfg!(unix),
11006 "a machine-scoped store needs root on Unix"
11007 );
11008 assert_eq!(
11009 sign_in_instruction(StartMode::Login).contains("SSH"),
11010 cfg!(target_os = "macos"),
11011 "only the macOS login keychain is out of an SSH session's reach"
11012 );
11013 }
11014
11015 #[cfg(unix)]
11024 #[test]
11025 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
11026 use std::os::unix::fs::PermissionsExt as _;
11027
11028 let host = Host::new();
11029 let plan = InstallPlan::resolve(
11030 ServiceIdentity::product(),
11031 &host.request(StartMode::Boot),
11032 ServiceDirectories::of(&host.paths),
11033 )
11034 .expect("a resolvable plan");
11035 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
11036 InstallRecord::of(&plan, &definition, Utc::now())
11037 .write(&host.paths)
11038 .expect("a writable record");
11039
11040 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
11041 .expect("the record is there")
11042 .permissions()
11043 .mode()
11044 & 0o777;
11045 assert_eq!(
11046 mode, 0o644,
11047 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
11048 service install` wrote, and `service status` fails on their own host. It holds no \
11049 credential and sits in a 0700 directory, so 0644 discloses nothing"
11050 );
11051 }
11052
11053 #[test]
11057 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
11058 let host = Host::new();
11059 let path = InstallRecord::path(&host.paths);
11060 std::fs::write(
11061 &path,
11062 format!(
11063 "schema_version = {RECORD_SCHEMA_VERSION}
11064service_name = \"runner-manager\"
11065 manager = \"systemd\"
11066start_mode = \"boot\"
11067account = \"root\"
11068 binary = \"/x\"
11069arguments = []
11070restart_delay_secs = 15
11071 restart_reset_secs = 600
11072log_file = \"/x\"
11073 installed_at = \"2026-01-01T00:00:00Z\"
11074installed_by_version = \"0.1.0\"
11075 [directories]
11076config = \"/a\"
11077state = \"/b\"
11078runtime = \"/c\"
11079logs = \"/d\"
11080"
11081 ),
11082 )
11083 .expect("a writable record");
11084 let read = InstallRecord::read(&host.paths)
11085 .expect("a record missing an optional field is still readable")
11086 .expect("a record is there");
11087 assert_eq!(
11088 read.source_binary, None,
11089 "the legacy layout has no source, and must not invent one"
11090 );
11091 }
11092
11093 #[test]
11095 fn a_registration_remembers_the_file_it_was_copied_from() {
11096 let host = Host::new();
11097 let source = host.binary.with_file_name("npm-installed-runner-manager");
11098 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
11099 let plan = InstallPlan::resolve(
11100 ServiceIdentity::product(),
11101 &host.request(StartMode::Boot).copied_from(&source),
11102 ServiceDirectories::of(&host.paths),
11103 )
11104 .expect("a resolvable plan");
11105 let definition = ServiceDefinition::from_text(
11106 DefinitionKind::SystemdUnit,
11107 "[Service]
11108",
11109 );
11110 let record = InstallRecord::of(&plan, &definition, Utc::now());
11111 record.write(&host.paths).expect("a writable record");
11112
11113 let read = InstallRecord::read(&host.paths)
11114 .expect("a readable record")
11115 .expect("a record is there");
11116 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
11117 assert_ne!(
11118 read.source_binary.as_deref(),
11119 Some(read.binary.as_path()),
11120 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
11121 );
11122 }
11123
11124 #[test]
11125 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
11126 let host = Host::new();
11127 let path = InstallRecord::path(&host.paths);
11128 std::fs::write(
11129 &path,
11130 format!(
11131 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
11132 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
11133 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
11134 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
11135 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
11136 RECORD_SCHEMA_VERSION + 1
11137 ),
11138 )
11139 .expect("a writable record");
11140 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
11141 assert!(
11142 matches!(error, ServiceError::RecordUnreadable { .. }),
11143 "{error}"
11144 );
11145 assert!(
11146 error.to_string().contains("service uninstall"),
11147 "the message must say how to recover: {error}"
11148 );
11149 }
11150
11151 #[test]
11152 fn no_record_is_not_an_error() {
11153 let host = Host::new();
11154 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
11155 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
11156 }
11157
11158 #[test]
11163 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
11164 let host = Host::new();
11165 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
11166 }
11167
11168 #[test]
11169 fn the_heartbeat_round_trips_to_the_second() {
11170 let host = Host::new();
11171 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
11172 .expect("a valid timestamp")
11173 .with_timezone(&Utc);
11174 record_github_contact(&host.paths, at).expect("a writable heartbeat");
11175 assert_eq!(
11176 last_github_contact(&host.paths).expect("readable"),
11177 Some(at)
11178 );
11179 }
11180
11181 #[test]
11182 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
11183 let host = Host::new();
11184 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
11185 let error = last_github_contact(&host.paths)
11186 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
11187 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11188 }
11189
11190 #[test]
11196 fn a_runner_root_refusal_round_trips_for_service_status() {
11197 let host = Host::new();
11198 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11199
11200 assert!(
11201 runner_root_refusals(&host.paths)
11202 .expect("readable")
11203 .is_empty(),
11204 "no record means every policy is placing runners"
11205 );
11206
11207 record_runner_root_refusal(
11208 &host.paths,
11209 "policy-a",
11210 at,
11211 "denied_by_privacy_policy",
11212 "/Volumes/NVME/runners",
11213 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
11214 )
11215 .expect("a writable record");
11216
11217 let refusals = runner_root_refusals(&host.paths).expect("readable");
11218 assert_eq!(refusals.len(), 1);
11219 assert_eq!(refusals[0].policy, "policy-a");
11220 assert_eq!(refusals[0].at, at);
11221 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
11222 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
11223 assert!(
11224 refusals[0].detail.contains("/Volumes/NVME/runners")
11225 && refusals[0].detail.contains("Full Disk Access"),
11226 "the path and the remediation are the whole point of this file: {refusals:?}"
11227 );
11228 }
11229
11230 #[test]
11238 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
11239 let host = Host::new();
11240 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11241 record_runner_root_refusal(
11242 &host.paths,
11243 "broken",
11244 at,
11245 "denied_by_privacy_policy",
11246 "/Volumes/NVME/runners",
11247 "detail",
11248 )
11249 .expect("a writable record");
11250 record_runner_root_refusal(
11251 &host.paths,
11252 "also-broken",
11253 at,
11254 "not_writable",
11255 "/srv/other",
11256 "detail",
11257 )
11258 .expect("a writable record");
11259
11260 clear_runner_root_refusal(&host.paths, "healthy")
11262 .expect("clearing an absent policy is fine");
11263 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
11264
11265 let refusals = runner_root_refusals(&host.paths).expect("readable");
11266 assert_eq!(
11267 refusals
11268 .iter()
11269 .map(|r| r.policy.as_str())
11270 .collect::<Vec<_>>(),
11271 vec!["broken"],
11272 "the policy that is still refused must keep its record"
11273 );
11274 }
11275
11276 #[test]
11279 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
11280 let host = Host::new();
11281 record_runner_root_refusal(
11282 &host.paths,
11283 "p",
11284 Utc::now(),
11285 "not_writable",
11286 "/srv/x",
11287 "detail",
11288 )
11289 .expect("a writable record");
11290
11291 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
11292 assert!(
11293 !root_refusal_path(&host.paths).exists(),
11294 "a host with nothing refused leaves nothing behind"
11295 );
11296 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
11297 }
11298
11299 #[test]
11302 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
11303 let host = Host::new();
11304 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
11305 let error = runner_root_refusals(&host.paths)
11306 .expect_err("an unparseable record is not the same as no record");
11307 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11308 }
11309
11310 #[test]
11320 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
11321 let host = Host::new();
11322 record_runner_root_refusal(
11323 &host.paths,
11324 "policy-a",
11325 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
11326 "denied_by_privacy_policy",
11327 "/Volumes/NVME/runners",
11328 "Grant Full Disk Access to the program that runs the service",
11329 )
11330 .expect("a writable record");
11331
11332 let status = host.operations().status().expect("a readable status");
11333 let notes = status.notes().join("\n");
11334
11335 assert!(
11336 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
11337 "the directory and the remediation the log had to scrub must appear here: {notes}"
11338 );
11339 assert!(
11340 notes.contains("policy-a"),
11341 "the operator has to know which target placed no runner: {notes}"
11342 );
11343 assert!(
11344 !status
11345 .problems()
11346 .iter()
11347 .any(|problem| problem.subject == "runner root"),
11348 "a record nothing an operator types can clear must not drive the exit code"
11349 );
11350 }
11351
11352 #[test]
11358 fn install_records_the_absolute_binary_path_and_the_four_directories() {
11359 let host = Host::new();
11360 let installed = host
11361 .operations()
11362 .install(&host.request(StartMode::Boot))
11363 .expect("an install against the recording controls");
11364
11365 assert_eq!(installed.record.binary, host.binary);
11366 assert!(installed.record.binary.is_absolute());
11367 assert_eq!(installed.record.start_mode, StartMode::Boot);
11368 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
11369 assert_eq!(
11370 installed.record.directories,
11371 ServiceDirectories::of(&host.paths)
11372 );
11373 assert_eq!(
11374 installed.record.log_file,
11375 host.paths.logs_dir().join(LOG_FILE_STEM)
11376 );
11377 assert_eq!(installed.record.restart_delay_secs, 15);
11378
11379 let registrations = host.controls.registrations();
11380 assert_eq!(registrations.len(), 1);
11381 assert_eq!(registrations[0].0, StartMode::Boot);
11382 assert_eq!(registrations[0].1, "runner-manager");
11383 }
11384
11385 #[test]
11386 fn install_is_refused_while_the_single_instance_lock_is_held() {
11387 let host = Host::new();
11388 {
11390 let operations = host.operations();
11391 operations
11392 .install(&host.request(StartMode::Boot))
11393 .expect("an install with the lock free");
11394 operations.uninstall().expect("a clean slate");
11395 }
11396
11397 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
11398 .expect("this process takes the lock first");
11399
11400 let error = host
11401 .operations()
11402 .install(&host.request(StartMode::Boot))
11403 .expect_err("a second agent must not be registered while one is running");
11404 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
11405 assert!(
11406 error.to_string().contains("already running"),
11407 "the message must be actionable: {error}"
11408 );
11409 assert!(
11410 host.controls.registrations().is_empty(),
11411 "a refused install must register nothing"
11412 );
11413 assert_eq!(
11414 InstallRecord::read(&host.paths).expect("readable"),
11415 None,
11416 "a refused install must write no record"
11417 );
11418 }
11419
11420 #[test]
11428 fn installing_over_the_same_start_mode_replaces_the_registration() {
11429 let host = Host::new();
11430 let operations = host.operations();
11431 operations
11432 .install(&host.request(StartMode::Boot))
11433 .expect("the first install");
11434
11435 let again = operations
11436 .install(&host.request(StartMode::Boot))
11437 .expect("an install over the same mode replaces rather than refusing");
11438
11439 assert!(
11440 again.replaced_existing,
11441 "the operator is told this replaced something rather than made it"
11442 );
11443 assert_eq!(
11444 host.controls.registrations().len(),
11445 1,
11446 "replacing must not leave two registrations behind"
11447 );
11448 assert_eq!(
11449 InstallRecord::read(&host.paths)
11450 .expect("readable")
11451 .expect("a record")
11452 .start_mode,
11453 StartMode::Boot
11454 );
11455 }
11456
11457 #[test]
11461 fn installing_over_the_other_start_mode_is_refused() {
11462 let host = Host::new();
11463 let operations = host.operations();
11464 operations
11465 .install(&host.request(StartMode::Boot))
11466 .expect("the first install");
11467
11468 let error = operations
11469 .install(&host.request(StartMode::Login))
11470 .expect_err("a mode change is not an install");
11471 assert!(
11472 matches!(
11473 error,
11474 ServiceError::AlreadyInstalled {
11475 existing: StartMode::Boot,
11476 requested: StartMode::Login,
11477 ..
11478 }
11479 ),
11480 "{error}"
11481 );
11482 assert!(
11483 !error
11484 .to_string()
11485 .contains("switch the start mode in place,"),
11486 "the old remedy named a capability no command offers; the terminal UI is where \
11487 the start mode moves: {error}"
11488 );
11489 assert_eq!(host.controls.registrations().len(), 1);
11490 }
11491
11492 #[test]
11493 fn install_rolls_back_the_registration_when_record_persistence_fails() {
11494 let host = Host::new();
11495 let record_path = InstallRecord::path(&host.paths);
11496 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11497
11498 let error = host
11499 .operations()
11500 .install(&host.request(StartMode::Boot))
11501 .expect_err("record persistence must fail");
11502
11503 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11504 assert!(
11505 host.controls.registrations().is_empty(),
11506 "a failed install must not leave a live unrecorded registration"
11507 );
11508 assert!(
11509 host.controls
11510 .calls()
11511 .iter()
11512 .any(|call| call == "uninstall runner-manager (boot)"),
11513 "the registration must be explicitly rolled back: {:?}",
11514 host.controls.calls()
11515 );
11516 assert!(
11517 !host.runner_root.as_path().exists(),
11518 "the rollback must take the runner root this install created with it; a directory \
11519 prepared for a registration that does not exist is litter, and on Windows it is \
11520 litter with a security descriptor"
11521 );
11522 }
11523
11524 #[test]
11529 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11530 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11531
11532 assert_eq!(
11536 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11537 ServiceAccount::LocalSystem
11538 );
11539 assert_eq!(
11540 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11541 ServiceAccount::InvokingUser
11542 );
11543
11544 let boot = default_root_sddl(&RootAdmission::LocalSystem);
11545 assert!(!grants_broad_write(&boot), "{boot}");
11546 assert!(
11547 !boot.contains("S-1-5-21"),
11548 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11549 );
11550
11551 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11556 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11557 assert!(!grants_broad_write(&login), "{login}");
11558 }
11559
11560 #[test]
11561 fn an_install_reports_the_runner_root_it_prepared() {
11562 let host = Host::new();
11563 let installed = host
11564 .operations()
11565 .install(&host.request(StartMode::Boot))
11566 .expect("an install");
11567 let rendered = installed.runner_root.to_string();
11568 assert!(
11569 !rendered.contains("S-1-5-21"),
11570 "the report must add no identity to the output: {rendered}"
11571 );
11572 if cfg!(windows) {
11573 assert_eq!(
11574 installed.runner_root.path(),
11575 Some(host.runner_root.as_path())
11576 );
11577 assert!(
11578 host.runner_root.as_path().is_dir(),
11579 "the directory jobs would run in has to exist once the service is registered"
11580 );
11581 } else {
11582 assert_eq!(
11583 installed.runner_root,
11584 crate::runner_root_access::RootAccessSummary::NotApplicable,
11585 "macOS and Linux keep the runtime directory they have always used"
11586 );
11587 }
11588 }
11589
11590 #[test]
11591 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11592 let host = Host::new();
11593 let operations = host.operations();
11594 operations
11595 .install(&host.request(StartMode::Boot))
11596 .expect("an install at boot");
11597
11598 let change = operations
11599 .set_start_mode(StartMode::Login)
11600 .expect("a switch to login");
11601
11602 assert!(change.changed);
11603 if cfg!(windows) {
11604 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11605 assert!(
11606 host.runner_root.as_path().is_dir(),
11607 "the switch must not remove the directory it reconciled"
11608 );
11609 }
11610 assert!(
11613 change.to_string().contains("runner root"),
11614 "{}",
11615 change.to_string()
11616 );
11617 }
11618
11619 #[test]
11620 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11621 let host = Host::new();
11622 let operations = host.operations();
11623 operations
11624 .install(&host.request(StartMode::Boot))
11625 .expect("an install at boot");
11626
11627 let change = operations
11628 .set_start_mode(StartMode::Boot)
11629 .expect("a switch to the mode already in force");
11630
11631 assert!(!change.changed);
11632 assert_eq!(
11633 change.runner_root,
11634 crate::runner_root_access::RootAccessSummary::NotApplicable,
11635 "nothing moves, so nothing about the root's access control has to; reconciling here \
11636 would turn a no-op command into one that can fail on a permission it does not need"
11637 );
11638 }
11639
11640 #[cfg(windows)]
11641 #[test]
11642 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11643 let host = Host::new();
11644 host.controls
11645 .fail_next_install(StartMode::Boot, "injected registration failure");
11646
11647 let error = host
11648 .operations()
11649 .install(&host.request(StartMode::Boot))
11650 .expect_err("the manager refuses the registration");
11651
11652 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11653 assert!(
11654 !host.runner_root.as_path().exists(),
11655 "the directory was created for a registration that does not exist"
11656 );
11657 }
11658
11659 #[cfg(windows)]
11660 #[test]
11661 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11662 let host = Host::new();
11663 crate::runner_root_access::create_with_descriptor_for_tests(
11667 host.runner_root.as_path(),
11668 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11669 )
11670 .expect("a deliberately open runner root");
11671 let before = crate::runner_root_access::report(host.runner_root.as_path());
11672
11673 let error = host
11674 .operations()
11675 .install(&host.request(StartMode::Boot))
11676 .expect_err("an open runner root is refused");
11677
11678 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11679 assert!(
11680 error.to_string().contains("nothing was registered"),
11681 "{error}"
11682 );
11683 assert!(
11684 host.controls.registrations().is_empty(),
11685 "the refusal has to come before the platform is asked to register anything: {:?}",
11686 host.controls.calls()
11687 );
11688 assert_eq!(
11689 crate::runner_root_access::report(host.runner_root.as_path()),
11690 before,
11691 "an open directory is refused rather than tightened: its contents cannot be trusted, \
11692 so adopting it would be worse than declining it"
11693 );
11694 }
11695
11696 #[cfg(windows)]
11697 #[test]
11698 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11699 let host = Host::new();
11700 let operations = host.operations();
11701 operations
11702 .install(&host.request(StartMode::Boot))
11703 .expect("an install");
11704 assert!(host.runner_root.as_path().is_dir());
11705
11706 operations.uninstall().expect("an uninstall");
11707
11708 assert!(
11709 host.runner_root.as_path().is_dir(),
11710 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11711 runner root may hold an operator's retained workspaces."
11712 );
11713 }
11714
11715 #[test]
11716 fn install_reviews_what_it_registered() {
11717 let host = Host::new();
11718 let installed = host
11719 .operations()
11720 .install(&host.request(StartMode::Boot))
11721 .expect("an install");
11722 assert!(
11723 installed.review.is_least_privilege(),
11724 "{}",
11725 installed.review
11726 );
11727 assert!(
11728 !installed.review.controls().is_empty(),
11729 "a review that confirms nothing proves nothing: {}",
11730 installed.review
11731 );
11732 assert_eq!(
11733 installed.review.kind(),
11734 host_definition_kind(StartMode::Boot),
11735 "the review must be of the definition this host's manager was given"
11736 );
11737 assert!(
11738 !installed.review.account().justification().is_empty(),
11739 "a privileged account with no stated reason is an unreviewed one"
11740 );
11741 }
11742
11743 #[test]
11748 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11749 let host = Host::new();
11750 let operations = host.operations();
11751 operations
11752 .install(&host.request(StartMode::Boot))
11753 .expect("an install");
11754
11755 let config = host.paths.config_dir();
11759 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11760 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11761 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11762 std::fs::write(
11763 host.paths
11764 .state_dir()
11765 .join("packages/2.330.0/runner.tar.gz"),
11766 b"cached package",
11767 )
11768 .expect("writable");
11769 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11770 std::fs::write(
11771 host.paths.state_dir().join("secrets/user-access-token"),
11772 b"a stand-in for the stored credential",
11773 )
11774 .expect("writable");
11775 std::fs::write(
11776 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11777 b"diagnostics",
11778 )
11779 .expect("writable");
11780
11781 let roots: Vec<PathBuf> = host
11782 .paths
11783 .all()
11784 .iter()
11785 .map(|(_, path)| (*path).to_path_buf())
11786 .collect();
11787 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11788 let before = snapshot(&roots);
11789
11790 assert!(
11793 before.len() >= 6,
11794 "the fixture must actually contain the files this test is about, got {before:#?}"
11795 );
11796 let record_path = InstallRecord::path(&host.paths);
11797 assert!(
11798 before.contains_key(&record_path),
11799 "the install record must be present before uninstall"
11800 );
11801
11802 let uninstalled = operations.uninstall().expect("an uninstall");
11803 assert!(uninstalled.removed_registration);
11804 assert!(uninstalled.removed_record);
11805
11806 let after = snapshot(&roots);
11807
11808 let mut expected = before.clone();
11810 expected.remove(&record_path);
11811 assert_eq!(
11812 after, expected,
11813 "uninstall must remove its own record and nothing else"
11814 );
11815 assert!(
11816 !record_path.exists(),
11817 "the record itself must go, or `uninstall` did nothing at all"
11818 );
11819 assert!(
11820 uninstalled
11821 .preserved
11822 .iter()
11823 .all(|path| roots.contains(&path.as_path())),
11824 "the preserved list must name the four directories: {uninstalled}"
11825 );
11826 }
11827
11828 #[test]
11829 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11830 let host = Host::new();
11831 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11832 assert!(!uninstalled.removed_registration);
11833 assert!(!uninstalled.removed_record);
11834 }
11835
11836 #[test]
11837 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11838 let host = Host::new();
11839 let operations = host.operations();
11840 operations
11841 .install(&host.request(StartMode::Boot))
11842 .expect("an install");
11843 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11844
11845 let uninstalled = operations.uninstall().expect("an uninstall");
11846 assert!(
11847 uninstalled.removed_registration,
11848 "a lost record must not strand a registration"
11849 );
11850 assert!(host.controls.registrations().is_empty());
11851 }
11852
11853 #[test]
11858 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11859 let host = Host::new();
11860 let operations = host.operations();
11861 operations
11862 .install(&host.request(StartMode::Boot))
11863 .expect("an install at boot");
11864
11865 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11869
11870 let change = operations
11871 .set_start_mode(StartMode::Login)
11872 .expect("a switch that does not reinstall the product");
11873 assert!(change.changed);
11874 assert_eq!(change.from, StartMode::Boot);
11875 assert_eq!(change.to, StartMode::Login);
11876 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11877
11878 let record = InstallRecord::read(&host.paths)
11879 .expect("readable")
11880 .expect("a record");
11881 assert_eq!(record.start_mode, StartMode::Login);
11882 assert_eq!(
11883 record.binary, host.binary,
11884 "the recorded path must survive the switch untouched"
11885 );
11886
11887 let registrations = host.controls.registrations();
11888 assert_eq!(registrations.len(), 1, "{registrations:?}");
11889 assert_eq!(registrations[0].0, StartMode::Login);
11890 assert!(
11891 registrations[0]
11892 .2
11893 .command_line
11894 .contains(&host.binary.to_string_lossy().into_owned()),
11895 "{:?}",
11896 registrations[0].2
11897 );
11898 }
11899
11900 #[test]
11901 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11902 let host = Host::new();
11903 let operations = host.operations();
11904 operations
11905 .install(&host.request(StartMode::Boot))
11906 .expect("an install at boot");
11907 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11908 host.controls
11909 .fail_next_install(StartMode::Login, "injected target failure");
11910
11911 let error = operations
11912 .set_start_mode(StartMode::Login)
11913 .expect_err("the target manager refuses the install");
11914
11915 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11916 assert_eq!(
11917 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11918 record_before
11919 );
11920 let registrations = host.controls.registrations();
11921 assert_eq!(registrations.len(), 1, "{registrations:?}");
11922 assert_eq!(registrations[0].0, StartMode::Boot);
11923 }
11924
11925 #[test]
11926 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11927 let host = Host::new();
11928 let operations = host.operations();
11929 operations
11930 .install(&host.request(StartMode::Boot))
11931 .expect("an install at boot");
11932 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11933 let config = host.paths.config_dir().to_path_buf();
11934 let hidden = config.with_file_name("config-hidden-by-fault");
11935 host.controls.hide_directory_after_install(
11936 StartMode::Login,
11937 config.clone(),
11938 hidden.clone(),
11939 );
11940
11941 let error = operations
11942 .set_start_mode(StartMode::Login)
11943 .expect_err("the injected filesystem fault prevents persistence");
11944
11945 std::fs::remove_file(&config).expect("remove the injected blocker");
11946 std::fs::rename(&hidden, &config).expect("restore the record directory");
11947 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11948 assert_eq!(
11949 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11950 record_before
11951 );
11952 let registrations = host.controls.registrations();
11953 assert_eq!(registrations.len(), 1, "{registrations:?}");
11954 assert_eq!(registrations[0].0, StartMode::Boot);
11955 assert!(
11956 host.controls
11957 .calls()
11958 .iter()
11959 .any(|call| call == "uninstall runner-manager (login)"),
11960 "the target must be rolled back: {:?}",
11961 host.controls.calls()
11962 );
11963 }
11964
11965 #[test]
11966 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11967 let host = Host::new();
11968 let operations = host.operations();
11969 operations
11970 .install(&host.request(StartMode::Boot))
11971 .expect("an install");
11972 let before = host.controls.calls().len();
11973
11974 let change = operations
11975 .set_start_mode(StartMode::Boot)
11976 .expect("a no-op switch");
11977 assert!(!change.changed);
11978 assert_eq!(
11979 host.controls.calls().len(),
11980 before,
11981 "a no-op switch must not touch the service manager"
11982 );
11983 }
11984
11985 #[test]
11986 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11987 let host = Host::new();
11988 let error = host
11989 .operations()
11990 .set_start_mode(StartMode::Login)
11991 .expect_err("there is nothing to switch");
11992 assert!(
11993 matches!(error, ServiceError::NotInstalled { .. }),
11994 "{error}"
11995 );
11996 }
11997
11998 #[test]
12003 fn status_reports_the_four_facts_journey_five_asks_for() {
12004 let host = Host::new();
12005 let operations = host.operations();
12006 operations
12007 .install(&host.request(StartMode::Boot))
12008 .expect("an install");
12009 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
12010 .expect("a valid timestamp")
12011 .with_timezone(&Utc);
12012 record_github_contact(&host.paths, at).expect("a heartbeat");
12013
12014 let status = operations.status().expect("a status");
12015 assert_eq!(status.start_mode(), Some(StartMode::Boot));
12016 assert_eq!(
12017 status.binary().map(BinaryPath::recorded),
12018 Some(host.binary.as_path())
12019 );
12020 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
12021 assert_eq!(status.last_github_contact(), Some(at));
12022 assert!(status.is_installed());
12023 assert!(status.is_healthy(), "{status}");
12024
12025 let printed = status.to_string();
12026 for fragment in [
12027 "start mode",
12028 "diagnostic log",
12029 "last GitHub contact",
12030 "binary",
12031 ] {
12032 assert!(printed.contains(fragment), "{printed}");
12033 }
12034 }
12035
12036 #[cfg(unix)]
12048 #[test]
12049 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
12050 use std::os::unix::fs::PermissionsExt as _;
12051
12052 if unsafe { libc::geteuid() } == 0 {
12056 return;
12057 }
12058
12059 let host = Host::new();
12060 let operations = host.operations();
12061 operations
12062 .install(&host.request(StartMode::Boot))
12063 .expect("an install");
12064 assert!(
12065 operations.status().expect("a status").is_healthy(),
12066 "the discriminator: healthy before the record is made unreadable"
12067 );
12068
12069 let record = InstallRecord::path(&host.paths);
12070 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
12071 .expect("the mode is applied");
12072
12073 let status = operations
12074 .status()
12075 .expect("a record this account may not read is reported, not thrown");
12076 assert!(status.is_installed(), "{status}");
12077 assert!(!status.is_healthy(), "{status}");
12078
12079 let printed = status.to_string();
12080 assert!(
12081 printed.contains("this account may not read it"),
12082 "the operator is told which of the two states this is: {printed}"
12083 );
12084 assert!(
12085 !printed.contains("there is no install record"),
12086 "a record that is there and unreadable is not a record that is missing, and the \
12087 missing one's remedy starts with `service uninstall`: {printed}"
12088 );
12089
12090 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
12093 .expect("the mode is restored");
12094 }
12095
12096 #[test]
12097 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
12098 let host = Host::new();
12099 let operations = host.operations();
12100 operations
12101 .install(&host.request(StartMode::Boot))
12102 .expect("an install");
12103
12104 assert!(
12107 operations.status().expect("a status").is_healthy(),
12108 "the freshly installed host must be healthy"
12109 );
12110
12111 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
12112
12113 let status = operations.status().expect("a status");
12114 assert!(!status.is_healthy(), "{status}");
12115 assert!(
12116 status
12117 .problems()
12118 .iter()
12119 .any(|problem| problem.subject == "binary"),
12120 "{status}"
12121 );
12122 assert!(status.to_string().contains("STALE"), "{status}");
12123 }
12124
12125 #[test]
12126 fn status_reports_a_registration_that_would_not_start_at_boot() {
12127 let host = Host::new();
12128 let operations = host.operations();
12129 operations
12130 .install(&host.request(StartMode::Boot))
12131 .expect("an install");
12132 assert!(operations.status().expect("a status").is_healthy());
12133
12134 host.controls.edit("runner-manager", |registration| {
12135 registration.starts_automatically = false;
12136 });
12137
12138 let status = operations.status().expect("a status");
12139 assert!(!status.is_healthy(), "{status}");
12140 assert!(
12141 status
12142 .problems()
12143 .iter()
12144 .any(|problem| problem.detail.contains("after a reboot")),
12145 "{status}"
12146 );
12147 }
12148
12149 #[test]
12150 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
12151 let host = Host::new();
12152 let operations = host.operations();
12153 operations
12154 .install(&host.request(StartMode::Login))
12155 .expect("install and immediate start");
12156
12157 assert!(
12158 operations.status().expect("running status").is_running(),
12159 "install must not wait for the next login trigger"
12160 );
12161 operations.stop().expect("stop the registration");
12162 let status = operations.status().expect("stopped status");
12163 assert!(!status.is_healthy(), "{status}");
12164 assert!(
12165 status
12166 .problems()
12167 .iter()
12168 .any(|problem| problem.subject == "runtime"),
12169 "{status}"
12170 );
12171 let rendered = status.to_string();
12172 assert!(
12173 rendered.contains("runner-manager service install --start-at login"),
12174 "{rendered}"
12175 );
12176 assert!(
12177 !rendered.contains("runner-manager service start"),
12178 "{rendered}"
12179 );
12180 }
12181
12182 #[test]
12183 fn status_reports_a_restart_policy_something_else_edited() {
12184 let host = Host::new();
12185 let operations = host.operations();
12186 operations
12187 .install(&host.request(StartMode::Boot))
12188 .expect("an install");
12189 assert!(operations.status().expect("a status").is_healthy());
12190
12191 host.controls.edit("runner-manager", |registration| {
12192 registration.restart_delay = Some(Duration::from_secs(1));
12193 });
12194
12195 let status = operations.status().expect("a status");
12196 assert!(!status.is_healthy(), "{status}");
12197 assert!(
12198 status
12199 .problems()
12200 .iter()
12201 .any(|problem| problem.subject == "restart policy"),
12202 "{status}"
12203 );
12204 }
12205
12206 #[test]
12207 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
12208 let host = Host::new();
12209 let operations = host.operations();
12210 operations
12211 .install(&host.request(StartMode::Boot))
12212 .expect("an install");
12213 let other = host.binary.with_file_name("someone-elses.exe");
12214 std::fs::write(&other, b"x").expect("writable");
12215
12216 host.controls.edit("runner-manager", |registration| {
12217 registration.command_line = quote_argument(&other.to_string_lossy());
12218 });
12219
12220 let status = operations.status().expect("a status");
12221 assert!(!status.is_healthy(), "{status}");
12222 assert!(
12223 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12224 "{status}"
12225 );
12226 }
12227
12228 #[test]
12229 fn status_reports_a_record_no_service_manager_knows_about() {
12230 let host = Host::new();
12231 let operations = host.operations();
12232 operations
12233 .install(&host.request(StartMode::Boot))
12234 .expect("an install");
12235 for mode in [StartMode::Boot, StartMode::Login] {
12237 host.controls
12238 .control(mode)
12239 .expect("a control")
12240 .uninstall(&ServiceIdentity::product())
12241 .expect("removed");
12242 }
12243
12244 let status = operations.status().expect("a status");
12245 assert!(!status.is_healthy(), "{status}");
12246 assert!(
12247 status
12248 .problems()
12249 .iter()
12250 .any(|problem| problem.subject == "registration"),
12251 "{status}"
12252 );
12253 }
12254
12255 #[test]
12256 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
12257 let host = Host::new();
12258 let status = host.operations().status().expect("a status");
12259 assert!(!status.is_installed());
12260 assert!(
12261 status.is_healthy(),
12262 "a host that never installed the service has no fault to report: {status}"
12263 );
12264 assert!(status.to_string().contains("installed"), "{status}");
12265 }
12266
12267 #[test]
12268 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
12269 let host = Host::new();
12270 let operations = host.operations();
12271 operations
12272 .install(&host.request(StartMode::Login))
12273 .expect("an install at login");
12274 let status = operations.status().expect("a status");
12275 assert!(
12276 status
12277 .notes()
12278 .iter()
12279 .any(|note| note.contains("does not run until the operator signs in")),
12280 "05-infrastructure.md requires `service status` to say so: {status}"
12281 );
12282 }
12283
12284 #[test]
12287 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
12288 use crate::unattended_login::{AutoLogin, UnattendedLogin};
12289 let automatic = UnattendedLogin {
12290 auto_login: AutoLogin::As("ivan".into()),
12291 filevault_on: Some(false),
12292 };
12293 assert_eq!(
12294 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
12295 None
12296 );
12297
12298 let off = UnattendedLogin {
12299 auto_login: AutoLogin::Off,
12300 filevault_on: Some(false),
12301 };
12302 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
12303 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
12304 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
12305
12306 let locked = UnattendedLogin {
12307 auto_login: AutoLogin::As("ivan".into()),
12308 filevault_on: Some(true),
12309 };
12310 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
12311 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
12312
12313 let unread = UnattendedLogin {
12316 auto_login: AutoLogin::As("ivan".into()),
12317 filevault_on: None,
12318 };
12319 assert_eq!(
12320 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
12321 None
12322 );
12323
12324 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
12326 assert!(
12327 gap.reason
12328 .contains("does not run until the operator signs in")
12329 );
12330 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
12331 }
12332
12333 #[test]
12336 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
12337 let host = Host::new();
12338 let operations = host.operations();
12339 operations
12340 .install(&host.request(StartMode::Login))
12341 .expect("an install at login");
12342 let stalled_problem = |operations: &ServiceOperations| {
12343 operations
12344 .status()
12345 .expect("a status")
12346 .problems()
12347 .iter()
12348 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
12349 .map(|problem| problem.detail.clone())
12350 };
12351 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
12352 assert_eq!(stalled_problem(&operations), None, "beating");
12353
12354 crate::daemon_heartbeat::beat(
12355 &operations.paths,
12356 Utc::now() - chrono::Duration::minutes(20),
12357 )
12358 .unwrap();
12359 let detail = stalled_problem(&operations).expect("a stall is a problem");
12360 assert!(detail.contains("has made no progress since"), "{detail}");
12361 let restart = ServiceIdentity::product().restart_command(StartMode::Login);
12364 assert!(detail.contains(&restart), "{detail}");
12365 assert!(!detail.contains("runner-manager service stop"), "{detail}");
12366 assert!(!detail.contains("runner-manager service start"), "{detail}");
12367 }
12368
12369 #[test]
12373 fn the_restart_command_is_the_service_managers_own() {
12374 let identity = ServiceIdentity::product();
12375 let boot = identity.restart_command(StartMode::Boot);
12376 let login = identity.restart_command(StartMode::Login);
12377 for command in [&boot, &login] {
12378 assert!(!command.contains("runner-manager service"), "{command}");
12379 assert!(command.contains(identity.name()), "{command}");
12380 }
12381 assert_ne!(boot, login);
12382 if cfg!(windows) {
12383 assert!(
12384 boot.starts_with("Restart-Service -Name runner-manager"),
12385 "{boot}"
12386 );
12387 assert!(
12388 login.contains("schtasks /Run /TN runner-manager"),
12389 "{login}"
12390 );
12391 } else if cfg!(target_os = "macos") {
12392 assert!(
12393 boot.starts_with("sudo launchctl kickstart -k system/"),
12394 "{boot}"
12395 );
12396 assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
12397 } else {
12398 assert_eq!(boot, "sudo systemctl restart runner-manager.service");
12399 assert_eq!(login, "systemctl --user restart runner-manager.service");
12400 }
12401 }
12402
12403 #[test]
12405 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
12406 let host = Host::new();
12407 let operations = host.operations();
12408 operations
12409 .install(&host.request(StartMode::Login))
12410 .expect("an install at login");
12411 let paths = operations.paths.clone();
12412 crate::host_fitness::record_host_unfit(
12413 &paths,
12414 &[crate::host_fitness::UnfitFinding {
12415 id: "host.runner_root_responsive".into(),
12416 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
12417 remedy: Some("click Allow".into()),
12418 }],
12419 Utc::now(),
12420 )
12421 .unwrap();
12422 let status = operations.status().expect("a status");
12423 assert!(!status.is_healthy(), "{status}");
12424 let problem = status
12425 .problems()
12426 .iter()
12427 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
12428 .expect("the refusal is a problem");
12429 assert!(
12430 problem.detail.contains("/Volumes/NVME/runners"),
12431 "{problem:?}"
12432 );
12433 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
12434
12435 crate::host_fitness::clear_host_unfit(&paths).unwrap();
12438 crate::host_fitness::record_host_unfit(
12439 &paths,
12440 &[],
12441 Utc::now() - chrono::Duration::hours(2),
12442 )
12443 .unwrap();
12444 let status = operations.status().expect("a status");
12445 assert!(
12446 !status
12447 .problems()
12448 .iter()
12449 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
12450 "{status}"
12451 );
12452 }
12453
12454 #[test]
12459 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
12460 let host = Host::new();
12461 let operations = host.operations();
12462 operations
12463 .install(&host.request(StartMode::Login))
12464 .expect("an install at login");
12465 operations.start().expect("a start");
12466 assert!(operations.status().expect("a status").is_running());
12467 assert!(operations.stop().expect("a stop"));
12468 assert!(!operations.status().expect("a status").is_running());
12469 }
12470
12471 #[test]
12472 fn starting_a_host_with_no_registration_is_refused() {
12473 let host = Host::new();
12474 let error = host.operations().start().expect_err("nothing to start");
12475 assert!(
12476 matches!(error, ServiceError::NotInstalled { .. }),
12477 "{error}"
12478 );
12479 }
12480
12481 #[cfg(windows)]
12497 #[test]
12498 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12499 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12500
12501 let root = tempfile::tempdir().expect("a temporary directory");
12502 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12503 .expect("a rooted machine-scoped store");
12504 store
12505 .store(&secrecy::SecretString::from("a stand-in for the token"))
12506 .expect("the store accepts a value");
12507 let protection = store.protection().expect("the DACL can be read back");
12508
12509 assert!(
12510 protection.description().contains(";;;SY)"),
12511 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12512 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12513 protection.description()
12514 );
12515 assert_eq!(
12516 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12517 ServiceAccount::LocalSystem,
12518 "and that is the account this installer registers, which is why SY is what matters"
12519 );
12520 assert!(
12521 !protection.readable_by_other_local_users(),
12522 "the same DACL must still exclude ordinary local users: {}",
12523 protection.description()
12524 );
12525
12526 for rejected in [";;;LS)", ";;;NS)"] {
12530 assert!(
12531 !protection.description().contains(rejected),
12532 "if the store ever admitted {rejected}, the least-privilege analysis in \
12533 docs/service-account.md would need redoing: {}",
12534 protection.description()
12535 );
12536 }
12537 }
12538
12539 fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12544 let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12545 arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12546 host.request(mode).with_arguments(arguments)
12547 }
12548
12549 fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12552 ServiceOperations::with_controls(
12553 account.paths.clone(),
12554 ServiceIdentity::product(),
12555 std::sync::Arc::new(machine.controls.clone()),
12556 )
12557 .with_runner_root(account.runner_root.clone())
12558 }
12559
12560 fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12564 let owner = Host::new();
12565 owner
12566 .operations()
12567 .install(&request_with_directories(&owner, StartMode::Boot))
12568 .expect("the owner's boot install");
12569 let earlier = Host::new();
12570 earlier
12571 .operations()
12572 .install(&request_with_directories(&earlier, StartMode::Login))
12573 .expect("the second account's earlier login install");
12574 assert!(
12575 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12576 "the earlier install left its record behind"
12577 );
12578 (owner, earlier)
12579 }
12580
12581 const DESTRUCTIVE_ADVICE: [&str; 4] = [
12584 "service uninstall",
12585 "service install`",
12586 "service install --",
12587 "auth login",
12588 ];
12589
12590 #[test]
12591 fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12592 let (owner, earlier) = a_machine_with_a_stale_second_account();
12593
12594 let status = second_account(&earlier, &owner).status().expect("a status");
12595
12596 let foreign = status.foreign().expect("the service is the owner's");
12597 assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12598 assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12599 assert!(foreign.unreadable.is_none(), "{status}");
12600 assert!(
12601 status.is_healthy(),
12602 "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12603 {status}"
12604 );
12605 for subject in [
12606 "binary",
12607 "start mode",
12608 "secret store",
12609 "record",
12610 "registration",
12611 ] {
12612 assert!(
12613 !status
12614 .problems()
12615 .iter()
12616 .any(|problem| problem.subject == subject),
12617 "{subject} compared the second account's record against the owner's \
12618 registration: {status}"
12619 );
12620 }
12621 let rendered = status.to_string();
12622 for advice in DESTRUCTIVE_ADVICE {
12623 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12624 }
12625 assert!(rendered.contains("another installation's"), "{rendered}");
12626 assert!(
12627 status
12628 .notes()
12629 .iter()
12630 .any(|note| note.contains("unused local configuration")
12631 && note.contains("host forget-local")),
12632 "{status}"
12633 );
12634 assert_eq!(status.start_mode(), Some(StartMode::Boot));
12636 }
12637
12638 #[test]
12639 fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12640 let (owner, earlier) = a_machine_with_a_stale_second_account();
12641 InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12644
12645 let operations = second_account(&earlier, &owner);
12646 let status = operations.status().expect("a status");
12647 let foreign = status.foreign().expect("the service is the owner's");
12648 assert!(foreign.unreadable.is_some(), "{status}");
12649 assert!(
12650 status.is_healthy(),
12651 "running, and nothing it could judge: {status}"
12652 );
12653 let rendered = status.to_string();
12654 for advice in DESTRUCTIVE_ADVICE {
12655 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12656 }
12657 assert!(rendered.contains("running; installed from"), "{rendered}");
12658 assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12659
12660 owner.operations().stop().expect("stopped");
12662 let stopped = operations.status().expect("a status");
12663 assert!(!stopped.is_healthy(), "{stopped}");
12664 let rendered = stopped.to_string();
12665 for advice in DESTRUCTIVE_ADVICE {
12666 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12667 }
12668 }
12669
12670 #[test]
12671 fn this_accounts_own_registration_still_reports_every_mismatch() {
12672 let host = Host::new();
12673 let operations = host.operations();
12674 operations
12675 .install(&request_with_directories(&host, StartMode::Boot))
12676 .expect("an install");
12677 let other = host.binary.with_file_name("someone-elses");
12678 std::fs::write(&other, b"x").expect("writable");
12679 host.controls.edit("runner-manager", |registration| {
12681 let mut arguments = split_command_line(®istration.command_line);
12682 arguments[0] = other.to_string_lossy().into_owned();
12683 registration.command_line = arguments
12684 .iter()
12685 .map(|argument| quote_argument(argument))
12686 .collect::<Vec<_>>()
12687 .join(" ");
12688 registration.start_mode = StartMode::Login;
12689 });
12690
12691 let status = operations.status().expect("a status");
12692 assert!(status.foreign().is_none(), "{status}");
12693 assert!(
12694 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12695 "{status}"
12696 );
12697 assert!(
12698 status
12699 .problems()
12700 .iter()
12701 .any(|problem| problem.subject == "start mode"),
12702 "{status}"
12703 );
12704 assert!(status.to_string().contains("service uninstall"), "{status}");
12705 }
12706
12707 #[test]
12708 fn directories_read_back_from_a_command_line_with_spaces() {
12709 let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12710 let directories = ServiceDirectories {
12711 config: root.join("config"),
12712 state: root.join("data").join("state"),
12713 runtime: root.join("data").join("runtime"),
12714 logs: root.join("data").join("logs"),
12715 };
12716 let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12717 for argument in ["daemon", "run"]
12718 .map(OsString::from)
12719 .into_iter()
12720 .chain(directories.arguments())
12721 {
12722 command_line.push(' ');
12723 command_line.push_str("e_argument(&argument.to_string_lossy()));
12724 }
12725 assert_eq!(
12726 ServiceDirectories::from_command_line(&command_line),
12727 Some(directories.clone())
12728 );
12729 assert_eq!(directories.root(), root);
12730 assert_eq!(
12731 ServiceDirectories::from_command_line("runner-manager daemon run"),
12732 None,
12733 "a registration that names no directories cannot be judged foreign"
12734 );
12735 }
12736
12737 #[test]
12738 fn the_owner_of_a_data_root_is_read_from_its_path() {
12739 let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12740 let home = users.join("ivand");
12741 assert_eq!(
12742 data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12743 Some("yuriv")
12744 );
12745 assert_eq!(
12746 data_root_owner(
12747 Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12748 Some(&home)
12749 ),
12750 None
12751 );
12752 if cfg!(windows) {
12753 assert_eq!(
12754 data_root_owner(
12755 Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12756 Some(&home)
12757 )
12758 .as_deref(),
12759 Some("LocalSystem")
12760 );
12761 } else {
12762 assert_eq!(
12763 data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12764 Some("root")
12765 );
12766 }
12767 }
12768
12769 #[test]
12770 fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12771 let (owner, earlier) = a_machine_with_a_stale_second_account();
12772 let owner_before = snapshot(&[owner._root.path()]);
12773 let config = earlier.paths.config_dir().to_path_buf();
12774 let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12775
12776 let forgotten = second_account(&earlier, &owner)
12777 .forget_local(Utc::now())
12778 .expect("archived");
12779
12780 assert_eq!(
12781 snapshot(&[owner._root.path()]),
12782 owner_before,
12783 "the owner's directories must not change by one byte"
12784 );
12785 let archive = forgotten.archive.expect("something was archived");
12786 assert!(archive.starts_with(&config), "{}", archive.display());
12787 assert_eq!(
12788 std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12789 record,
12790 "moved, not rewritten or deleted"
12791 );
12792 assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12793 assert_eq!(
12794 forgotten.service_root,
12795 Some(ServiceDirectories::of(&owner.paths).root())
12796 );
12797 let status = second_account(&earlier, &owner).status().expect("status");
12799 assert!(
12800 !status
12801 .notes()
12802 .iter()
12803 .any(|note| note.contains("unused local configuration")),
12804 "{status}"
12805 );
12806 }
12807
12808 #[test]
12809 fn forget_local_refuses_a_configuration_a_service_runs_against() {
12810 let (owner, earlier) = a_machine_with_a_stale_second_account();
12811 let before = snapshot(&[owner._root.path()]);
12812 let refused = owner.operations().forget_local(Utc::now());
12813 assert!(
12814 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12815 "{refused:?}"
12816 );
12817 assert_eq!(snapshot(&[owner._root.path()]), before);
12818
12819 let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12821 .expect("the agent's lock");
12822 let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12823 assert!(
12824 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12825 "{refused:?}"
12826 );
12827 assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12828 }
12829
12830 #[test]
12831 fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12832 let (owner, earlier) = a_machine_with_a_stale_second_account();
12833 owner.operations().stop().expect("stopped");
12836 record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12837 record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12838
12839 let status = second_account(&earlier, &owner).status().expect("a status");
12840 assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12841 let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12842 for subject in [
12843 "runtime",
12844 "github credential",
12845 CREDENTIAL_UNREADABLE_SUBJECT,
12846 ] {
12847 assert!(subjects.contains(&subject), "{subject}: {status}");
12848 }
12849 let rendered = status.to_string();
12850 for advice in DESTRUCTIVE_ADVICE {
12851 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12852 }
12853 for problem in status.problems() {
12854 assert!(problem.detail.contains("Act on it"), "{problem}");
12855 }
12856 }
12857
12858 #[test]
12859 fn no_command_of_this_account_changes_another_installations_service() {
12860 let (owner, earlier) = a_machine_with_a_stale_second_account();
12861 let operations = second_account(&earlier, &owner);
12862 let foreign = |result: Result<(), ServiceError>| {
12863 assert!(
12864 matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12865 "{result:?}"
12866 );
12867 };
12868 foreign(
12869 operations
12870 .install(&request_with_directories(&earlier, StartMode::Boot))
12871 .map(drop),
12872 );
12873 foreign(operations.uninstall().map(drop));
12874 foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12875 foreign(operations.start());
12876 foreign(operations.stop().map(drop));
12877
12878 let registration = owner
12879 .controls
12880 .control(StartMode::Boot)
12881 .unwrap()
12882 .query(&ServiceIdentity::product())
12883 .unwrap()
12884 .expect("the owner's registration is still there");
12885 assert_eq!(
12886 ServiceDirectories::from_command_line(®istration.command_line),
12887 Some(ServiceDirectories::of(&owner.paths)),
12888 "and still runs against the owner's directories"
12889 );
12890 assert!(registration.running, "and was never stopped");
12891 assert!(
12892 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12893 "the refused uninstall kept this account's record"
12894 );
12895 }
12896
12897 #[test]
12898 fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12899 let root = tempfile::tempdir().unwrap();
12902 let config = root.path().join("Application Support");
12903 let paths = AppPaths::from_directories(
12904 &config,
12905 config.join("state"),
12906 config.join("runtime"),
12907 config.join("logs"),
12908 );
12909 paths.create_all().unwrap();
12910 std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12911 std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12912 let operations = ServiceOperations::with_controls(
12913 paths.clone(),
12914 ServiceIdentity::product(),
12915 std::sync::Arc::new(RecordingControls::new()),
12916 );
12917
12918 let forgotten = operations.forget_local(Utc::now()).expect("archived");
12919
12920 assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12921 for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12922 assert!(kept.is_dir(), "{} was moved", kept.display());
12923 }
12924 assert!(paths.logs_dir().join("runner-manager.log").is_file());
12925 }
12926
12927 #[test]
12928 fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12929 for (detail, diagnosis) in [
12930 (
12931 "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12932 service install --start-at boot`; if the service manager denies replacement",
12933 "x holds an automatic registration, but the daemon is stopped",
12934 ),
12935 (
12936 "store -- MISMATCH. Run `auth login` again so the token is stored",
12937 "store -- MISMATCH",
12938 ),
12939 (
12940 "unit differs, so the daemon runs under outdated settings. Re-register it with \
12941 `sudo runner-manager service install --start-at boot`",
12942 "unit differs, so the daemon runs under outdated settings",
12943 ),
12944 (
12945 "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12946 "it starts no runner; the log has it",
12947 ),
12948 (
12949 "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12950 install`",
12951 "C:\\a\\runner-manager.exe -- STALE: moved",
12952 ),
12953 (
12954 "a new builder: run `runner-manager service install` here, or not",
12955 "the installation's own records report this",
12956 ),
12957 ] {
12958 assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12959 }
12960 }
12961}