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 out.push_str(&format!(
1768 "ReadWritePaths={}\n",
1769 quoted(&permitted_paths(DefinitionKind::SystemdUnit, plan))
1770 ));
1771 if !plan.protected_paths().is_empty() {
1772 out.push_str(&format!(
1773 "InaccessiblePaths={}\n",
1774 inaccessible_entries(plan).join(" ")
1775 ));
1776 }
1777
1778 out.push_str("\n[Install]\n");
1779 out.push_str(match plan.start_mode() {
1780 StartMode::Boot => "WantedBy=multi-user.target\n",
1781 StartMode::Login => "WantedBy=default.target\n",
1782 });
1783 out
1784}
1785
1786const SYSTEMD_HARDENING: [&str; 12] = [
1809 "NoNewPrivileges=yes",
1810 "CapabilityBoundingSet=",
1811 "AmbientCapabilities=",
1812 "PrivateTmp=yes",
1813 "PrivateDevices=yes",
1814 "ProtectSystem=strict",
1815 "ProtectKernelTunables=yes",
1816 "ProtectKernelModules=yes",
1817 "ProtectControlGroups=yes",
1818 "RestrictNamespaces=yes",
1819 "RestrictRealtime=yes",
1820 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1821];
1822
1823pub const BOOT_CAPABILITIES: &str = boot_capabilities!();
1837
1838macro_rules! boot_capabilities {
1841 () => {
1842 "CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_SETGID CAP_SETUID"
1843 };
1844}
1845use boot_capabilities;
1846
1847fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1856 let mut directives = SYSTEMD_HARDENING.to_vec();
1857 if mode == StartMode::Boot {
1858 if let Some(directive) = directives
1859 .iter_mut()
1860 .find(|directive| **directive == "CapabilityBoundingSet=")
1861 {
1862 *directive = concat!("CapabilityBoundingSet=", boot_capabilities!());
1863 }
1864 directives.push("ProtectHome=tmpfs");
1865 }
1866 directives
1867}
1868
1869fn inaccessible_entries(plan: &InstallPlan) -> Vec<String> {
1872 plan.protected_paths()
1873 .iter()
1874 .map(|path| quote_argument(&format!("-{}", path.to_string_lossy())))
1875 .collect()
1876}
1877
1878#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1890#[serde(deny_unknown_fields)]
1891pub struct ProtectedPaths {
1892 #[serde(default)]
1893 pub paths: Vec<PathBuf>,
1894}
1895
1896impl ProtectedPaths {
1897 pub const FILE: &'static str = "protected-paths.json";
1899
1900 #[must_use]
1902 pub const fn supported() -> bool {
1903 cfg!(all(unix, not(target_os = "macos")))
1904 }
1905
1906 pub fn load(config_dir: &Path) -> std::io::Result<Self> {
1912 crate::settings_file::load(&config_dir.join(Self::FILE))
1913 }
1914
1915 pub fn save(&self, config_dir: &Path) -> std::io::Result<()> {
1921 crate::settings_file::save(&config_dir.join(Self::FILE), self)
1922 }
1923
1924 #[must_use]
1930 pub fn refusal(path: &Path, own: &[PathBuf]) -> Option<String> {
1931 const SYSTEM: [&str; 16] = [
1932 "/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/opt", "/proc",
1933 "/run", "/sbin", "/sys", "/tmp", "/usr", "/var",
1934 ];
1935 if !path.is_absolute()
1936 || path
1937 .components()
1938 .any(|component| matches!(component, std::path::Component::ParentDir))
1939 {
1940 return Some(format!(
1941 "{} is not an absolute path without `..`",
1942 path.display()
1943 ));
1944 }
1945 if SYSTEM.iter().any(|system| path == Path::new(system)) {
1946 return Some(format!(
1947 "{} is one every job needs to run; protect a directory inside it instead",
1948 path.display()
1949 ));
1950 }
1951 if let Some(needed) = own
1952 .iter()
1953 .find(|needed| needed.starts_with(path) || path.starts_with(needed))
1954 {
1955 return Some(format!(
1956 "{} holds or lies inside {}, which the service itself uses",
1957 path.display(),
1958 needed.display()
1959 ));
1960 }
1961 None
1962 }
1963}
1964
1965#[cfg(any(test, all(unix, not(target_os = "macos"))))]
1968const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1969
1970#[cfg(any(all(test, unix), target_os = "linux"))]
1973fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
1974 let occupied = match std::fs::read_dir(path) {
1975 Ok(mut entries) => entries.next().is_some(),
1976 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
1977 Err(error) => return Err(error),
1978 };
1979 if occupied {
1980 let name = path.file_name().map_or_else(
1981 || std::borrow::Cow::Borrowed("directory"),
1982 |name| name.to_string_lossy(),
1983 );
1984 std::fs::rename(
1985 path,
1986 path.with_file_name(format!("{name}.before-move-{stamp}")),
1987 )?;
1988 }
1989 Ok(())
1990}
1991
1992#[cfg(any(all(test, unix), target_os = "linux"))]
1996fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
1997 use std::os::unix::fs::DirBuilderExt as _;
1998
1999 std::fs::DirBuilder::new()
2000 .recursive(true)
2001 .mode(0o700)
2002 .create(to)?;
2003 for entry in std::fs::read_dir(from)? {
2004 let entry = entry?;
2005 if skip.is_some_and(|skip| entry.file_name() == skip) {
2006 continue;
2007 }
2008 let source = entry.path();
2009 let target = to.join(entry.file_name());
2010 let kind = entry.file_type()?;
2011 if kind.is_symlink() {
2012 std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
2013 } else if kind.is_dir() {
2014 copy_tree(&source, &target, None)?;
2015 } else {
2016 std::fs::copy(&source, &target)?;
2017 }
2018 }
2019 Ok(())
2020}
2021
2022#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2029fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
2030 if let Some(root) = SYSTEMD_HOME_ROOTS
2031 .iter()
2032 .find(|root| path.starts_with(root))
2033 {
2034 return Some(format!(
2035 "{} is under {root}, which a boot service cannot see",
2036 path.display()
2037 ));
2038 }
2039 match owner {
2040 Some(0) => None,
2041 Some(uid) => Some(format!(
2042 "{} belongs to uid {uid}, and a boot service runs as root with no capability \
2043 to open another account's directory",
2044 path.display()
2045 )),
2046 None => Some(format!("{} cannot be inspected", path.display())),
2047 }
2048}
2049
2050pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
2074
2075#[must_use]
2082pub fn launchd_plist(plan: &InstallPlan) -> String {
2083 let identity = plan.identity();
2084 let directories = plan.directories();
2085 let mut out = String::new();
2086 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
2087 out.push_str(
2088 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
2089 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
2090 );
2091 out.push_str("<plist version=\"1.0\">\n<dict>\n");
2092 out.push_str(&plist_string("Label", &identity.launchd_label()));
2093
2094 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
2095 out.push_str(&format!(
2096 " <string>{}</string>\n",
2097 xml_escape(&plan.binary().to_string_lossy())
2098 ));
2099 for argument in plan.arguments() {
2100 out.push_str(&format!(
2101 " <string>{}</string>\n",
2102 xml_escape(&argument.to_string_lossy())
2103 ));
2104 }
2105 out.push_str(" </array>\n");
2106
2107 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
2108 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
2109 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
2110 out.push_str(" </dict>\n");
2111 out.push_str(&format!(
2112 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
2113 plan.restart().delay().as_secs()
2114 ));
2115 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
2116 out.push_str(&plist_string(
2117 "WorkingDirectory",
2118 &directories.state.to_string_lossy(),
2119 ));
2120 out.push_str(&plist_string(
2121 "StandardOutPath",
2122 &directories
2123 .logs
2124 .join("runner-manager.launchd.out.log")
2125 .to_string_lossy(),
2126 ));
2127 out.push_str(&plist_string(
2128 "StandardErrorPath",
2129 &directories
2130 .logs
2131 .join("runner-manager.launchd.err.log")
2132 .to_string_lossy(),
2133 ));
2134
2135 match plan.start_mode() {
2136 StartMode::Boot => {
2137 out.push_str(&plist_string(
2140 "UserName",
2141 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
2142 .as_str(),
2143 ));
2144 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
2146 }
2147 StartMode::Login => {
2148 }
2152 }
2153
2154 out.push_str("</dict>\n</plist>\n");
2155 out
2156}
2157
2158fn plist_string(key: &str, value: &str) -> String {
2160 format!(
2161 " <key>{}</key>\n <string>{}</string>\n",
2162 xml_escape(key),
2163 xml_escape(value)
2164 )
2165}
2166
2167#[derive(Debug, Clone, PartialEq, Eq)]
2175pub struct TaskPrincipal {
2176 user_id: String,
2177}
2178
2179impl TaskPrincipal {
2180 pub fn current() -> Result<Self, ServiceError> {
2188 let user = std::env::var("USERNAME")
2189 .ok()
2190 .filter(|value| !value.trim().is_empty());
2191 let Some(user) = user else {
2192 return Err(ServiceError::Control {
2193 operation: "identify the account for",
2194 name: SERVICE_NAME.to_string(),
2195 manager: "Windows Task Scheduler",
2196 detail: "this session reports no %USERNAME%, so there is no principal to \
2197 register a logon-triggered task for"
2198 .to_string(),
2199 });
2200 };
2201 let domain = std::env::var("USERDOMAIN")
2202 .ok()
2203 .filter(|value| !value.trim().is_empty());
2204 Ok(Self {
2205 user_id: match domain {
2206 Some(domain) => format!("{domain}\\{user}"),
2207 None => user,
2208 },
2209 })
2210 }
2211
2212 #[must_use]
2214 pub fn named(user_id: impl Into<String>) -> Self {
2215 Self {
2216 user_id: user_id.into(),
2217 }
2218 }
2219
2220 #[must_use]
2222 pub fn user_id(&self) -> &str {
2223 &self.user_id
2224 }
2225}
2226
2227#[must_use]
2234pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2235 let identity = plan.identity();
2236 let user = xml_escape(principal.user_id());
2237 let arguments = plan
2238 .arguments()
2239 .iter()
2240 .map(|argument| quote_argument(&argument.to_string_lossy()))
2241 .collect::<Vec<_>>()
2242 .join(" ");
2243 let mut out = String::new();
2244 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2245 out.push_str(
2246 "<Task version=\"1.4\" \
2247 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2248 );
2249 out.push_str(" <RegistrationInfo>\n");
2250 out.push_str(&format!(
2251 " <Description>{}</Description>\n",
2252 xml_escape(identity.description())
2253 ));
2254 out.push_str(&format!(
2255 " <URI>\\{}</URI>\n",
2256 xml_escape(identity.name())
2257 ));
2258 out.push_str(" </RegistrationInfo>\n");
2259
2260 out.push_str(" <Triggers>\n <LogonTrigger>\n");
2261 out.push_str(" <Enabled>true</Enabled>\n");
2262 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2263 out.push_str(" </LogonTrigger>\n </Triggers>\n");
2264
2265 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
2266 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2267 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
2268 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
2269 out.push_str(" </Principal>\n </Principals>\n");
2270
2271 out.push_str(" <Settings>\n");
2272 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2276 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2277 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2278 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
2279 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
2280 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2281 out.push_str(" <IdleSettings>\n");
2282 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2283 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
2284 out.push_str(" </IdleSettings>\n");
2285 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2286 out.push_str(" <Enabled>true</Enabled>\n");
2287 out.push_str(" <Hidden>false</Hidden>\n");
2288 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2289 out.push_str(" <WakeToRun>false</WakeToRun>\n");
2290 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2292 out.push_str(" <Priority>7</Priority>\n");
2293 out.push_str(" <RestartOnFailure>\n");
2294 out.push_str(&format!(
2295 " <Interval>{}</Interval>\n",
2296 iso8601_minutes(
2297 plan.restart()
2298 .effective_delay(DefinitionKind::WindowsScheduledTask)
2299 )
2300 ));
2301 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
2302 out.push_str(" </RestartOnFailure>\n");
2303 out.push_str(" </Settings>\n");
2304
2305 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
2306 out.push_str(&format!(
2307 " <Command>{}</Command>\n",
2308 xml_escape(&plan.binary().to_string_lossy())
2309 ));
2310 if !arguments.is_empty() {
2311 out.push_str(&format!(
2312 " <Arguments>{}</Arguments>\n",
2313 xml_escape(&arguments)
2314 ));
2315 }
2316 out.push_str(&format!(
2317 " <WorkingDirectory>{}</WorkingDirectory>\n",
2318 xml_escape(&plan.directories().state.to_string_lossy())
2319 ));
2320 out.push_str(" </Exec>\n </Actions>\n");
2321 out.push_str("</Task>\n");
2322 out
2323}
2324
2325fn iso8601_minutes(duration: Duration) -> String {
2328 format!("PT{}M", duration.as_secs() / 60)
2329}
2330
2331pub(crate) fn xml_escape(value: &str) -> String {
2339 let mut out = String::with_capacity(value.len());
2340 for c in value.chars() {
2341 match c {
2342 '&' => out.push_str("&"),
2343 '<' => out.push_str("<"),
2344 '>' => out.push_str(">"),
2345 '"' => out.push_str("""),
2346 '\'' => out.push_str("'"),
2347 other => out.push(other),
2348 }
2349 }
2350 out
2351}
2352
2353fn xml_unescape(value: &str) -> String {
2359 value
2360 .replace("<", "<")
2361 .replace(">", ">")
2362 .replace(""", "\"")
2363 .replace("'", "'")
2364 .replace("&", "&")
2365}
2366
2367#[derive(Debug, Clone, PartialEq, Eq)]
2376pub struct WindowsServiceSpec {
2377 pub name: String,
2379 pub display_name: String,
2381 pub description: String,
2383 pub automatic_start: bool,
2385 pub account: Option<String>,
2388 pub command_line: String,
2390 pub restart: RestartPolicy,
2392}
2393
2394#[must_use]
2396pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2397 WindowsServiceSpec {
2398 name: plan.identity().name().to_string(),
2399 display_name: plan.identity().display_name().to_string(),
2400 description: plan.identity().description().to_string(),
2401 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2406 account: match ServiceAccount::for_definition(
2407 DefinitionKind::WindowsService,
2408 plan.start_mode(),
2409 ) {
2410 ServiceAccount::LocalSystem => None,
2413 other => Some(other.as_str().to_string()),
2414 },
2415 command_line: plan.command_line(),
2416 restart: plan.restart(),
2417 }
2418}
2419
2420#[must_use]
2423fn windows_service_descriptor(plan: &InstallPlan) -> String {
2424 let spec = windows_service_spec(plan);
2425 let mut out = String::new();
2426 out.push_str("[windows-service]\n");
2427 out.push_str(&format!("Name={}\n", spec.name));
2428 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2429 out.push_str(&format!("Description={}\n", spec.description));
2430 out.push_str("ServiceType=OWN_PROCESS\n");
2434 out.push_str(&format!(
2435 "StartType={}\n",
2436 if spec.automatic_start {
2437 "AutoStart"
2438 } else {
2439 "OnDemand"
2440 }
2441 ));
2442 out.push_str("ErrorControl=Normal\n");
2443 out.push_str(&format!(
2444 "Account={}\n",
2445 spec.account
2446 .as_deref()
2447 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2448 ));
2449 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2450 out.push_str(&format!(
2451 "FailureActionRestartDelaySecs={}\n",
2452 spec.restart.delay().as_secs()
2453 ));
2454 out.push_str(&format!(
2455 "FailureActionsResetPeriodSecs={}\n",
2456 spec.restart.reset_after().as_secs()
2457 ));
2458 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2462 out.push_str(&format!(
2463 "ReadWritePaths={}\n",
2464 plan.directories()
2465 .all()
2466 .iter()
2467 .map(|path| quote_argument(&path.to_string_lossy()))
2468 .collect::<Vec<_>>()
2469 .join(" ")
2470 ));
2471 out
2472}
2473
2474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2480pub enum FindingKind {
2481 Excess,
2484 Shortfall,
2487}
2488
2489impl fmt::Display for FindingKind {
2490 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2491 f.write_str(match self {
2492 Self::Excess => "excess",
2493 Self::Shortfall => "shortfall",
2494 })
2495 }
2496}
2497
2498#[derive(Debug, Clone, PartialEq, Eq)]
2500pub struct PrivilegeFinding {
2501 pub kind: FindingKind,
2503 pub subject: String,
2505 pub detail: String,
2507}
2508
2509impl fmt::Display for PrivilegeFinding {
2510 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2511 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2512 }
2513}
2514
2515#[derive(Debug, Clone, PartialEq, Eq)]
2529pub struct PrivilegeReview {
2530 kind: DefinitionKind,
2531 account: ServiceAccount,
2532 controls: Vec<String>,
2533 findings: Vec<PrivilegeFinding>,
2534}
2535
2536impl PrivilegeReview {
2537 #[must_use]
2539 pub fn is_least_privilege(&self) -> bool {
2540 !self
2541 .findings
2542 .iter()
2543 .any(|finding| finding.kind == FindingKind::Excess)
2544 }
2545
2546 #[must_use]
2548 pub fn findings(&self) -> &[PrivilegeFinding] {
2549 &self.findings
2550 }
2551
2552 #[must_use]
2555 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2556 self.findings
2557 .iter()
2558 .filter(|finding| finding.kind == FindingKind::Excess)
2559 .collect()
2560 }
2561
2562 #[must_use]
2568 pub fn controls(&self) -> &[String] {
2569 &self.controls
2570 }
2571
2572 #[must_use]
2574 pub const fn account(&self) -> &ServiceAccount {
2575 &self.account
2576 }
2577
2578 #[must_use]
2580 pub const fn kind(&self) -> DefinitionKind {
2581 self.kind
2582 }
2583}
2584
2585impl fmt::Display for PrivilegeReview {
2586 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2587 writeln!(
2588 f,
2589 "{} runs as {} ({})",
2590 self.kind,
2591 self.account,
2592 self.account.justification()
2593 )?;
2594 for control in &self.controls {
2595 writeln!(f, " confirmed {control}")?;
2596 }
2597 for finding in &self.findings {
2598 writeln!(f, " {finding}")?;
2599 }
2600 if self.is_least_privilege() {
2601 write!(f, " verdict least privilege")
2602 } else {
2603 write!(
2604 f,
2605 " verdict NOT least privilege: {} excess(es)",
2606 self.excesses().len()
2607 )
2608 }
2609 }
2610}
2611
2612#[must_use]
2618pub fn review_least_privilege(
2619 definition: &ServiceDefinition,
2620 plan: &InstallPlan,
2621) -> PrivilegeReview {
2622 let mut controls = Vec::new();
2623 let mut findings = Vec::new();
2624 match definition.kind() {
2625 DefinitionKind::SystemdUnit => {
2626 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2627 }
2628 DefinitionKind::LaunchdPlist => {
2629 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2630 }
2631 DefinitionKind::WindowsScheduledTask => {
2632 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2633 }
2634 DefinitionKind::WindowsService => {
2635 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2636 }
2637 }
2638 PrivilegeReview {
2639 kind: definition.kind(),
2640 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2645 controls,
2646 findings,
2647 }
2648}
2649
2650fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2652 let mut permitted = plan
2653 .directories()
2654 .all()
2655 .iter()
2656 .map(|path| path.to_string_lossy().into_owned())
2657 .collect::<Vec<_>>();
2658 if kind == DefinitionKind::SystemdUnit
2659 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2660 {
2661 permitted.push(secret_directory.to_string_lossy().into_owned());
2662 }
2663 permitted
2664}
2665
2666fn same_path_text(left: &str, right: &str) -> bool {
2673 if cfg!(windows) {
2674 left.eq_ignore_ascii_case(right)
2675 } else {
2676 left == right
2677 }
2678}
2679
2680fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2683 match kind {
2684 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2685 left.eq_ignore_ascii_case(right)
2686 }
2687 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2688 }
2689}
2690
2691fn review_writable_paths(
2693 kind: DefinitionKind,
2694 subject: &str,
2695 listed: &[String],
2696 plan: &InstallPlan,
2697 controls: &mut Vec<String>,
2698 findings: &mut Vec<PrivilegeFinding>,
2699) {
2700 let permitted = permitted_paths(kind, plan);
2701 for entry in listed {
2702 if !permitted
2703 .iter()
2704 .any(|allowed| same_path_for(kind, allowed, entry))
2705 {
2706 findings.push(PrivilegeFinding {
2707 kind: FindingKind::Excess,
2708 subject: subject.to_string(),
2709 detail: format!(
2710 "{entry} is writable but is not one of this registration's required paths"
2711 ),
2712 });
2713 }
2714 }
2715 for allowed in &permitted {
2716 if !listed
2717 .iter()
2718 .any(|entry| same_path_for(kind, allowed, entry))
2719 {
2720 findings.push(PrivilegeFinding {
2721 kind: FindingKind::Shortfall,
2722 subject: subject.to_string(),
2723 detail: format!(
2724 "{allowed} is one of this registration's directories but is not writable, \
2725 so the daemon cannot use it"
2726 ),
2727 });
2728 }
2729 }
2730 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2731 controls.push(format!(
2732 "{subject} names exactly the required application-data and credential paths"
2733 ));
2734 }
2735}
2736
2737fn review_capabilities(
2740 expected: &str,
2741 wanted: &str,
2742 actual: &str,
2743 controls: &mut Vec<String>,
2744 findings: &mut Vec<PrivilegeFinding>,
2745) {
2746 let subject = "CapabilityBoundingSet";
2747 if actual.trim_start().starts_with('~') {
2749 findings.push(PrivilegeFinding {
2750 kind: FindingKind::Excess,
2751 subject: subject.to_string(),
2752 detail: format!("is `{actual}`, which keeps every capability it does not name"),
2753 });
2754 return;
2755 }
2756 let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2757 let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2758 let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2759 let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2760 if !extra.is_empty() {
2761 findings.push(PrivilegeFinding {
2762 kind: FindingKind::Excess,
2763 subject: subject.to_string(),
2764 detail: format!(
2765 "keeps {}, which the requirement does not ask for",
2766 extra.join(" ")
2767 ),
2768 });
2769 }
2770 if !missing.is_empty() {
2771 findings.push(PrivilegeFinding {
2772 kind: FindingKind::Shortfall,
2773 subject: subject.to_string(),
2774 detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2775 });
2776 }
2777 if extra.is_empty() && missing.is_empty() {
2778 controls.push(expected.to_string());
2779 }
2780}
2781
2782fn review_inbound_surface(
2790 text: &str,
2791 markers: &[(&str, &str)],
2792 controls: &mut Vec<String>,
2793 findings: &mut Vec<PrivilegeFinding>,
2794) {
2795 let mut clean = true;
2796 for (marker, detail) in markers {
2797 if text.contains(marker) {
2798 clean = false;
2799 findings.push(PrivilegeFinding {
2800 kind: FindingKind::Excess,
2801 subject: (*marker).to_string(),
2802 detail: (*detail).to_string(),
2803 });
2804 }
2805 }
2806 if clean {
2807 controls.push(
2808 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2809 );
2810 }
2811}
2812
2813fn review_systemd(
2814 text: &str,
2815 plan: &InstallPlan,
2816 controls: &mut Vec<String>,
2817 findings: &mut Vec<PrivilegeFinding>,
2818) {
2819 let directives = ini_directives(text, "Service");
2820 for expected in systemd_hardening(plan.start_mode()) {
2821 let (key, value) = expected
2822 .split_once('=')
2823 .expect("every hardening directive is written as key=value");
2824 match directives.get(key) {
2825 Some(actual) if key == "CapabilityBoundingSet" => {
2826 review_capabilities(expected, value, actual, controls, findings);
2827 }
2828 Some(actual) if actual == value => controls.push((*expected).to_string()),
2829 Some(actual) => findings.push(PrivilegeFinding {
2830 kind: FindingKind::Excess,
2831 subject: key.to_string(),
2832 detail: format!(
2833 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2834 requirement does not ask for"
2835 ),
2836 }),
2837 None => findings.push(PrivilegeFinding {
2838 kind: FindingKind::Excess,
2839 subject: key.to_string(),
2840 detail: format!(
2841 "is absent, so the unit inherits systemd's default rather than `{value}`"
2842 ),
2843 }),
2844 }
2845 }
2846
2847 match directives.get("ReadWritePaths") {
2848 Some(value) => {
2849 let listed = split_quoted(value);
2850 review_writable_paths(
2851 DefinitionKind::SystemdUnit,
2852 "ReadWritePaths",
2853 &listed,
2854 plan,
2855 controls,
2856 findings,
2857 );
2858 }
2859 None => findings.push(PrivilegeFinding {
2860 kind: FindingKind::Shortfall,
2861 subject: "ReadWritePaths".to_string(),
2862 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2863 .to_string(),
2864 }),
2865 }
2866
2867 if !plan.protected_paths().is_empty() {
2870 let listed = directives
2871 .get("InaccessiblePaths")
2872 .map(|value| split_quoted(value))
2873 .unwrap_or_default();
2874 let missing = plan
2875 .protected_paths()
2876 .iter()
2877 .filter(|path| {
2878 let entry = format!("-{}", path.to_string_lossy());
2879 !listed.contains(&entry)
2880 })
2881 .map(|path| path.display().to_string())
2882 .collect::<Vec<_>>();
2883 if missing.is_empty() {
2884 controls.push("InaccessiblePaths keeps every protected path from every job".into());
2885 } else {
2886 findings.push(PrivilegeFinding {
2887 kind: FindingKind::Excess,
2888 subject: "InaccessiblePaths".to_string(),
2889 detail: format!("no longer hides {}", missing.join(", ")),
2890 });
2891 }
2892 }
2893
2894 if directives.contains_key("PrivateUsers")
2897 && directives.get("PrivateUsers") == Some(&"no".to_string())
2898 {
2899 findings.push(PrivilegeFinding {
2900 kind: FindingKind::Excess,
2901 subject: "PrivateUsers".to_string(),
2902 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2903 .to_string(),
2904 });
2905 }
2906
2907 review_inbound_surface(
2908 text,
2909 &[
2910 (
2911 "ListenStream=",
2912 "asks systemd to open a listening socket for this service, which \
2913 07-security.md rule 2 forbids the product to have",
2914 ),
2915 (
2916 "ListenDatagram=",
2917 "asks systemd to open a listening socket for this service, which \
2918 07-security.md rule 2 forbids the product to have",
2919 ),
2920 ],
2921 controls,
2922 findings,
2923 );
2924}
2925
2926fn review_launchd(
2927 text: &str,
2928 plan: &InstallPlan,
2929 controls: &mut Vec<String>,
2930 findings: &mut Vec<PrivilegeFinding>,
2931) {
2932 match plist_string_value(text, "ProcessType") {
2936 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2937 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2938 }
2939 found => findings.push(PrivilegeFinding {
2940 kind: FindingKind::Shortfall,
2941 subject: "ProcessType".to_string(),
2942 detail: format!(
2943 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2944 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2945 when it next starts with no runner; `service status` says how to restart it",
2946 found.map_or_else(
2947 || "absent (launchd's `Standard`)".to_string(),
2948 |value| format!("`{value}`")
2949 )
2950 ),
2951 }),
2952 }
2953
2954 match plan.start_mode() {
2955 StartMode::Boot => {
2956 if plist_bool_value(text, "SessionCreate") == Some(true) {
2957 findings.push(PrivilegeFinding {
2958 kind: FindingKind::Excess,
2959 subject: "SessionCreate".to_string(),
2960 detail: "asks launchd to create a security session for a job that runs \
2961 outside every login session and has no use for one"
2962 .to_string(),
2963 });
2964 } else {
2965 controls.push("SessionCreate is not requested".to_string());
2966 }
2967 match plist_string_value(text, "UserName").as_deref() {
2968 Some("root") => {
2969 controls.push("UserName=root, stated rather than inherited".to_string())
2970 }
2971 Some(other) => findings.push(PrivilegeFinding {
2972 kind: FindingKind::Shortfall,
2973 subject: "UserName".to_string(),
2974 detail: format!(
2975 "is `{other}`, which cannot unlock the System Keychain: \
2976 /var/db/SystemKey is root-only, so the daemon would start and then \
2977 find no credential"
2978 ),
2979 }),
2980 None => findings.push(PrivilegeFinding {
2981 kind: FindingKind::Shortfall,
2982 subject: "UserName".to_string(),
2983 detail: "is absent, so the account is launchd's implicit default and this \
2984 review cannot confirm it"
2985 .to_string(),
2986 }),
2987 }
2988 }
2989 StartMode::Login => {
2990 if let Some(named) = plist_string_value(text, "UserName") {
2991 findings.push(PrivilegeFinding {
2992 kind: FindingKind::Excess,
2993 subject: "UserName".to_string(),
2994 detail: format!(
2995 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2996 naming an account here asks launchd for a switch a login-mode \
2997 registration has no reason to want"
2998 ),
2999 });
3000 } else {
3001 controls
3002 .push("no UserName: the agent runs as the operator and no other".to_string());
3003 }
3004 }
3005 }
3006
3007 review_inbound_surface(
3008 text,
3009 &[
3010 (
3011 "<key>Sockets</key>",
3012 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
3013 forbids the product to have",
3014 ),
3015 (
3016 "<key>MachServices</key>",
3017 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
3018 forbids the product to have",
3019 ),
3020 ],
3021 controls,
3022 findings,
3023 );
3024}
3025
3026fn review_scheduled_task(
3027 text: &str,
3028 controls: &mut Vec<String>,
3029 findings: &mut Vec<PrivilegeFinding>,
3030) {
3031 match xml_value(text, "RunLevel").as_deref() {
3032 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
3033 Some(other) => findings.push(PrivilegeFinding {
3034 kind: FindingKind::Excess,
3035 subject: "RunLevel".to_string(),
3036 detail: format!(
3037 "is `{other}`, so the task runs with an elevated token whenever the operator is \
3038 an administrator"
3039 ),
3040 }),
3041 None => findings.push(PrivilegeFinding {
3042 kind: FindingKind::Excess,
3043 subject: "RunLevel".to_string(),
3044 detail: "is absent, so Task Scheduler decides the token rather than the definition"
3045 .to_string(),
3046 }),
3047 }
3048
3049 match xml_value(text, "LogonType").as_deref() {
3050 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
3051 Some(other) => findings.push(PrivilegeFinding {
3052 kind: FindingKind::Excess,
3053 subject: "LogonType".to_string(),
3054 detail: format!(
3055 "is `{other}`, which asks Windows to store or synthesise a credential for this \
3056 task; an interactive token needs neither"
3057 ),
3058 }),
3059 None => findings.push(PrivilegeFinding {
3060 kind: FindingKind::Shortfall,
3061 subject: "LogonType".to_string(),
3062 detail: "is absent, so this review cannot confirm that no credential is stored"
3063 .to_string(),
3064 }),
3065 }
3066}
3067
3068fn review_windows_service(
3069 text: &str,
3070 plan: &InstallPlan,
3071 controls: &mut Vec<String>,
3072 findings: &mut Vec<PrivilegeFinding>,
3073) {
3074 let directives = ini_directives(text, "windows-service");
3075
3076 match directives.get("ServiceType").map(String::as_str) {
3077 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
3078 Some(other) => findings.push(PrivilegeFinding {
3079 kind: FindingKind::Excess,
3080 subject: "ServiceType".to_string(),
3081 detail: format!(
3082 "is `{other}`; an interactive or shared-process service reaches further than a \
3083 daemon that only talks to GitHub over HTTPS"
3084 ),
3085 }),
3086 None => findings.push(PrivilegeFinding {
3087 kind: FindingKind::Shortfall,
3088 subject: "ServiceType".to_string(),
3089 detail: "is absent, so this review cannot confirm the service is not interactive"
3090 .to_string(),
3091 }),
3092 }
3093
3094 match directives.get("Account").map(String::as_str) {
3098 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
3099 controls.push(format!(
3100 "Account={account}: the only stock account the machine-scoped store's DACL \
3101 (SY, BA, OW) admits"
3102 ));
3103 }
3104 Some(other) => findings.push(PrivilegeFinding {
3105 kind: FindingKind::Shortfall,
3106 subject: "Account".to_string(),
3107 detail: format!(
3108 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
3109 daemon would start and then find no credential. Widening that DACL is not this \
3110 registration's to do: an ACE reaching `{other}` would also reach every other \
3111 service running under it"
3112 ),
3113 }),
3114 None => findings.push(PrivilegeFinding {
3115 kind: FindingKind::Shortfall,
3116 subject: "Account".to_string(),
3117 detail: "is absent, so this review cannot confirm which account was registered"
3118 .to_string(),
3119 }),
3120 }
3121
3122 match directives.get("ReadWritePaths") {
3123 Some(value) => {
3124 let listed = split_quoted(value);
3125 review_writable_paths(
3126 DefinitionKind::WindowsService,
3127 "ReadWritePaths",
3128 &listed,
3129 plan,
3130 controls,
3131 findings,
3132 );
3133 }
3134 None => findings.push(PrivilegeFinding {
3135 kind: FindingKind::Shortfall,
3136 subject: "ReadWritePaths".to_string(),
3137 detail: "is absent, so the directories the service was installed against are not \
3138 recorded"
3139 .to_string(),
3140 }),
3141 }
3142}
3143
3144fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
3152 let mut out = BTreeMap::new();
3153 let mut inside = false;
3154 for line in text.lines() {
3155 let line = line.trim();
3156 if line.starts_with('[') && line.ends_with(']') {
3157 inside = &line[1..line.len() - 1] == section;
3158 continue;
3159 }
3160 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
3161 continue;
3162 }
3163 if let Some((key, value)) = line.split_once('=') {
3164 out.insert(key.trim().to_string(), value.trim().to_string());
3165 }
3166 }
3167 out
3168}
3169
3170#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3177fn systemd_exec_command_line(value: &str) -> String {
3178 let value = value.trim();
3179 let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
3180 if !value[..value.len() - command.len()].contains('@') {
3181 return command.to_string();
3182 }
3183 let mut words = split_command_line(command);
3184 if words.len() > 1 {
3185 words.remove(1);
3186 }
3187 words
3188 .iter()
3189 .map(|word| quote_argument(word))
3190 .collect::<Vec<_>>()
3191 .join(" ")
3192}
3193
3194#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3198fn systemd_timespan(value: &str) -> Option<Duration> {
3199 let mut rest = value.trim();
3200 if rest.is_empty() || rest == "infinity" {
3201 return None;
3202 }
3203 let mut seconds = 0f64;
3204 while !rest.is_empty() {
3205 let digits = rest
3206 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3207 .unwrap_or(rest.len());
3208 if digits == 0 {
3209 return None;
3210 }
3211 let number: f64 = rest[..digits].parse().ok()?;
3212 rest = rest[digits..].trim_start();
3213 let letters = rest
3214 .find(|c: char| !c.is_alphabetic())
3215 .unwrap_or(rest.len());
3216 let scale = match &rest[..letters] {
3217 "" | "s" | "sec" | "second" | "seconds" => 1.0,
3218 "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3219 "ms" | "msec" => 1e-3,
3220 "m" | "min" | "minute" | "minutes" => 60.0,
3221 "h" | "hr" | "hour" | "hours" => 3_600.0,
3222 "d" | "day" | "days" => 86_400.0,
3223 "w" | "week" | "weeks" => 604_800.0,
3224 "M" | "month" | "months" => 2_629_800.0,
3225 "y" | "year" | "years" => 31_557_600.0,
3226 _ => return None,
3227 };
3228 seconds += number * scale;
3229 rest = rest[letters..].trim_start();
3230 }
3231 Duration::try_from_secs_f64(seconds).ok()
3232}
3233
3234fn split_quoted(value: &str) -> Vec<String> {
3237 let mut out = Vec::new();
3238 let mut rest = value.trim();
3239 while !rest.is_empty() {
3240 if rest.starts_with('"') {
3241 if let Some(parsed) = executable_from_command_line(rest) {
3244 out.push(parsed.to_string_lossy().into_owned());
3245 }
3246 let mut depth = 0usize;
3248 let mut end = rest.len();
3249 for (index, c) in rest.char_indices() {
3250 match c {
3251 '\\' => depth += 1,
3252 '"' => {
3253 if depth.is_multiple_of(2) && index > 0 {
3254 end = index + 1;
3255 break;
3256 }
3257 depth = 0;
3258 }
3259 _ => depth = 0,
3260 }
3261 }
3262 rest = rest[end..].trim_start();
3263 } else {
3264 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3265 out.push(rest[..end].to_string());
3266 rest = rest[end..].trim_start();
3267 }
3268 }
3269 out
3270}
3271
3272pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3284 let open = format!("<{tag}>");
3285 let close = format!("</{tag}>");
3286 let start = text.find(&open)? + open.len();
3287 let end = text[start..].find(&close)? + start;
3288 Some(xml_unescape(text[start..end].trim()))
3289}
3290
3291#[cfg(any(windows, test))]
3297fn windows_login_task_starts_automatically(document: &str) -> bool {
3298 document.contains("<LogonTrigger>")
3299 && xml_value(document, "Enabled").as_deref() != Some("false")
3300}
3301
3302fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3304 let marker = format!("<key>{key}</key>");
3305 let start = text.find(&marker)? + marker.len();
3306 Some(text[start..].trim_start())
3307}
3308
3309pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3310 let rest = plist_value_after_key(text, key)?;
3311 if !rest.starts_with("<string>") {
3312 return None;
3313 }
3314 xml_value(rest, "string")
3315}
3316
3317fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3318 let rest = plist_value_after_key(text, key)?;
3319 if rest.starts_with("<true/>") {
3320 Some(true)
3321 } else if rest.starts_with("<false/>") {
3322 Some(false)
3323 } else {
3324 None
3325 }
3326}
3327
3328pub const RECORD_SCHEMA_VERSION: u32 = 1;
3339
3340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3352pub struct InstallRecord {
3353 pub schema_version: u32,
3355 pub service_name: String,
3357 pub manager: String,
3359 pub start_mode: StartMode,
3361 pub account: ServiceAccount,
3363 pub binary: PathBuf,
3365 #[serde(default)]
3373 pub source_binary: Option<PathBuf>,
3374 pub arguments: Vec<String>,
3376 pub restart_delay_secs: u64,
3378 pub restart_reset_secs: u64,
3380 pub log_file: PathBuf,
3382 #[serde(default)]
3390 pub starts_on_demand: bool,
3391 pub definition_path: Option<PathBuf>,
3393 #[serde(default)]
3396 pub protected_paths: Vec<PathBuf>,
3397 pub installed_at: DateTime<Utc>,
3399 pub installed_by_version: String,
3401 pub directories: ServiceDirectories,
3406}
3407
3408impl InstallRecord {
3409 #[must_use]
3411 pub fn path(paths: &AppPaths) -> PathBuf {
3412 paths.config_dir().join(RECORD_FILE)
3413 }
3414
3415 #[must_use]
3417 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3418 Self {
3419 schema_version: RECORD_SCHEMA_VERSION,
3420 service_name: plan.identity().name().to_string(),
3421 manager: definition.kind().manager().to_string(),
3422 start_mode: plan.start_mode(),
3423 account: plan.account().clone(),
3424 binary: plan.binary().to_path_buf(),
3425 arguments: plan
3426 .arguments()
3427 .iter()
3428 .map(|argument| argument.to_string_lossy().into_owned())
3429 .collect(),
3430 restart_delay_secs: plan.restart().delay().as_secs(),
3431 restart_reset_secs: plan.restart().reset_after().as_secs(),
3432 starts_on_demand: plan.is_on_demand(),
3433 source_binary: plan.source_binary().map(Path::to_path_buf),
3434 log_file: plan.directories().log_file(),
3435 definition_path: definition.install_path().map(Path::to_path_buf),
3436 protected_paths: plan.protected_paths().to_vec(),
3437 installed_at: at,
3438 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3439 directories: plan.directories().clone(),
3440 }
3441 }
3442
3443 #[must_use]
3451 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3452 let plan = InstallPlan::unchecked(
3453 identity,
3454 self.start_mode,
3455 self.binary.clone(),
3456 self.directories.clone(),
3457 )
3458 .with_arguments(self.arguments.clone())
3459 .with_restart(self.restart())
3460 .with_protected_paths(self.protected_paths.clone());
3461 let plan = if self.starts_on_demand {
3462 plan.started_on_demand()
3463 } else {
3464 plan
3465 };
3466 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3467 Ok(store) => plan.with_secret_guard(store.guard()),
3468 Err(_) => plan,
3469 }
3470 }
3471
3472 #[must_use]
3476 pub fn restart(&self) -> RestartPolicy {
3477 RestartPolicy::new(
3478 Duration::from_secs(self.restart_delay_secs),
3479 Duration::from_secs(self.restart_reset_secs),
3480 )
3481 .unwrap_or_default()
3482 }
3483
3484 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3495 let path = Self::path(paths);
3496 let text = match std::fs::read_to_string(&path) {
3497 Ok(text) => text,
3498 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3499 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3502 return Err(ServiceError::RecordNotPermitted {
3503 path,
3504 detail: error.to_string(),
3505 });
3506 }
3507 Err(error) => {
3508 return Err(ServiceError::Record {
3509 operation: "read",
3510 path,
3511 detail: error.to_string(),
3512 });
3513 }
3514 };
3515 let record: Self =
3516 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3517 path: path.clone(),
3518 detail: error.to_string(),
3519 })?;
3520 if record.schema_version != RECORD_SCHEMA_VERSION {
3521 return Err(ServiceError::RecordUnreadable {
3522 path,
3523 detail: format!(
3524 "it declares schema version {} and this build reads version {}",
3525 record.schema_version, RECORD_SCHEMA_VERSION
3526 ),
3527 });
3528 }
3529 Ok(Some(record))
3530 }
3531
3532 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3538 use std::io::Write as _;
3539
3540 let path = Self::path(paths);
3541 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3542 operation: "encode",
3543 path: path.clone(),
3544 detail: error.to_string(),
3545 })?;
3546 if let Some(parent) = path.parent() {
3547 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3548 operation: "write",
3549 path: path.clone(),
3550 detail: error.to_string(),
3551 })?;
3552 }
3553 let parent = path.parent().ok_or_else(|| ServiceError::Record {
3554 operation: "write",
3555 path: path.clone(),
3556 detail: "the record path has no parent directory".to_string(),
3557 })?;
3558 let mut temporary =
3559 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3560 operation: "write",
3561 path: path.clone(),
3562 detail: error.to_string(),
3563 })?;
3564 temporary
3565 .write_all(text.as_bytes())
3566 .and_then(|()| temporary.as_file().sync_all())
3567 .map_err(|error| ServiceError::Record {
3568 operation: "write",
3569 path: path.clone(),
3570 detail: error.to_string(),
3571 })?;
3572 #[cfg(unix)]
3588 {
3589 use std::os::unix::fs::PermissionsExt as _;
3590
3591 temporary
3592 .as_file()
3593 .set_permissions(std::fs::Permissions::from_mode(0o644))
3594 .map_err(|error| ServiceError::Record {
3595 operation: "write",
3596 path: path.clone(),
3597 detail: error.to_string(),
3598 })?;
3599 }
3600 temporary
3601 .persist(&path)
3602 .map(|_| ())
3603 .map_err(|error| ServiceError::Record {
3604 operation: "write",
3605 path,
3606 detail: error.error.to_string(),
3607 })
3608 }
3609
3610 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3616 let path = Self::path(paths);
3617 match std::fs::remove_file(&path) {
3618 Ok(()) => Ok(true),
3619 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3620 Err(error) => Err(ServiceError::Record {
3621 operation: "remove",
3622 path,
3623 detail: error.to_string(),
3624 }),
3625 }
3626 }
3627}
3628
3629const CONTACT_SCHEMA_VERSION: u32 = 1;
3635
3636#[derive(Debug, Clone, Serialize, Deserialize)]
3637struct ContactRecord {
3638 schema_version: u32,
3639 last_success: DateTime<Utc>,
3640}
3641
3642pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3663 write_state_record(
3664 &contact_path(paths),
3665 &ContactRecord {
3666 schema_version: CONTACT_SCHEMA_VERSION,
3667 last_success: at,
3668 },
3669 )
3670}
3671
3672pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3684 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3685}
3686
3687#[must_use]
3689pub fn contact_path(paths: &AppPaths) -> PathBuf {
3690 paths.state_dir().join(CONTACT_FILE)
3691}
3692
3693pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3696 let failed = |detail: String| ServiceError::Record {
3697 operation: "write",
3698 path: path.to_path_buf(),
3699 detail,
3700 };
3701 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3702 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3703 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3704 let temporary = path.with_extension("toml.new");
3705 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3706 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3707}
3708
3709pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3712 path: &Path,
3713) -> Result<Option<T>, ServiceError> {
3714 let failed = |detail: String| ServiceError::Record {
3715 operation: "read",
3716 path: path.to_path_buf(),
3717 detail,
3718 };
3719 let text = match std::fs::read_to_string(path) {
3720 Ok(text) => text,
3721 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3722 Err(error) => return Err(failed(error.to_string())),
3723 };
3724 toml::from_str(&text)
3725 .map(Some)
3726 .map_err(|error| failed(error.to_string()))
3727}
3728
3729pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3731 match std::fs::remove_file(path) {
3732 Ok(()) => Ok(()),
3733 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3734 Err(error) => Err(ServiceError::Record {
3735 operation: "remove",
3736 path: path.to_path_buf(),
3737 detail: error.to_string(),
3738 }),
3739 }
3740}
3741
3742#[derive(Debug, Serialize, Deserialize)]
3747#[serde(deny_unknown_fields)]
3748struct CredentialRejectionRecord {
3749 schema_version: u32,
3750 rejected_since: DateTime<Utc>,
3751}
3752
3753pub fn record_github_credential_rejected(
3769 paths: &AppPaths,
3770 at: DateTime<Utc>,
3771) -> Result<(), ServiceError> {
3772 if github_credential_rejected_since(paths)
3773 .ok()
3774 .flatten()
3775 .is_some()
3776 {
3777 return Ok(());
3778 }
3779 write_state_record(
3780 &credential_rejection_path(paths),
3781 &CredentialRejectionRecord {
3782 schema_version: CONTACT_SCHEMA_VERSION,
3783 rejected_since: at,
3784 },
3785 )
3786}
3787
3788pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3794 remove_state_record(&credential_rejection_path(paths))
3795}
3796
3797pub fn github_credential_rejected_since(
3803 paths: &AppPaths,
3804) -> Result<Option<DateTime<Utc>>, ServiceError> {
3805 Ok(
3806 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3807 .map(|record| record.rejected_since),
3808 )
3809}
3810
3811#[must_use]
3813pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3814 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3815}
3816
3817#[derive(Debug, Serialize, Deserialize)]
3818#[serde(deny_unknown_fields)]
3819struct CredentialUnreadableRecord {
3820 schema_version: u32,
3821 unreadable_since: DateTime<Utc>,
3822}
3823
3824pub fn record_credential_unreadable(
3843 paths: &AppPaths,
3844 at: DateTime<Utc>,
3845) -> Result<(), ServiceError> {
3846 if credential_unreadable_since(paths).ok().flatten().is_some() {
3847 return Ok(());
3848 }
3849 write_state_record(
3850 &credential_unreadable_path(paths),
3851 &CredentialUnreadableRecord {
3852 schema_version: CONTACT_SCHEMA_VERSION,
3853 unreadable_since: at,
3854 },
3855 )
3856}
3857
3858pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3864 remove_state_record(&credential_unreadable_path(paths))
3865}
3866
3867pub fn credential_unreadable_since(
3874 paths: &AppPaths,
3875) -> Result<Option<DateTime<Utc>>, ServiceError> {
3876 Ok(
3877 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3878 .map(|record| record.unreadable_since),
3879 )
3880}
3881
3882#[must_use]
3884pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3885 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3886}
3887
3888#[must_use]
3896pub fn sign_in_instruction(mode: StartMode) -> String {
3897 let (command, place) = match mode {
3898 StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3899 StartMode::Boot => (
3900 "runner-manager auth login --start-at boot".to_owned(),
3901 " from an elevated terminal",
3902 ),
3903 StartMode::Login => (
3904 "runner-manager auth login --start-at login".to_owned(),
3905 MACOS_LOGIN_PLACE,
3906 ),
3907 };
3908 format!("`{command}`{place}")
3909}
3910
3911#[must_use]
3920pub fn sudo_command(arguments: &str) -> String {
3921 let executable = if cfg!(test) {
3924 None
3925 } else {
3926 std::env::current_exe().ok()
3927 };
3928 format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
3929}
3930
3931fn sudo_program(executable: Option<&Path>) -> String {
3933 const SECURE_PATH: [&str; 7] = [
3936 "/usr/local/sbin",
3937 "/usr/local/bin",
3938 "/usr/sbin",
3939 "/usr/bin",
3940 "/sbin",
3941 "/bin",
3942 "/snap/bin",
3943 ];
3944 let Some(executable) = executable else {
3945 return "runner-manager".to_owned();
3946 };
3947 if executable
3948 .parent()
3949 .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
3950 {
3951 return "runner-manager".to_owned();
3952 }
3953 let text = executable.to_string_lossy();
3954 if text
3955 .chars()
3956 .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
3957 {
3958 text.into_owned()
3959 } else {
3960 format!("'{}'", text.replace('\'', "'\\''"))
3961 }
3962}
3963
3964pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3967 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3968 keychain)"
3969} else {
3970 ""
3971};
3972
3973#[derive(Debug, Clone, PartialEq, Eq)]
3990pub struct DefinitionDrift {
3991 pub path: PathBuf,
3993 pub installed_by_version: String,
3995 pub rendered: String,
3997 pub start_mode: StartMode,
3999 pub manager: DefinitionKind,
4001}
4002
4003fn definition_drift(
4011 controls: &dyn ControlFactory,
4012 identity: &ServiceIdentity,
4013 record: &InstallRecord,
4014) -> Option<DefinitionDrift> {
4015 let compare = match host_definition_kind(record.start_mode) {
4016 DefinitionKind::SystemdUnit => systemd_definition_drift,
4017 DefinitionKind::LaunchdPlist => launchd_definition_drift,
4018 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
4019 };
4020 let path = record.definition_path.clone()?;
4021 let installed = controls.installed_definition(&path)?;
4022 compare(identity, record, path, &installed)
4023}
4024
4025fn launchd_definition_drift(
4034 identity: &ServiceIdentity,
4035 record: &InstallRecord,
4036 path: PathBuf,
4037 installed: &str,
4038) -> Option<DefinitionDrift> {
4039 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
4040 || DefinitionDrift {
4041 path,
4042 installed_by_version: record.installed_by_version.clone(),
4043 rendered: launchd_plist(&record.plan(identity.clone())),
4044 start_mode: record.start_mode,
4045 manager: DefinitionKind::LaunchdPlist,
4046 },
4047 )
4048}
4049
4050fn systemd_definition_drift(
4051 identity: &ServiceIdentity,
4052 record: &InstallRecord,
4053 path: PathBuf,
4054 installed: &str,
4055) -> Option<DefinitionDrift> {
4056 let rendered = systemd_unit(&record.plan(identity.clone()));
4057 (installed != rendered).then(|| DefinitionDrift {
4058 path,
4059 installed_by_version: record.installed_by_version.clone(),
4060 rendered,
4061 start_mode: record.start_mode,
4062 manager: DefinitionKind::SystemdUnit,
4063 })
4064}
4065
4066const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
4071
4072#[derive(Debug, Clone, Serialize, Deserialize)]
4073struct RootRefusalFile {
4074 schema_version: u32,
4075 #[serde(default)]
4077 refusals: BTreeMap<String, RootRefusalEntry>,
4078}
4079
4080#[derive(Debug, Clone, Serialize, Deserialize)]
4081struct RootRefusalEntry {
4082 at: DateTime<Utc>,
4083 kind: String,
4084 root: String,
4085 detail: String,
4086}
4087
4088#[derive(Debug, Clone, PartialEq, Eq)]
4090pub struct RunnerRootRefusal {
4091 pub policy: String,
4093 pub at: DateTime<Utc>,
4095 pub kind: String,
4097 pub root: String,
4099 pub detail: String,
4102}
4103
4104pub fn record_runner_root_refusal(
4138 paths: &AppPaths,
4139 policy: &str,
4140 at: DateTime<Utc>,
4141 kind: &str,
4142 root: &str,
4143 detail: &str,
4144) -> Result<(), ServiceError> {
4145 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
4146 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
4147 refusals: BTreeMap::new(),
4148 });
4149 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
4150 file.refusals.insert(
4151 policy.to_owned(),
4152 RootRefusalEntry {
4153 at,
4154 kind: kind.to_owned(),
4155 root: root.to_owned(),
4156 detail: detail.to_owned(),
4157 },
4158 );
4159 write_refusal_file(paths, &file)
4160}
4161
4162pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
4173 let Some(mut file) = read_refusal_file(paths)? else {
4174 return Ok(());
4175 };
4176 if file.refusals.remove(policy).is_none() {
4177 return Ok(());
4178 }
4179 if file.refusals.is_empty() {
4180 let path = root_refusal_path(paths);
4181 return match std::fs::remove_file(&path) {
4182 Ok(()) => Ok(()),
4183 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4184 Err(error) => Err(ServiceError::Record {
4185 operation: "remove",
4186 path,
4187 detail: error.to_string(),
4188 }),
4189 };
4190 }
4191 write_refusal_file(paths, &file)
4192}
4193
4194pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4206 Ok(read_refusal_file(paths)?
4207 .map(|file| {
4208 file.refusals
4209 .into_iter()
4210 .map(|(policy, entry)| RunnerRootRefusal {
4211 policy,
4212 at: entry.at,
4213 kind: entry.kind,
4214 root: entry.root,
4215 detail: entry.detail,
4216 })
4217 .collect()
4218 })
4219 .unwrap_or_default())
4220}
4221
4222fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4223 read_state_record(&root_refusal_path(paths))
4224}
4225
4226fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4227 write_state_record(&root_refusal_path(paths), file)
4228}
4229
4230#[must_use]
4232pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4233 paths.state_dir().join(ROOT_REFUSAL_FILE)
4234}
4235
4236#[derive(Debug, Clone, PartialEq, Eq)]
4247pub enum BinaryPath {
4248 Current {
4250 path: PathBuf,
4252 },
4253 Missing {
4260 recorded: PathBuf,
4262 },
4263 NotExecutable {
4266 recorded: PathBuf,
4268 detail: String,
4270 },
4271 Diverged {
4273 recorded: PathBuf,
4275 registered: PathBuf,
4277 },
4278}
4279
4280impl BinaryPath {
4281 #[must_use]
4283 pub const fn is_error(&self) -> bool {
4284 !matches!(self, Self::Current { .. })
4285 }
4286
4287 #[must_use]
4289 pub fn recorded(&self) -> &Path {
4290 match self {
4291 Self::Current { path } => path,
4292 Self::Missing { recorded }
4293 | Self::NotExecutable { recorded, .. }
4294 | Self::Diverged { recorded, .. } => recorded,
4295 }
4296 }
4297}
4298
4299impl fmt::Display for BinaryPath {
4300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4301 match self {
4302 Self::Current { path } => write!(f, "{}", path.display()),
4303 Self::Missing { recorded } => write!(
4304 f,
4305 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4306 package manager that moved the binary is the usual cause; an `npm i -g` \
4307 installation moves with the active Node version. Run `service install` again \
4308 from the binary that is now installed.",
4309 recorded.display()
4310 ),
4311 Self::NotExecutable { recorded, detail } => write!(
4312 f,
4313 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4314 from the installed binary.",
4315 recorded.display()
4316 ),
4317 Self::Diverged {
4318 recorded,
4319 registered,
4320 } => write!(
4321 f,
4322 "{} -- STALE: the service manager is registered to start {} instead. Something \
4323 has edited the registration since it was installed. Run `service uninstall` and \
4324 `service install`; neither touches configuration, secrets, or the cache.",
4325 recorded.display(),
4326 registered.display()
4327 ),
4328 }
4329 }
4330}
4331
4332#[must_use]
4340pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4341 match std::fs::metadata(recorded) {
4342 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4343 return BinaryPath::Missing {
4344 recorded: recorded.to_path_buf(),
4345 };
4346 }
4347 Err(error) => {
4348 return BinaryPath::NotExecutable {
4349 recorded: recorded.to_path_buf(),
4350 detail: format!("it cannot be inspected ({error})"),
4351 };
4352 }
4353 Ok(metadata) if !metadata.is_file() => {
4354 return BinaryPath::NotExecutable {
4355 recorded: recorded.to_path_buf(),
4356 detail: "what is there is not a file".to_string(),
4357 };
4358 }
4359 Ok(_) => {}
4360 }
4361 if let Some(registered) = registered
4362 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
4363 {
4364 return BinaryPath::Diverged {
4365 recorded: recorded.to_path_buf(),
4366 registered: registered.to_path_buf(),
4367 };
4368 }
4369 BinaryPath::Current {
4370 path: recorded.to_path_buf(),
4371 }
4372}
4373
4374#[derive(Debug, Clone, PartialEq, Eq)]
4380pub struct Registration {
4381 pub manager: DefinitionKind,
4386 pub start_mode: StartMode,
4388 pub command_line: String,
4390 pub account: Option<String>,
4397 pub running: bool,
4399 pub starts_automatically: bool,
4406 pub restart_delay: Option<Duration>,
4415}
4416
4417impl Registration {
4418 #[must_use]
4420 pub fn binary(&self) -> Option<PathBuf> {
4421 executable_from_command_line(&self.command_line)
4422 }
4423}
4424
4425pub trait ServiceControl: fmt::Debug {
4431 fn manager(&self) -> DefinitionKind;
4433
4434 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4440
4441 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4452
4453 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4459
4460 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4467
4468 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4474}
4475
4476pub trait ControlFactory: fmt::Debug + Send + Sync {
4482 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4488
4489 fn installed_definition(&self, _path: &Path) -> Option<String> {
4493 None
4494 }
4495
4496 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4501 None
4502 }
4503}
4504
4505#[derive(Debug, Clone, Copy, Default)]
4507pub struct HostControls;
4508
4509#[derive(Debug, Clone)]
4515pub struct Installed {
4516 pub plan: InstallPlan,
4518 pub definition: ServiceDefinition,
4520 pub record: InstallRecord,
4522 pub review: PrivilegeReview,
4524 pub runner_root: RootAccessSummary,
4529 pub replaced_existing: bool,
4537}
4538
4539#[derive(Debug, Clone, PartialEq, Eq)]
4541pub struct Uninstalled {
4542 pub removed_registration: bool,
4544 pub removed_record: bool,
4546 pub removed_definition: Option<PathBuf>,
4548 pub preserved: Vec<PathBuf>,
4555}
4556
4557impl fmt::Display for Uninstalled {
4558 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4559 if self.removed_registration {
4560 writeln!(f, "The service registration was removed.")?;
4561 } else {
4562 writeln!(f, "There was no service registration to remove.")?;
4563 }
4564 writeln!(f, "Nothing else was deleted. These are untouched:")?;
4565 for path in &self.preserved {
4566 writeln!(f, " {}", path.display())?;
4567 }
4568 write!(
4569 f,
4570 "The stored GitHub token is untouched too; `auth logout` is what purges it."
4571 )
4572 }
4573}
4574
4575#[derive(Debug, Clone, PartialEq, Eq)]
4577pub struct StartModeChange {
4578 pub from: StartMode,
4580 pub to: StartMode,
4582 pub changed: bool,
4584 pub store_scope: crate::secrets::SecretScope,
4587 pub runner_root: RootAccessSummary,
4593}
4594
4595impl fmt::Display for StartModeChange {
4596 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4597 if !self.changed {
4598 return write!(f, "The service already starts at {}.", self.to);
4599 }
4600 write!(
4601 f,
4602 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4603 if the token was stored under the other scope, run `auth login` again. {}",
4604 self.to, self.from, self.store_scope, self.runner_root
4605 )
4606 }
4607}
4608
4609#[derive(Debug, Clone)]
4617pub struct ServiceOperations {
4618 paths: AppPaths,
4619 identity: ServiceIdentity,
4620 controls: std::sync::Arc<dyn ControlFactory>,
4621 runner_root: Option<LocalAbsolutePath>,
4622}
4623
4624impl ServiceOperations {
4625 #[must_use]
4627 pub fn on_this_host(paths: AppPaths) -> Self {
4628 Self::with_controls(
4629 paths,
4630 ServiceIdentity::product(),
4631 std::sync::Arc::new(HostControls),
4632 )
4633 }
4634
4635 #[must_use]
4643 pub fn with_controls(
4644 paths: AppPaths,
4645 identity: ServiceIdentity,
4646 controls: std::sync::Arc<dyn ControlFactory>,
4647 ) -> Self {
4648 Self {
4649 paths,
4650 identity,
4651 controls,
4652 runner_root: None,
4653 }
4654 }
4655
4656 #[must_use]
4675 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4676 self.runner_root = Some(root);
4677 self
4678 }
4679
4680 #[must_use]
4682 pub const fn paths(&self) -> &AppPaths {
4683 &self.paths
4684 }
4685
4686 #[must_use]
4688 pub const fn identity(&self) -> &ServiceIdentity {
4689 &self.identity
4690 }
4691
4692 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4712 self.paths
4713 .create_all()
4714 .map_err(|source| ServiceError::Paths {
4715 source: Box::new(source),
4716 })?;
4717
4718 let _guard = self.refuse_while_an_agent_runs()?;
4721 let adopted = self.adopt_boot_installation(request.start_mode())?;
4722 if !adopted {
4726 self.refuse_foreign("replace")?;
4727 }
4728
4729 let (replacing, previous_was_running) = match self.find_registration()? {
4758 Some((existing, registration)) if existing == request.start_mode() => {
4759 (true, registration.running)
4760 }
4761 Some((existing, _)) => {
4762 return Err(ServiceError::AlreadyInstalled {
4763 name: self.identity.name().to_string(),
4764 existing,
4765 requested: request.start_mode(),
4766 });
4767 }
4768 None => (false, false),
4769 };
4770
4771 let plan = InstallPlan::resolve(
4772 self.identity.clone(),
4773 request,
4774 ServiceDirectories::of(&self.paths),
4775 )?;
4776
4777 let control = self.controls.control(plan.start_mode())?;
4782
4783 let root = self.prepare_runner_root(plan.start_mode())?;
4789
4790 let previous = if replacing {
4795 InstallRecord::read(&self.paths).ok().flatten()
4796 } else {
4797 None
4798 };
4799 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4800 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4801 }
4802
4803 let definition = match control.install(&plan) {
4804 Ok(definition) => definition,
4805 Err(cause) => {
4806 let restored =
4809 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4810 return Err(rolled_back(
4811 retained_runner_root(&root),
4812 restored,
4813 "install",
4814 &self.identity,
4815 cause,
4816 ));
4817 }
4818 };
4819 let review = review_least_privilege(&definition, &plan);
4820 let record = InstallRecord::of(&plan, &definition, Utc::now());
4821 if let Err(cause) = record.write(&self.paths) {
4822 let rollback = control
4823 .uninstall(&self.identity)
4824 .map(|_| ())
4825 .and_then(|()| {
4826 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4827 });
4828 return Err(rolled_back(
4829 retained_runner_root(&root),
4830 rollback,
4831 "install",
4832 &self.identity,
4833 cause,
4834 ));
4835 }
4836
4837 if let Err(cause) = control.start(&self.identity) {
4844 let remove_new = control.uninstall(&self.identity).map(|_| ());
4845 let restore_registration = remove_new.and_then(|()| {
4846 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4847 });
4848 let restore_record = restore_registration.and_then(|()| match &previous {
4849 Some(previous) => previous.write(&self.paths),
4850 None => InstallRecord::remove(&self.paths).map(|_| ()),
4851 });
4852 return Err(rolled_back(
4853 retained_runner_root(&root),
4854 restore_record,
4855 "install and start",
4856 &self.identity,
4857 cause,
4858 ));
4859 }
4860 Ok(Installed {
4861 plan,
4862 definition,
4863 record,
4864 review,
4865 runner_root: root.summary().clone(),
4866 replaced_existing: replacing,
4867 })
4868 }
4869
4870 fn reinstate(
4881 &self,
4882 control: &dyn ServiceControl,
4883 previous: Option<&InstallRecord>,
4884 was_running: bool,
4885 ) -> Result<(), ServiceError> {
4886 let Some(record) = previous else {
4887 return Ok(());
4888 };
4889 control.install(&record.plan(self.identity.clone()))?;
4890 if was_running {
4891 control.start(&self.identity)?;
4892 }
4893 Ok(())
4894 }
4895
4896 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4905 let record = InstallRecord::read(&self.paths).ok().flatten();
4906 let removed_definition = record
4907 .as_ref()
4908 .and_then(|record| record.definition_path.clone());
4909
4910 let mut removed_registration = false;
4911 let mut foreign = None;
4918 for mode in [StartMode::Boot, StartMode::Login] {
4919 let control = self.controls.control(mode)?;
4920 if let Some(directories) = control
4921 .query(&self.identity)
4922 .ok()
4923 .flatten()
4924 .and_then(|registration| self.foreign_directories(®istration))
4925 {
4926 foreign = Some(directories);
4927 continue;
4928 }
4929 if control.uninstall(&self.identity)? {
4930 removed_registration = true;
4931 }
4932 }
4933 if !removed_registration && let Some(directories) = foreign {
4934 return Err(self.foreign_refusal("uninstall", directories));
4935 }
4936 let removed_record = InstallRecord::remove(&self.paths)?;
4937 Ok(Uninstalled {
4938 removed_registration,
4939 removed_record,
4940 removed_definition: removed_definition.filter(|_| removed_registration),
4941 preserved: self
4942 .paths
4943 .all()
4944 .iter()
4945 .map(|(_, path)| (*path).to_path_buf())
4946 .collect(),
4947 })
4948 }
4949
4950 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4972 let Some(record) = InstallRecord::read(&self.paths)? else {
4973 return Err(ServiceError::NotInstalled {
4974 name: self.identity.name().to_string(),
4975 operation: "switch the start mode of",
4976 });
4977 };
4978 let from = record.start_mode;
4979 if from == to {
4980 return Ok(StartModeChange {
4984 from,
4985 to,
4986 changed: false,
4987 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4988 runner_root: RootAccessSummary::NotApplicable,
4989 });
4990 }
4991
4992 self.refuse_foreign("switch the start mode of")?;
4995
4996 #[cfg(windows)]
4997 let arguments = {
4998 let mut arguments = record.arguments.clone();
4999 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
5000 if to == StartMode::Boot {
5001 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
5002 }
5003 arguments
5004 };
5005 #[cfg(not(windows))]
5006 let arguments = record.arguments.clone();
5007 let plan = InstallPlan::unchecked(
5008 self.identity.clone(),
5009 to,
5010 record.binary.clone(),
5011 record.directories.clone(),
5012 )
5013 .with_arguments(arguments)
5014 .with_restart(record.restart());
5015 let plan = if record.starts_on_demand {
5016 plan.started_on_demand()
5017 } else {
5018 plan
5019 };
5020 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
5021 Ok(store) => plan.with_secret_guard(store.guard()),
5022 Err(_) => plan,
5023 };
5024
5025 let target = self.controls.control(to)?;
5032 let previous = self.controls.control(from)?;
5037
5038 let root = self.prepare_runner_root(to)?;
5046
5047 let definition = match target.install(&plan) {
5048 Ok(definition) => definition,
5049 Err(cause) => {
5050 return Err(undo_runner_root(
5051 &root,
5052 "switch start mode",
5053 &self.identity,
5054 cause,
5055 ));
5056 }
5057 };
5058 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
5059 if let Err(cause) = next_record.write(&self.paths) {
5060 return Err(rolled_back(
5061 retained_runner_root(&root),
5062 target.uninstall(&self.identity),
5063 "switch start mode",
5064 &self.identity,
5065 cause,
5066 ));
5067 }
5068
5069 if let Err(cause) = previous.uninstall(&self.identity) {
5073 let target_rollback = target.uninstall(&self.identity);
5074 let record_rollback = record.write(&self.paths);
5075 return Err(rolled_back(
5076 retained_runner_root(&root),
5077 target_rollback.and(record_rollback),
5078 "switch start mode",
5079 &self.identity,
5080 cause,
5081 ));
5082 }
5083 Ok(StartModeChange {
5084 from,
5085 to,
5086 changed: true,
5087 store_scope: crate::secrets::SecretScope::for_start_mode(to),
5088 runner_root: root.summary().clone(),
5089 })
5090 }
5091
5092 pub fn start(&self) -> Result<(), ServiceError> {
5098 let mode = self.own_registration_mode("start")?;
5099 self.controls.control(mode)?.start(&self.identity)
5100 }
5101
5102 fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
5110 match self.registration_for_status()? {
5111 None => Err(ServiceError::NotInstalled {
5112 name: self.identity.name().to_string(),
5113 operation,
5114 }),
5115 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5116 Some((registration, None)) => Ok(registration.start_mode),
5117 }
5118 }
5119
5120 pub fn stop(&self) -> Result<bool, ServiceError> {
5126 let mode = self.own_registration_mode("stop")?;
5127 self.controls.control(mode)?.stop(&self.identity)
5128 }
5129
5130 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
5161 match self.registration_for_status()? {
5162 Some((registration, Some(directories))) => {
5163 Ok(self.foreign_status(registration, directories))
5164 }
5165 found => {
5166 let (record, record_refused) = match InstallRecord::read(&self.paths) {
5167 Ok(record) => (record, None),
5168 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
5169 Err(error) => return Err(error),
5170 };
5171 self.status_at(
5172 &self.paths,
5173 record,
5174 record_refused.as_ref(),
5175 found.map(|(registration, _)| registration),
5176 )
5177 }
5178 }
5179 }
5180
5181 fn status_at(
5184 &self,
5185 paths: &AppPaths,
5186 record: Option<InstallRecord>,
5187 record_refused: Option<&ServiceError>,
5188 found: Option<Registration>,
5189 ) -> Result<ServiceStatus, ServiceError> {
5190 let last_github_contact = last_github_contact(paths)?;
5191 let drift = record
5192 .as_ref()
5193 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5194 let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5195 let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5196 let probed = self.controls.unattended_login();
5197 let unattended = record.as_ref().and_then(|record| {
5198 let account = probed.as_ref().map_or_else(String::new, |_| {
5201 found
5202 .as_ref()
5203 .and_then(|registration| registration.account.clone())
5204 .unwrap_or_else(|| service_account_name(&record.account))
5205 });
5206 unattended_gap(record.start_mode, &account, probed.as_ref())
5207 });
5208 let now = Utc::now();
5209 let installed = record.is_some() || found.is_some();
5210 let status = ServiceStatus::compose(
5211 self.identity.clone(),
5212 record,
5213 record_refused,
5214 found,
5215 last_github_contact,
5216 paths,
5217 )
5218 .with_definition_drift(drift)
5219 .with_credential_rejection(credential_rejected_since)
5220 .with_credential_unreadable(credential_unreadable_since)
5221 .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5222 .with_unattended_gap(unattended)
5223 .with_host_unfit(
5224 crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5225 );
5226 let restart = status.restart_command();
5227 Ok(status.with_stall(
5228 crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5229 ))
5230 }
5231
5232 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5239 Ok(InstallRecord::read(&self.paths)?
5240 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5241 }
5242
5243 #[cfg(target_os = "macos")]
5265 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5266 let root = unsafe { libc::getuid() } == 0;
5272 if root != (drift.start_mode == StartMode::Boot) {
5273 return Err(ServiceError::Control {
5274 operation: "rewrite",
5275 name: self.identity.name().to_string(),
5276 manager: "launchd",
5277 detail: format!(
5278 "the install record says --start-at {}, but this daemon runs as {}; the plist \
5279 is left as it is",
5280 drift.start_mode,
5281 if root { "root" } else { "the operator" }
5282 ),
5283 });
5284 }
5285 replace_definition(self.identity.name(), drift)?;
5286 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5290 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5291 let _ = record.write(&self.paths);
5292 }
5293 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5294 }
5295
5296 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5306 #[cfg(not(windows))]
5307 {
5308 let _ = (mode, &self.runner_root);
5311 Ok(RootAccessChange::not_applicable())
5312 }
5313 #[cfg(windows)]
5314 {
5315 let wrap = |source| ServiceError::RunnerRoot {
5316 source: Box::new(source),
5317 };
5318 if let Some(root) = self
5336 .runner_root
5337 .as_ref()
5338 .filter(|_| self.identity.is_fixture() || cfg!(test))
5339 {
5340 let admission = RootAdmission::of_this_account().map_err(wrap)?;
5341 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5342 .map_err(wrap);
5343 }
5344
5345 let admission = match ServiceAccount::for_start_mode(mode) {
5346 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5349 ServiceAccount::InvokingUser | ServiceAccount::Root => {
5353 RootAdmission::of_this_account().map_err(wrap)?
5354 }
5355 };
5356 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5357 }
5358 }
5359
5360 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5362 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5363 |source| match source {
5364 held @ LockError::Held { .. } => ServiceError::LockHeld {
5365 source: Box::new(held),
5366 },
5367 other => ServiceError::LockUnreadable {
5368 source: Box::new(other),
5369 },
5370 },
5371 )
5372 }
5373
5374 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5376 for mode in [StartMode::Boot, StartMode::Login] {
5377 let control = self.controls.control(mode)?;
5378 if let Some(registration) = control.query(&self.identity)? {
5379 return Ok(Some((registration.start_mode, registration)));
5380 }
5381 }
5382 Ok(None)
5383 }
5384
5385 fn registration_for_status(
5394 &self,
5395 ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5396 let Some((mode, found)) = self.find_registration()? else {
5397 return Ok(None);
5398 };
5399 let Some(directories) = self.foreign_directories(&found) else {
5400 return Ok(Some((found, None)));
5401 };
5402 let other = match mode {
5403 StartMode::Boot => StartMode::Login,
5404 StartMode::Login => StartMode::Boot,
5405 };
5406 let own = self
5407 .controls
5408 .control(other)
5409 .ok()
5410 .and_then(|control| control.query(&self.identity).ok().flatten())
5411 .filter(|registration| self.foreign_directories(registration).is_none());
5412 Ok(Some(match own {
5413 Some(own) => (own, None),
5414 None => (found, Some(directories)),
5415 }))
5416 }
5417
5418 pub fn adopt_boot_installation(&self, mode: StartMode) -> Result<bool, ServiceError> {
5436 #[cfg(target_os = "linux")]
5437 return self.adopt_boot_installation_on_linux(mode);
5438 #[cfg(not(target_os = "linux"))]
5439 {
5440 let _ = mode;
5441 Ok(false)
5442 }
5443 }
5444
5445 #[cfg(target_os = "linux")]
5446 fn adopt_boot_installation_on_linux(&self, mode: StartMode) -> Result<bool, ServiceError> {
5447 if mode != StartMode::Boot {
5448 return Ok(false);
5449 }
5450 self.refuse_overridden_account()?;
5451 if !crate::host_fitness::is_elevated() {
5452 return Ok(false);
5453 }
5454 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5455 return Ok(false);
5456 };
5457 let Some(old) = self.foreign_directories(&found) else {
5458 return Ok(false);
5459 };
5460 self.controls
5461 .control(StartMode::Boot)?
5462 .stop(&self.identity)?;
5463 if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5468 let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5469 for (from, to, skip) in [
5470 (&old.config, self.paths.config_dir(), None),
5471 (&old.state, self.paths.state_dir(), Some("bin")),
5473 ] {
5474 set_aside(to, &stamp)
5475 .and_then(|()| copy_tree(from, to, skip))
5476 .map_err(|error| ServiceError::Control {
5477 operation: "move the data of",
5478 name: self.identity.name().to_string(),
5479 manager: "systemd",
5480 detail: format!(
5481 "cannot copy {} to {}: {error}",
5482 from.display(),
5483 to.display()
5484 ),
5485 })?;
5486 }
5487 }
5488 Ok(true)
5489 }
5490
5491 #[cfg(target_os = "linux")]
5498 fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5499 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5500 return Ok(());
5501 };
5502 match found.account.as_deref() {
5503 Some(account) if account != ServiceAccount::Root.as_str() => {
5504 Err(ServiceError::Control {
5505 operation: "install",
5506 name: self.identity.name().to_string(),
5507 manager: "systemd",
5508 detail: format!(
5509 "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5510 a boot service must run as root to use {}. Remove `User=` and `Group=` \
5511 from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5512 self.identity.systemd_unit(),
5513 crate::paths::SYSTEM_DATA_ROOT,
5514 ),
5515 })
5516 }
5517 _ => Ok(()),
5518 }
5519 }
5520
5521 fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5524 match self.registration_for_status()? {
5525 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5526 _ => Ok(()),
5527 }
5528 }
5529
5530 fn foreign_refusal(
5533 &self,
5534 operation: &'static str,
5535 directories: ServiceDirectories,
5536 ) -> ServiceError {
5537 let foreign = self.foreign_service_at(directories);
5538 ServiceError::ForeignInstallation {
5539 name: self.identity.name().to_string(),
5540 operation,
5541 installed_by: foreign.installed_by(),
5542 act_as: foreign.act_as(),
5543 }
5544 }
5545
5546 fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5549 ServiceDirectories::from_command_line(®istration.command_line)
5550 .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5551 }
5552
5553 #[must_use]
5558 pub fn foreign_service(&self) -> Option<ForeignService> {
5559 let (_, directories) = self.registration_for_status().ok()??;
5560 Some(self.foreign_service_at(directories?))
5561 }
5562
5563 fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5566 let root = directories.root();
5567 let home = host_home();
5568 let this_account = home
5571 .as_deref()
5572 .filter(|home| contains_directory(home, &root))
5573 .and_then(|_| {
5574 if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5575 Some(format!(
5576 "with `--data-dir {}`",
5577 quote_argument(&root.to_string_lossy())
5578 ))
5579 } else {
5580 AppPaths::discover()
5581 .ok()
5582 .filter(|standard| directories == ServiceDirectories::of(standard))
5583 .map(|_| "without `--data-dir`".to_owned())
5584 }
5585 });
5586 ForeignService {
5587 owner: data_root_owner(&root, home.as_deref()),
5588 this_account,
5589 root,
5590 directories,
5591 local_root: ServiceDirectories::of(&self.paths).root(),
5592 local_record: InstallRecord::path(&self.paths),
5593 unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5594 unreadable: None,
5595 }
5596 }
5597
5598 fn foreign_status(
5605 &self,
5606 registration: Registration,
5607 directories: ServiceDirectories,
5608 ) -> ServiceStatus {
5609 let service_paths = directories.paths();
5610 let mut foreign = self.foreign_service_at(directories);
5611 let unreadable = match InstallRecord::read(&service_paths) {
5612 Ok(Some(record)) => {
5613 match self.status_at(
5614 &service_paths,
5615 Some(record),
5616 None,
5617 Some(registration.clone()),
5618 ) {
5619 Ok(status) => return status.with_foreign(foreign),
5620 Err(error) => error.to_string(),
5621 }
5622 }
5623 Ok(None) => format!(
5624 "there is no install record at {}",
5625 InstallRecord::path(&service_paths).display()
5626 ),
5627 Err(
5632 ServiceError::RecordNotPermitted { path, detail }
5633 | ServiceError::RecordUnreadable { path, detail }
5634 | ServiceError::Record { path, detail, .. },
5635 ) => format!("this account cannot read {} ({detail})", path.display()),
5636 Err(error) => error.to_string(),
5637 };
5638 foreign.unreadable = Some(unreadable);
5639 ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5640 }
5641
5642 pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5666 let config = self.paths.config_dir();
5667 let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5668 let mut service_root = None;
5669 for mode in [StartMode::Boot, StartMode::Login] {
5670 let registration = self
5671 .controls
5672 .control(mode)
5673 .and_then(|control| control.query(&self.identity))
5674 .map_err(|error| {
5675 in_use(format!(
5676 "whether a {mode} registration runs against it could not be read ({error})"
5677 ))
5678 })?;
5679 let Some(registration) = registration else {
5680 continue;
5681 };
5682 let Some(directories) = self.foreign_directories(®istration) else {
5683 return Err(in_use(format!(
5684 "{} holds a registration that runs against {}. `service status` describes it",
5685 registration.manager,
5686 config.display()
5687 )));
5688 };
5689 if let Some(overlap) = directories.all().into_iter().find(|directory| {
5690 contains_directory(directory, config) || contains_directory(config, directory)
5691 }) {
5692 return Err(in_use(format!(
5693 "the machine's service runs against {}, which overlaps {}",
5694 overlap.display(),
5695 config.display()
5696 )));
5697 }
5698 service_root.get_or_insert_with(|| directories.root());
5699 }
5700 let _lock =
5701 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5702 in_use(format!(
5703 "an agent may be running against these directories: {source}"
5704 ))
5705 })?;
5706 let failed = |operation: &'static str, path: &Path| {
5707 let path = path.to_path_buf();
5708 move |error: std::io::Error| ServiceError::Record {
5709 operation,
5710 path,
5711 detail: error.to_string(),
5712 }
5713 };
5714 let kept = [
5716 self.paths.state_dir(),
5717 self.paths.runtime_dir(),
5718 self.paths.logs_dir(),
5719 ];
5720 let mut entries = Vec::new();
5721 for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5722 let entry = entry.map_err(failed("list", config))?.path();
5723 let archived = entry
5724 .file_name()
5725 .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5726 if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5727 entries.push(entry);
5728 }
5729 }
5730 if entries.is_empty() {
5731 return Ok(ForgottenLocal {
5732 archive: None,
5733 moved: entries,
5734 service_root,
5735 });
5736 }
5737 entries.sort();
5738 let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5739 std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5740 let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5741 for (index, entry) in entries.iter().enumerate() {
5742 if let Err(error) = std::fs::rename(entry, inside(entry)) {
5743 let stranded: Vec<String> = entries[..index]
5744 .iter()
5745 .rev()
5746 .filter(|done| std::fs::rename(inside(done), done).is_err())
5747 .map(|done| inside(done).display().to_string())
5748 .collect();
5749 let _ = std::fs::remove_dir(&archive);
5750 let mut detail = error.to_string();
5751 if !stranded.is_empty() {
5752 detail.push_str(&format!(
5753 "; these could not be put back and are still in the archive: {}",
5754 stranded.join(", ")
5755 ));
5756 }
5757 return Err(ServiceError::Record {
5758 operation: "archive",
5759 path: entry.clone(),
5760 detail,
5761 });
5762 }
5763 }
5764 Ok(ForgottenLocal {
5765 archive: Some(archive),
5766 moved: entries,
5767 service_root,
5768 })
5769 }
5770}
5771
5772pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5775
5776#[derive(Debug, Clone, PartialEq, Eq)]
5778pub struct ForgottenLocal {
5779 pub archive: Option<PathBuf>,
5781 pub moved: Vec<PathBuf>,
5783 pub service_root: Option<PathBuf>,
5786}
5787
5788impl fmt::Display for ForgottenLocal {
5789 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5790 let Some(archive) = &self.archive else {
5791 return write!(
5792 f,
5793 "Nothing to archive: this account's configuration directory is empty."
5794 );
5795 };
5796 writeln!(f, "Archived this account's unused local configuration")?;
5797 writeln!(f, " archive {}", archive.display())?;
5798 for path in &self.moved {
5799 writeln!(f, " moved {}", path.display())?;
5800 }
5801 if let Some(root) = &self.service_root {
5802 writeln!(
5803 f,
5804 " service data root {} (untouched)",
5805 root.display()
5806 )?;
5807 }
5808 write!(
5809 f,
5810 " restore move everything in the archive back into its parent \
5811 directory"
5812 )
5813 }
5814}
5815
5816fn same_directory(left: &Path, right: &Path) -> bool {
5819 let text = |path: &Path| {
5820 path.to_string_lossy()
5821 .trim_end_matches(['/', '\\'])
5822 .to_owned()
5823 };
5824 same_path_text(&text(left), &text(right))
5825 || matches!(
5826 (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5827 (Ok(left), Ok(right)) if left == right
5828 )
5829}
5830
5831const REMEDY_OPENINGS: [&str; 8] = [
5834 ". Run ",
5835 ". If nothing is waiting",
5836 ". Re-register",
5837 ". Switch the start mode",
5838 " Sign in again",
5839 ". Fix: ",
5840 "; to do it now",
5841 "; if the service manager denies",
5842];
5843
5844fn diagnosis_only(detail: &str) -> String {
5853 let cut = REMEDY_OPENINGS
5854 .iter()
5855 .filter_map(|opening| detail.find(opening))
5856 .min()
5857 .unwrap_or(detail.len());
5858 let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5859 let names_a_change = |text: &str| {
5860 [
5861 "service install",
5862 "service uninstall",
5863 "auth login",
5864 "launchctl",
5865 "systemctl",
5866 ]
5867 .iter()
5868 .any(|command| text.contains(command))
5869 };
5870 if !names_a_change(diagnosis) {
5871 return diagnosis.to_owned();
5872 }
5873 let end = [", ", "; ", ". "]
5875 .iter()
5876 .filter_map(|separator| diagnosis.find(separator))
5877 .min()
5878 .unwrap_or(diagnosis.len());
5879 let first = &diagnosis[..end];
5880 if names_a_change(first) {
5881 "the installation's own records report this".to_owned()
5882 } else {
5883 first.to_owned()
5884 }
5885}
5886
5887fn path_key(path: &Path) -> Vec<String> {
5890 path.components()
5891 .map(|component| {
5892 let text = component.as_os_str().to_string_lossy();
5893 if cfg!(windows) {
5894 text.to_lowercase()
5895 } else {
5896 text.into_owned()
5897 }
5898 })
5899 .collect()
5900}
5901
5902fn contains_directory(outer: &Path, inner: &Path) -> bool {
5905 path_key(inner).starts_with(&path_key(outer))
5906 || matches!(
5907 (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5908 (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5909 )
5910}
5911
5912#[must_use]
5917pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5918 let target = path_key(root);
5919 if cfg!(windows) {
5920 if target
5921 .windows(3)
5922 .any(|run| run == ["system32", "config", "systemprofile"])
5923 {
5924 return Some("LocalSystem".to_owned());
5925 }
5926 } else if [
5927 "/root",
5928 "/var/root",
5929 "/private/var/root",
5930 crate::paths::SYSTEM_DATA_ROOT,
5931 ]
5932 .iter()
5933 .any(|home| root.starts_with(home))
5934 {
5935 return Some("root".to_owned());
5936 }
5937 let users = path_key(home?.parent()?);
5938 if users.len() < 2 {
5941 return None;
5942 }
5943 if target.len() > users.len() && target[..users.len()] == users[..] {
5944 root.components()
5945 .nth(users.len())
5946 .map(|name| name.as_os_str().to_string_lossy().into_owned())
5947 } else {
5948 None
5949 }
5950}
5951
5952#[derive(Debug, Clone, PartialEq, Eq)]
5966pub struct ForeignService {
5967 pub directories: ServiceDirectories,
5969 pub root: PathBuf,
5971 pub owner: Option<String>,
5973 pub this_account: Option<String>,
5978 pub local_root: PathBuf,
5980 pub local_record: PathBuf,
5982 pub unused_local_record: Option<InstallRecord>,
5984 pub unreadable: Option<String>,
5987}
5988
5989impl ForeignService {
5990 #[must_use]
5992 pub fn where_to_look(&self) -> String {
5993 if let Some(reach) = &self.this_account {
5994 return reach.clone();
5995 }
5996 let elevated = if cfg!(windows) {
5997 "from an elevated terminal (Run as administrator)"
5998 } else {
5999 "with sudo"
6000 };
6001 match &self.owner {
6002 Some(owner) if owner != "LocalSystem" && owner != "root" => {
6003 format!("{elevated}, or signed in as {owner}")
6004 }
6005 _ => elevated.to_owned(),
6006 }
6007 }
6008
6009 #[must_use]
6014 pub fn act_as(&self) -> String {
6015 if let Some(reach) = &self.this_account {
6016 return reach.clone();
6017 }
6018 match &self.owner {
6019 Some(owner) if owner != "LocalSystem" && owner != "root" => {
6020 format!("signed in as {owner}")
6021 }
6022 _ => format!(
6023 "from the account whose data root {} is",
6024 self.root.display()
6025 ),
6026 }
6027 }
6028
6029 #[must_use]
6032 pub const fn is_unread(&self) -> bool {
6033 self.unreadable.is_some()
6034 }
6035
6036 #[must_use]
6038 pub fn installed_by(&self) -> String {
6039 match &self.owner {
6040 Some(owner) => format!("{owner}'s data root {}", self.root.display()),
6041 None => format!("the data root {}", self.root.display()),
6042 }
6043 }
6044
6045 #[must_use]
6047 pub fn label(&self) -> String {
6048 match &self.owner {
6049 Some(owner) => format!("{} ({owner})", self.root.display()),
6050 None => self.root.display().to_string(),
6051 }
6052 }
6053
6054 #[must_use]
6056 pub fn unused_local_note(&self) -> Option<String> {
6057 let record = self.unused_local_record.as_ref()?;
6058 Some(format!(
6059 "unused local configuration from an earlier install: {} records a {} registration \
6060 made by runner-manager {} on {}, and no service on this machine runs against it. \
6061 `runner-manager host forget-local` moves it aside; it never touches the service's \
6062 own directories or any secret.",
6063 self.local_record.display(),
6064 record.start_mode,
6065 record.installed_by_version,
6066 record.installed_at.format("%Y-%m-%d"),
6067 ))
6068 }
6069}
6070
6071#[derive(Debug, Clone, PartialEq, Eq)]
6077pub struct StatusProblem {
6078 pub subject: &'static str,
6080 pub detail: String,
6082}
6083
6084impl fmt::Display for StatusProblem {
6085 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6086 write!(f, "{}: {}", self.subject, self.detail)
6087 }
6088}
6089
6090#[derive(Debug, Clone)]
6099pub struct ServiceStatus {
6100 identity: ServiceIdentity,
6101 record: Option<InstallRecord>,
6102 registration: Option<Registration>,
6103 binary: Option<BinaryPath>,
6104 log_file: PathBuf,
6105 store: Option<crate::secrets::ActiveStore>,
6106 last_github_contact: Option<DateTime<Utc>>,
6107 runner_root: Option<(PathBuf, RootAccessReport)>,
6108 credential_rejected_since: Option<DateTime<Utc>>,
6109 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
6110 unattended_gap: Option<UnattendedGap>,
6111 foreign: Option<ForeignService>,
6112 problems: Vec<StatusProblem>,
6113 notes: Vec<String>,
6114}
6115
6116#[derive(Debug, Clone, PartialEq, Eq)]
6119pub struct UnattendedGap {
6120 pub reason: String,
6122 pub remedy: String,
6124}
6125
6126#[must_use]
6129pub fn service_account_name(account: &ServiceAccount) -> String {
6130 match account {
6131 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
6133 .ok()
6134 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
6135 .or_else(crate::unattended_login::current_account)
6136 .unwrap_or_else(|| "the operator".to_owned()),
6137 other => other.as_str().to_owned(),
6138 }
6139}
6140
6141fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
6144 registration
6145 .and_then(|registration| registration.account.as_deref())
6146 .unwrap_or(recorded)
6147}
6148
6149#[cfg(any(windows, test))]
6153fn windows_service_account(name: &str) -> String {
6154 if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
6155 ServiceAccount::LocalSystem.as_str().to_string()
6156 } else {
6157 name.to_string()
6158 }
6159}
6160
6161#[cfg(any(windows, test))]
6164fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
6165 user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
6166}
6167
6168#[must_use]
6177pub fn unattended_gap(
6178 mode: StartMode,
6179 account: &str,
6180 found: Option<&crate::unattended_login::UnattendedLogin>,
6181) -> Option<UnattendedGap> {
6182 let Some(found) = found else {
6183 return (mode == StartMode::Login).then(|| UnattendedGap {
6184 reason: "This registration starts at login, so the agent does not run until the \
6185 operator signs in; this host does not resume work after an unattended \
6186 reboot."
6187 .to_owned(),
6188 remedy: "For machine-on operation, move the service and credential to `--start-at \
6189 boot` from an elevated terminal."
6190 .to_owned(),
6191 });
6192 };
6193 let resume = crate::unattended_login::resume(found, mode, account);
6194 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6195 return None;
6198 }
6199 Some(UnattendedGap {
6200 reason: resume.explain(account)?,
6201 remedy: resume.remedy().unwrap_or_else(|| {
6202 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6203 FileVault can be read."
6204 .to_owned()
6205 }),
6206 })
6207}
6208
6209impl ServiceStatus {
6210 fn compose(
6215 identity: ServiceIdentity,
6216 record: Option<InstallRecord>,
6217 record_refused: Option<&ServiceError>,
6218 registration: Option<Registration>,
6219 last_github_contact: Option<DateTime<Utc>>,
6220 paths: &AppPaths,
6221 ) -> Self {
6222 let mut problems = Vec::new();
6223 let mut notes = Vec::new();
6224
6225 if let Some(refusal) = record_refused {
6226 problems.push(StatusProblem {
6231 subject: "install record",
6232 detail: refusal.to_string(),
6233 });
6234 }
6235
6236 let log_file = record.as_ref().map_or_else(
6237 || ServiceDirectories::of(paths).log_file(),
6238 |record| record.log_file.clone(),
6239 );
6240
6241 let binary = record.as_ref().map(|record| {
6242 inspect_binary(
6243 &record.binary,
6244 registration
6245 .as_ref()
6246 .and_then(Registration::binary)
6247 .as_deref(),
6248 )
6249 });
6250 if let Some(state) = &binary
6251 && state.is_error()
6252 {
6253 problems.push(StatusProblem {
6254 subject: "binary",
6255 detail: state.to_string(),
6256 });
6257 }
6258
6259 match (&record, ®istration) {
6260 (Some(_), None) => problems.push(StatusProblem {
6261 subject: "registration",
6262 detail: "this host has a service record but no service manager knows the \
6263 registration. Run `service install` again; it deletes nothing."
6264 .to_string(),
6265 }),
6266 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6271 subject: "record",
6272 detail: format!(
6273 "{} holds a registration for this service but there is no install record, so \
6274 the path it was installed from and the directories it was installed against \
6275 are unknown. Run `service uninstall` and `service install`.",
6276 found.manager
6277 ),
6278 }),
6279 (None, Some(_)) => {}
6280 (Some(record), Some(found)) => {
6281 if record.start_mode != found.start_mode {
6282 problems.push(StatusProblem {
6283 subject: "start mode",
6284 detail: format!(
6285 "the record says {} and {} holds a {} registration. Switch the start \
6286 mode again to make them agree.",
6287 record.start_mode, found.manager, found.start_mode
6288 ),
6289 });
6290 }
6291 if record.start_mode == StartMode::Boot && !found.starts_automatically {
6292 problems.push(StatusProblem {
6293 subject: "start mode",
6294 detail: format!(
6295 "{} holds the registration but will not start it by itself, so this \
6296 host does not resume work after a reboot.",
6297 found.manager
6298 ),
6299 });
6300 }
6301 if let Some(actual) = found.restart_delay {
6302 let expected = record.restart().effective_delay(found.manager);
6303 if actual != expected {
6304 problems.push(StatusProblem {
6305 subject: "restart policy",
6306 detail: format!(
6307 "the record says the service restarts after {}s and {} reports \
6308 {}s. Something has edited the registration since it was \
6309 installed.",
6310 expected.as_secs(),
6311 found.manager,
6312 actual.as_secs()
6313 ),
6314 });
6315 } else if expected != record.restart().delay() {
6316 notes.push(format!(
6321 "{} expresses the restart delay in whole minutes, so the {}s asked \
6322 for is enforced as {}s. The service therefore never restarts faster \
6323 than the configured bound.",
6324 found.manager,
6325 record.restart().delay().as_secs(),
6326 expected.as_secs()
6327 ));
6328 }
6329 }
6330 if found.starts_automatically && !found.running {
6331 problems.push(StatusProblem {
6332 subject: "runtime",
6333 detail: format!(
6334 "{} holds an automatic registration, but the daemon is stopped. Run \
6335 `runner-manager service install --start-at {}`; if the service \
6336 manager denies replacement, retry from an elevated terminal. If it \
6337 stops again, inspect {}.",
6338 found.manager,
6339 found.start_mode,
6340 log_file.display()
6341 ),
6342 });
6343 }
6344 }
6345 (None, None) => {}
6346 }
6347
6348 let store = record.as_ref().and_then(|record| {
6353 let registered_mode = registration
6354 .as_ref()
6355 .map_or(record.start_mode, |found| found.start_mode);
6356 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6357 .ok()
6358 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6359 });
6360 if let Some(store) = &store
6361 && !store.agrees_with_start_mode()
6362 {
6363 problems.push(StatusProblem {
6364 subject: "secret store",
6365 detail: format!(
6366 "{store}. Run `auth login` again so the token is stored where the registered \
6367 start mode can read it."
6368 ),
6369 });
6370 }
6371
6372 if last_github_contact.is_none() {
6373 notes.push(
6374 "GitHub has not been reached successfully since this host's state directory was \
6375 created."
6376 .to_string(),
6377 );
6378 }
6379
6380 let runner_root = crate::runner_root::default_runner_root(paths)
6387 .ok()
6388 .map(|root| {
6389 let path = root.as_path().to_path_buf();
6390 let report = crate::runner_root_access::report(&path);
6391 (path, report)
6392 });
6393 if let Some((
6403 path,
6404 RootAccessReport::Present {
6405 broad_write: true, ..
6406 },
6407 )) = &runner_root
6408 {
6409 notes.push(format!(
6410 "the platform default runner root {} can be written by ordinary local users, so \
6411 it is not a safe place to run jobs. `service install` refuses it rather than \
6412 tightening it, because the contents of a directory anybody could write cannot \
6413 be trusted: remove or empty it, or choose another root with `runner-manager \
6414 host set-runtime-root --path <PATH>`.",
6415 path.display()
6416 ));
6417 }
6418
6419 match runner_root_refusals(paths) {
6442 Ok(refusals) => {
6443 for refusal in refusals {
6444 notes.push(format!(
6445 "policy {} started no runner: its runner root {} refused the launch \
6446 ({}), last at {}. {} This clears when that policy next places a \
6447 runner.",
6448 refusal.policy,
6449 refusal.root,
6450 refusal.kind,
6451 refusal.at.to_rfc3339(),
6452 refusal.detail,
6453 ));
6454 }
6455 }
6456 Err(error) => notes.push(format!(
6457 "whether the agent could use its runner roots could not be read: {error}"
6458 )),
6459 }
6460
6461 Self {
6462 identity,
6463 record,
6464 registration,
6465 binary,
6466 log_file,
6467 store,
6468 last_github_contact,
6469 runner_root,
6470 credential_rejected_since: None,
6471 launches_blocked: None,
6472 unattended_gap: None,
6473 foreign: None,
6474 problems,
6475 notes,
6476 }
6477 }
6478
6479 fn with_foreign(mut self, foreign: ForeignService) -> Self {
6490 let act_as = foreign.act_as();
6491 for problem in &mut self.problems {
6492 problem.detail = format!(
6493 "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6494 own directories, not the service's.",
6495 diagnosis_only(&problem.detail)
6496 );
6497 }
6498 if let Some(note) = foreign.unused_local_note() {
6499 self.notes.push(note);
6500 }
6501 self.foreign = Some(foreign);
6502 self
6503 }
6504
6505 fn foreign_unread(
6513 identity: ServiceIdentity,
6514 registration: Registration,
6515 foreign: ForeignService,
6516 ) -> Self {
6517 let mut problems = Vec::new();
6518 let look = foreign.where_to_look();
6519 if registration.starts_automatically && !registration.running {
6520 problems.push(StatusProblem {
6521 subject: "runtime",
6522 detail: format!(
6523 "{} holds an automatic registration for the service installed from {}, but \
6524 it is stopped. Run `runner-manager service status` {look} to see why; do not \
6525 reinstall it from this account.",
6526 registration.manager,
6527 foreign.installed_by(),
6528 ),
6529 });
6530 }
6531 if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6532 problems.push(StatusProblem {
6533 subject: "start mode",
6534 detail: format!(
6535 "{} holds the boot registration installed from {} but will not start it by \
6536 itself. Run `runner-manager service status` {look}.",
6537 registration.manager,
6538 foreign.installed_by(),
6539 ),
6540 });
6541 }
6542 let mut notes = Vec::new();
6543 if let Some(reason) = &foreign.unreadable {
6544 notes.push(format!(
6545 "its health is not known here: {reason}. Run `runner-manager service status` \
6546 {look} to see it."
6547 ));
6548 }
6549 notes.extend(foreign.unused_local_note());
6550 Self {
6551 identity,
6552 record: None,
6553 log_file: foreign.directories.log_file(),
6554 registration: Some(registration),
6555 binary: None,
6556 store: None,
6557 last_github_contact: None,
6558 runner_root: None,
6559 credential_rejected_since: None,
6560 launches_blocked: None,
6561 unattended_gap: None,
6562 foreign: Some(foreign),
6563 problems,
6564 notes,
6565 }
6566 }
6567
6568 #[must_use]
6571 pub const fn foreign(&self) -> Option<&ForeignService> {
6572 self.foreign.as_ref()
6573 }
6574
6575 #[must_use]
6578 pub fn is_unread(&self) -> bool {
6579 self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6580 }
6581
6582 #[must_use]
6584 pub fn verdict(&self) -> String {
6585 if !self.is_healthy() {
6586 return "NOT healthy".to_owned();
6587 }
6588 match &self.foreign {
6589 Some(foreign) if foreign.is_unread() => format!(
6590 "{}; installed from {}, so its health is read {}",
6591 if self.is_running() {
6592 "running"
6593 } else {
6594 "not running"
6595 },
6596 foreign.installed_by(),
6597 foreign.where_to_look()
6598 ),
6599 _ => "healthy".to_owned(),
6600 }
6601 }
6602
6603 #[must_use]
6605 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6606 if let Some(drift) = drift {
6607 if drift.manager == DefinitionKind::LaunchdPlist {
6608 let restart = match drift.start_mode {
6612 StartMode::Boot => format!(
6613 "sudo launchctl kickstart -k system/{}",
6614 self.identity.launchd_label()
6615 ),
6616 StartMode::Login => format!(
6617 "launchctl kickstart -k gui/$(id -u)/{}",
6618 self.identity.launchd_label()
6619 ),
6620 };
6621 self.problems.push(StatusProblem {
6622 subject: "definition",
6623 detail: format!(
6624 "{} was written by runner-manager {} with a `ProcessType` other than \
6625 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6626 daemon starts, so their jobs run below normal priority. The daemon \
6627 rewrites it and reloads itself when it starts with no runner, which it \
6628 does after every upgrade; to do it now, restart the service while no \
6629 job is running: `{restart}`. Configuration and credentials are kept.",
6630 drift.path.display(),
6631 drift.installed_by_version,
6632 ),
6633 });
6634 return self;
6635 }
6636 let repair = match drift.start_mode {
6637 StartMode::Boot => format!(
6638 "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6639 `runner-manager wsl install --distribution <name>` from Windows)",
6640 sudo_command("service install --start-at boot")
6641 ),
6642 StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6643 service install --start-at login`"
6644 .to_owned(),
6645 };
6646 self.problems.push(StatusProblem {
6647 subject: "definition",
6648 detail: format!(
6649 "{} was written by runner-manager {} and differs from what {} renders, so \
6650 the daemon runs under outdated settings (for example a credential frozen \
6651 at service start). Re-register it with {repair}; configuration and \
6652 credentials are kept.",
6653 drift.path.display(),
6654 drift.installed_by_version,
6655 env!("CARGO_PKG_VERSION"),
6656 ),
6657 });
6658 }
6659 self
6660 }
6661
6662 #[must_use]
6664 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6665 if let Some(since) = since {
6666 self.credential_rejected_since = Some(since);
6667 self.problems.push(StatusProblem {
6668 subject: "github credential",
6669 detail: format!(
6670 "GitHub has rejected the service's credential since {}, so the service \
6671 cannot see queued jobs or start runners. Run `runner-manager auth login` \
6672 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6673 from Windows).",
6674 since.to_rfc3339()
6675 ),
6676 });
6677 }
6678 self
6679 }
6680
6681 #[must_use]
6683 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6684 self.credential_rejected_since
6685 }
6686
6687 #[must_use]
6690 pub fn with_launches_blocked(
6691 mut self,
6692 blocked: Option<crate::launch_health::LaunchesBlocked>,
6693 ) -> Self {
6694 if let Some(blocked) = &blocked {
6695 self.problems.push(StatusProblem {
6696 subject: LAUNCHES_BLOCKED_SUBJECT,
6697 detail: format!(
6698 "the service has started no runner {blocked}. Fix: {}.",
6699 blocked.remedy
6700 ),
6701 });
6702 }
6703 self.launches_blocked = blocked;
6704 self
6705 }
6706
6707 #[must_use]
6709 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6710 self.launches_blocked.as_ref()
6711 }
6712
6713 #[must_use]
6716 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6717 if let Some(since) = since {
6718 let mode = self.start_mode().unwrap_or_default();
6719 self.problems.push(StatusProblem {
6720 subject: CREDENTIAL_UNREADABLE_SUBJECT,
6721 detail: format!(
6722 "the service has been unable to read its stored GitHub credential since {}, \
6723 so it starts no runner; the diagnostic log has the store's own answer.{} \
6724 Sign in again: {}.",
6725 since.to_rfc3339(),
6726 if cfg!(target_os = "macos") {
6727 " That is what a keychain item written by a different build looks like."
6728 } else {
6729 ""
6730 },
6731 sign_in_instruction(mode)
6732 ),
6733 });
6734 }
6735 self
6736 }
6737
6738 #[must_use]
6746 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6747 self.runner_root
6748 .as_ref()
6749 .map(|(path, report)| (path.as_path(), report))
6750 }
6751
6752 #[must_use]
6754 pub const fn is_installed(&self) -> bool {
6755 self.registration.is_some() || self.record.is_some()
6756 }
6757
6758 #[must_use]
6760 pub fn is_running(&self) -> bool {
6761 self.registration
6762 .as_ref()
6763 .is_some_and(|registration| registration.running)
6764 }
6765
6766 #[must_use]
6768 pub fn is_healthy(&self) -> bool {
6769 self.problems.is_empty()
6770 }
6771
6772 #[must_use]
6774 pub fn problems(&self) -> &[StatusProblem] {
6775 &self.problems
6776 }
6777
6778 #[must_use]
6781 pub fn notes(&self) -> &[String] {
6782 &self.notes
6783 }
6784
6785 #[must_use]
6790 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6791 if let Some(unfit) = unfit {
6792 self.problems.push(StatusProblem {
6793 subject: HOST_UNFIT_SUBJECT,
6794 detail: format!(
6795 "the service has started no runner since {}, because a required host check \
6796 fails from its own session: {}",
6797 unfit.since.to_rfc3339(),
6798 unfit.describe()
6799 ),
6800 });
6801 }
6802 self
6803 }
6804
6805 #[must_use]
6809 pub fn with_stall(mut self, stall: Option<String>) -> Self {
6810 if let Some(detail) = stall {
6811 self.problems.push(StatusProblem {
6812 subject: DAEMON_STALLED_SUBJECT,
6813 detail,
6814 });
6815 }
6816 self
6817 }
6818
6819 #[must_use]
6822 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6823 if let Some(gap) = &gap {
6824 self.notes.push(gap.reason.clone());
6825 }
6826 self.unattended_gap = gap;
6827 self
6828 }
6829
6830 #[must_use]
6833 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6834 self.unattended_gap.as_ref()
6835 }
6836
6837 #[must_use]
6840 pub fn restart_command(&self) -> String {
6841 self.identity
6842 .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6843 }
6844
6845 #[must_use]
6849 pub fn start_mode(&self) -> Option<StartMode> {
6850 self.record
6851 .as_ref()
6852 .map(|record| record.start_mode)
6853 .or_else(|| {
6854 self.registration
6855 .as_ref()
6856 .filter(|_| self.is_unread())
6857 .map(|registration| registration.start_mode)
6858 })
6859 }
6860
6861 #[must_use]
6865 pub fn service_binary(&self) -> Option<PathBuf> {
6866 self.record
6867 .as_ref()
6868 .map(|record| record.binary.clone())
6869 .or_else(|| {
6870 self.registration
6871 .as_ref()
6872 .filter(|_| self.is_unread())
6873 .and_then(Registration::binary)
6874 })
6875 }
6876
6877 #[must_use]
6880 pub const fn binary(&self) -> Option<&BinaryPath> {
6881 self.binary.as_ref()
6882 }
6883
6884 #[must_use]
6886 pub fn log_file(&self) -> &Path {
6887 &self.log_file
6888 }
6889
6890 #[must_use]
6892 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6893 self.last_github_contact
6894 }
6895
6896 #[must_use]
6899 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6900 self.store.as_ref()
6901 }
6902
6903 #[must_use]
6905 pub const fn record(&self) -> Option<&InstallRecord> {
6906 self.record.as_ref()
6907 }
6908
6909 #[must_use]
6911 pub const fn registration(&self) -> Option<&Registration> {
6912 self.registration.as_ref()
6913 }
6914}
6915
6916impl fmt::Display for ServiceStatus {
6917 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6918 writeln!(f, "Service: {}", self.identity)?;
6919 match (&self.record, &self.registration) {
6920 (None, None) => {
6921 writeln!(
6922 f,
6923 " installed no. `service install` registers `{} {}`.",
6924 SERVICE_NAME,
6925 DAEMON_ARGUMENTS.join(" ")
6926 )?;
6927 }
6928 _ => {
6929 let manager = self
6930 .registration
6931 .as_ref()
6932 .map(|registration| registration.manager.manager());
6933 writeln!(
6934 f,
6935 " installed {}",
6936 manager.unwrap_or("yes, but no service manager knows it")
6937 )?;
6938 writeln!(
6939 f,
6940 " state {}",
6941 if self.is_running() {
6942 "running"
6943 } else {
6944 "not running"
6945 }
6946 )?;
6947 }
6948 }
6949 if let Some(foreign) = &self.foreign {
6950 writeln!(
6951 f,
6952 " installed from {} -- another installation's, not this account's ({})",
6953 foreign.installed_by(),
6954 foreign.local_root.display()
6955 )?;
6956 if foreign.is_unread()
6958 && let Some(registration) = &self.registration
6959 {
6960 writeln!(f, " start mode {}", registration.start_mode)?;
6961 if let Some(account) = ®istration.account {
6962 writeln!(f, " account {account}")?;
6963 }
6964 if let Some(binary) = self.service_binary() {
6965 writeln!(f, " binary {}", binary.display())?;
6966 }
6967 }
6968 }
6969 if let Some(record) = &self.record {
6970 writeln!(f, " start mode {}", record.start_mode)?;
6971 let recorded = record.account.to_string();
6972 let account = registered_account(self.registration.as_ref(), &recorded);
6973 writeln!(f, " account {account}")?;
6974 writeln!(f, " restart on failure {}", record.restart())?;
6975 writeln!(
6976 f,
6977 " arguments {}",
6978 record.arguments.join(" ")
6979 )?;
6980 }
6981 if let Some(binary) = &self.binary {
6982 writeln!(f, " binary {binary}")?;
6983 }
6984 writeln!(f, " diagnostic log {}", self.log_file.display())?;
6985 if let Some((path, report)) = &self.runner_root {
6986 writeln!(f, " default runner root {}", path.display())?;
6993 if *report != RootAccessReport::NotApplicable {
6994 writeln!(f, " default root access {report}")?;
6995 }
6996 }
6997 if let Some(store) = &self.store {
6998 writeln!(f, " secret store {store}")?;
6999 }
7000 if !self.is_unread() {
7003 writeln!(
7004 f,
7005 " last GitHub contact {}",
7006 match self.last_github_contact {
7007 Some(at) => at.to_rfc3339(),
7008 None => "never".to_string(),
7009 }
7010 )?;
7011 }
7012 for note in &self.notes {
7013 writeln!(f, " note {note}")?;
7014 }
7015 for problem in &self.problems {
7016 writeln!(f, " ERROR {problem}")?;
7017 }
7018 write!(f, " verdict {}", self.verdict())
7019 }
7020}
7021
7022#[derive(Debug, Clone, Default)]
7039pub struct RecordingControls {
7040 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7041}
7042
7043#[derive(Debug, Default)]
7044struct RecordingState {
7045 registrations: BTreeMap<(StartMode, String), Registration>,
7046 definitions: BTreeMap<String, ServiceDefinition>,
7047 calls: Vec<String>,
7048 #[cfg(test)]
7049 install_failures: BTreeMap<StartMode, String>,
7050 #[cfg(test)]
7051 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
7052}
7053
7054#[cfg(test)]
7055#[derive(Debug, Clone)]
7056enum TestInstallSideEffect {
7057 HideDirectory { directory: PathBuf, hidden: PathBuf },
7058}
7059
7060impl RecordingControls {
7061 #[must_use]
7063 pub fn new() -> Self {
7064 Self::default()
7065 }
7066
7067 #[must_use]
7070 pub fn calls(&self) -> Vec<String> {
7071 self.state.lock().expect("not poisoned").calls.clone()
7072 }
7073
7074 #[must_use]
7076 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
7077 self.state
7078 .lock()
7079 .expect("not poisoned")
7080 .registrations
7081 .iter()
7082 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
7083 .collect()
7084 }
7085
7086 #[must_use]
7088 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
7089 self.state
7090 .lock()
7091 .expect("not poisoned")
7092 .definitions
7093 .get(name)
7094 .cloned()
7095 }
7096
7097 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
7107 let mut state = self.state.lock().expect("not poisoned");
7108 if let Some((_, registration)) = state
7109 .registrations
7110 .iter_mut()
7111 .find(|((_, held), _)| held == name)
7112 {
7113 edit(registration);
7114 }
7115 }
7116
7117 #[cfg(test)]
7118 fn fail_next_install(&self, mode: StartMode, detail: &str) {
7119 self.state
7120 .lock()
7121 .expect("not poisoned")
7122 .install_failures
7123 .insert(mode, detail.to_string());
7124 }
7125
7126 #[cfg(test)]
7127 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
7128 self.state
7129 .lock()
7130 .expect("not poisoned")
7131 .after_install
7132 .insert(
7133 mode,
7134 TestInstallSideEffect::HideDirectory { directory, hidden },
7135 );
7136 }
7137}
7138
7139impl ControlFactory for RecordingControls {
7140 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7141 Ok(Box::new(RecordingControl {
7142 mode,
7143 state: std::sync::Arc::clone(&self.state),
7144 }))
7145 }
7146}
7147
7148#[derive(Debug)]
7149struct RecordingControl {
7150 mode: StartMode,
7151 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7152}
7153
7154impl RecordingControl {
7155 fn note(&self, operation: &str, name: &str) {
7156 self.state
7157 .lock()
7158 .expect("not poisoned")
7159 .calls
7160 .push(format!("{operation} {name} ({})", self.mode));
7161 }
7162}
7163
7164impl ServiceControl for RecordingControl {
7165 fn manager(&self) -> DefinitionKind {
7166 host_definition_kind(self.mode)
7170 }
7171
7172 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7173 self.note("install", plan.identity().name());
7174 #[cfg(test)]
7175 if let Some(detail) = self
7176 .state
7177 .lock()
7178 .expect("not poisoned")
7179 .install_failures
7180 .remove(&self.mode)
7181 {
7182 return Err(ServiceError::Control {
7183 operation: "install",
7184 name: plan.identity().name().to_string(),
7185 manager: "recording control",
7186 detail,
7187 });
7188 }
7189 let definition = ServiceDefinition::for_host(plan)?;
7194 let mut state = self.state.lock().expect("not poisoned");
7195 state.registrations.insert(
7196 (self.mode, plan.identity().name().to_string()),
7197 Registration {
7198 manager: host_definition_kind(self.mode),
7199 start_mode: self.mode,
7200 command_line: plan.command_line(),
7201 account: (*plan.account() != ServiceAccount::InvokingUser)
7202 .then(|| plan.account().as_str().to_string()),
7203 running: false,
7204 starts_automatically: true,
7205 restart_delay: Some(plan.restart().delay()),
7206 },
7207 );
7208 state
7209 .definitions
7210 .insert(plan.identity().name().to_string(), definition.clone());
7211 #[cfg(test)]
7212 let side_effect = state.after_install.remove(&self.mode);
7213 drop(state);
7214 #[cfg(test)]
7215 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7216 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7217 std::fs::write(&directory, b"blocks recreation")
7218 .expect("test fault can block record directory recreation");
7219 }
7220 Ok(definition)
7221 }
7222
7223 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7224 self.note("uninstall", identity.name());
7225 let mut state = self.state.lock().expect("not poisoned");
7226 state.definitions.remove(identity.name());
7227 Ok(state
7228 .registrations
7229 .remove(&(self.mode, identity.name().to_string()))
7230 .is_some())
7231 }
7232
7233 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7234 self.note("query", identity.name());
7235 Ok(self
7236 .state
7237 .lock()
7238 .expect("not poisoned")
7239 .registrations
7240 .get(&(self.mode, identity.name().to_string()))
7241 .cloned())
7242 }
7243
7244 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7245 self.note("start", identity.name());
7246 let mut state = self.state.lock().expect("not poisoned");
7247 match state
7248 .registrations
7249 .get_mut(&(self.mode, identity.name().to_string()))
7250 {
7251 Some(registration) => {
7252 registration.running = true;
7253 Ok(())
7254 }
7255 None => Err(ServiceError::NotInstalled {
7256 name: identity.name().to_string(),
7257 operation: "start",
7258 }),
7259 }
7260 }
7261
7262 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7263 self.note("stop", identity.name());
7264 let mut state = self.state.lock().expect("not poisoned");
7265 match state
7266 .registrations
7267 .get_mut(&(self.mode, identity.name().to_string()))
7268 {
7269 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7270 None => Err(ServiceError::NotInstalled {
7271 name: identity.name().to_string(),
7272 operation: "stop",
7273 }),
7274 }
7275 }
7276}
7277
7278impl ControlFactory for HostControls {
7279 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7280 sys::control(mode)
7281 }
7282
7283 fn installed_definition(&self, path: &Path) -> Option<String> {
7284 std::fs::read_to_string(path).ok()
7285 }
7286
7287 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7288 crate::unattended_login::probe()
7289 }
7290}
7291
7292#[must_use]
7299pub fn host_home() -> Option<PathBuf> {
7300 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7301}
7302
7303#[cfg(unix)]
7305fn home_directory() -> Option<PathBuf> {
7306 host_home()
7307}
7308
7309fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7318 let output = std::process::Command::new(program)
7319 .args(arguments)
7320 .output()?;
7321 Ok((
7322 output.status.success(),
7323 String::from_utf8_lossy(&output.stdout).into_owned(),
7324 String::from_utf8_lossy(&output.stderr).into_owned(),
7325 ))
7326}
7327
7328#[must_use]
7331pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7332 if cfg!(windows) {
7333 match mode {
7334 StartMode::Boot => DefinitionKind::WindowsService,
7335 StartMode::Login => DefinitionKind::WindowsScheduledTask,
7336 }
7337 } else if cfg!(target_os = "macos") {
7338 DefinitionKind::LaunchdPlist
7339 } else {
7340 DefinitionKind::SystemdUnit
7341 }
7342}
7343
7344#[cfg(any(target_os = "macos", test))]
7351fn enable_launchd_registration(
7352 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7353 domain: &str,
7354 service_target: &str,
7355 plist: &Path,
7356 name: &str,
7357 elevation_remedy: &'static str,
7358) -> Result<(), ServiceError> {
7359 let (enabled, cause) = launchctl(&[
7360 std::ffi::OsStr::new("enable"),
7361 std::ffi::OsStr::new(service_target),
7362 ]);
7363 if enabled {
7364 return Ok(());
7365 }
7366
7367 let (booted_out, bootout_detail) = launchctl(&[
7368 std::ffi::OsStr::new("bootout"),
7369 std::ffi::OsStr::new(service_target),
7370 ]);
7371 let removed = std::fs::remove_file(plist);
7372 if !booted_out || removed.is_err() {
7373 return Err(ServiceError::Rollback {
7374 operation: "enable launchd registration",
7375 name: name.to_string(),
7376 cause,
7377 rollback: format!(
7378 "launchctl bootout {domain}: {}; remove {}: {}",
7379 if booted_out {
7380 "succeeded".to_string()
7381 } else {
7382 bootout_detail
7383 },
7384 plist.display(),
7385 removed
7386 .err()
7387 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7388 ),
7389 });
7390 }
7391
7392 if cause.to_ascii_lowercase().contains("permission denied") {
7393 Err(ServiceError::NeedsElevation {
7394 operation: "enable",
7395 name: name.to_string(),
7396 detail: cause,
7397 remedy: elevation_remedy,
7398 })
7399 } else {
7400 Err(ServiceError::Control {
7401 operation: "enable",
7402 name: name.to_string(),
7403 manager: "launchd",
7404 detail: cause,
7405 })
7406 }
7407}
7408
7409#[derive(Debug, Clone)]
7418pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7419
7420impl ServiceShutdown {
7421 pub async fn wait(mut self) {
7423 if !*self.0.borrow() {
7424 let _ = self.0.changed().await;
7425 }
7426 }
7427}
7428
7429#[cfg(windows)]
7436pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7437where
7438 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7439{
7440 windows_host::run(Box::new(run))
7441}
7442
7443#[cfg(windows)]
7444mod windows_host {
7445 use std::ffi::OsString;
7446 use std::sync::{Arc, Mutex, OnceLock, mpsc};
7447 use std::time::Duration;
7448
7449 use windows_service::service::{
7450 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7451 ServiceType,
7452 };
7453 use windows_service::service_control_handler::{
7454 self, ServiceControlHandlerResult, ServiceStatusHandle,
7455 };
7456
7457 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7458
7459 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7460
7461 struct Invocation {
7462 run: Runner,
7463 result: mpsc::SyncSender<Result<u8, String>>,
7464 }
7465
7466 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7467
7468 windows_service::define_windows_service!(ffi_service_main, service_main);
7469
7470 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7471 let (result_tx, result_rx) = mpsc::sync_channel(1);
7472 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7473 let mut invocation = slot
7474 .lock()
7475 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7476 if invocation.is_some() {
7477 return Err(host_error(
7478 "prepare",
7479 "the service-host slot was already used",
7480 ));
7481 }
7482 *invocation = Some(Invocation {
7483 run,
7484 result: result_tx,
7485 });
7486 drop(invocation);
7487
7488 windows_service::service_dispatcher::start("", ffi_service_main)
7489 .map_err(|error| host_error("connect", &error.to_string()))?;
7490 result_rx
7491 .recv()
7492 .map_err(|error| host_error("finish", &error.to_string()))?
7493 .map_err(|detail| host_error("run", &detail))
7494 }
7495
7496 fn service_main(_arguments: Vec<OsString>) {
7497 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7498 return;
7499 };
7500 let result = run_service(invocation.run);
7501 let _ = invocation.result.send(result);
7502 }
7503
7504 fn run_service(run: Runner) -> Result<u8, String> {
7505 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7506 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7507 let handler_status = Arc::clone(&status);
7508 let handler = move |control| match control {
7509 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7510 ServiceControl::Stop | ServiceControl::Shutdown => {
7511 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7512 let _ = handle.set_service_status(service_status(
7513 ServiceState::StopPending,
7514 ServiceControlAccept::empty(),
7515 1,
7516 Duration::from_secs(300),
7517 0,
7518 ));
7519 }
7520 let _ = stop_tx.send(true);
7521 ServiceControlHandlerResult::NoError
7522 }
7523 _ => ServiceControlHandlerResult::NotImplemented,
7524 };
7525 let handle = service_control_handler::register("", handler)
7526 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7527 *status
7528 .lock()
7529 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7530 handle
7531 .set_service_status(service_status(
7532 ServiceState::Running,
7533 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7534 0,
7535 Duration::default(),
7536 0,
7537 ))
7538 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7539
7540 let exit = run(ServiceShutdown(stop_rx));
7541 handle
7542 .set_service_status(service_status(
7543 ServiceState::Stopped,
7544 ServiceControlAccept::empty(),
7545 0,
7546 Duration::default(),
7547 u32::from(exit),
7548 ))
7549 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7550 Ok(exit)
7551 }
7552
7553 fn service_status(
7554 state: ServiceState,
7555 accepted: ServiceControlAccept,
7556 checkpoint: u32,
7557 wait_hint: Duration,
7558 exit: u32,
7559 ) -> ServiceStatus {
7560 ServiceStatus {
7561 service_type: ServiceType::OWN_PROCESS,
7562 current_state: state,
7563 controls_accepted: accepted,
7564 exit_code: if exit == 0 {
7565 ServiceExitCode::Win32(0)
7566 } else {
7567 ServiceExitCode::ServiceSpecific(exit)
7568 },
7569 checkpoint,
7570 wait_hint,
7571 process_id: None,
7572 }
7573 }
7574
7575 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7576 ServiceError::Control {
7577 operation,
7578 name: SERVICE_NAME.to_string(),
7579 manager: "the Windows Service Control Manager",
7580 detail: detail.to_string(),
7581 }
7582 }
7583}
7584
7585#[cfg(windows)]
7586mod sys {
7587 use std::ffi::{OsStr, OsString};
7597 use std::time::{Duration, Instant};
7598
7599 use runner_manager_domain::model::StartMode;
7600 use windows_service::service::{
7601 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7602 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7603 ServiceState, ServiceType,
7604 };
7605 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7606
7607 use super::{
7608 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7609 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7610 };
7611
7612 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7615 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7619 const ACCESS_DENIED: i32 = 5;
7621 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7622 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7623
7624 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7625 choose \"Run as administrator\".";
7626
7627 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7628 Ok(match mode {
7629 StartMode::Boot => Box::new(ScmControl),
7630 StartMode::Login => Box::new(TaskControl),
7631 })
7632 }
7633
7634 #[derive(Debug)]
7637 struct ScmControl;
7638
7639 fn scm_error(
7643 operation: &'static str,
7644 name: &str,
7645 error: &windows_service::Error,
7646 ) -> ServiceError {
7647 let raw = match error {
7648 windows_service::Error::Winapi(io) => io.raw_os_error(),
7649 _ => None,
7650 };
7651 let detail = match error {
7652 windows_service::Error::Winapi(io) => io.to_string(),
7653 other => other.to_string(),
7654 };
7655 if raw == Some(ACCESS_DENIED) {
7656 return ServiceError::NeedsElevation {
7657 operation,
7658 name: name.to_string(),
7659 detail,
7660 remedy: ELEVATION_REMEDY,
7661 };
7662 }
7663 ServiceError::Control {
7664 operation,
7665 name: name.to_string(),
7666 manager: "the Windows Service Control Manager",
7667 detail,
7668 }
7669 }
7670
7671 fn open_manager(
7672 access: ServiceManagerAccess,
7673 operation: &'static str,
7674 name: &str,
7675 ) -> Result<ServiceManager, ServiceError> {
7676 ServiceManager::local_computer(None::<&OsStr>, access)
7677 .map_err(|error| scm_error(operation, name, &error))
7678 }
7679
7680 impl ServiceControl for ScmControl {
7681 fn manager(&self) -> DefinitionKind {
7682 DefinitionKind::WindowsService
7683 }
7684
7685 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7686 let spec = windows_service_spec(plan);
7687 let manager = open_manager(
7688 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7689 "install",
7690 &spec.name,
7691 )?;
7692 let info = ServiceInfo {
7693 name: OsString::from(&spec.name),
7694 display_name: OsString::from(&spec.display_name),
7695 service_type: ServiceType::OWN_PROCESS,
7697 start_type: if spec.automatic_start {
7698 ServiceStartType::AutoStart
7699 } else {
7700 ServiceStartType::OnDemand
7701 },
7702 error_control: ServiceErrorControl::Normal,
7703 executable_path: plan.binary().to_path_buf(),
7704 launch_arguments: plan.arguments().to_vec(),
7705 dependencies: Vec::new(),
7706 account_name: spec.account.as_ref().map(OsString::from),
7708 account_password: None,
7711 };
7712 let service = manager
7713 .create_service(
7714 &info,
7715 ServiceAccess::CHANGE_CONFIG
7716 | ServiceAccess::QUERY_CONFIG
7717 | ServiceAccess::QUERY_STATUS
7718 | ServiceAccess::START
7719 | ServiceAccess::STOP
7720 | ServiceAccess::DELETE,
7721 )
7722 .map_err(|error| scm_error("install", &spec.name, &error))?;
7723 service
7724 .set_description(&spec.description)
7725 .map_err(|error| scm_error("describe", &spec.name, &error))?;
7726 service
7727 .update_failure_actions(ServiceFailureActions {
7728 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7729 reboot_msg: None,
7730 command: None,
7731 actions: Some(vec![
7738 ServiceAction {
7739 action_type: ServiceActionType::Restart,
7740 delay: spec.restart.delay(),
7741 };
7742 3
7743 ]),
7744 })
7745 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7746 service
7749 .set_failure_actions_on_non_crash_failures(true)
7750 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7751 Ok(ServiceDefinition::windows_service(plan))
7752 }
7753
7754 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7755 let manager =
7756 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7757 let service = match manager.open_service(
7758 identity.name(),
7759 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7760 ) {
7761 Ok(service) => Some(service),
7762 Err(error) if is_missing(&error) => return Ok(false),
7763 Err(error) if is_marked_for_delete(&error) => None,
7768 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7769 };
7770 if let Some(service) = service {
7771 if let Ok(status) = service.query_status()
7775 && status.current_state != ServiceState::Stopped
7776 {
7777 let _ = service.stop();
7778 }
7779 match service.delete() {
7780 Ok(()) => {}
7781 Err(error) if is_marked_for_delete(&error) => {}
7785 Err(error) => {
7786 return Err(scm_error("uninstall", identity.name(), &error));
7787 }
7788 }
7789 drop(service);
7790 }
7791
7792 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7799 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7800 Ok(service) => {
7801 drop(service);
7802 Ok(false)
7803 }
7804 Err(error) if is_missing(&error) => Ok(true),
7805 Err(error) if is_marked_for_delete(&error) => Ok(false),
7806 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7807 }
7808 })?;
7809 if !absent {
7810 return Err(ServiceError::Control {
7811 operation: "verify uninstall of",
7812 name: identity.name().to_string(),
7813 manager: "the Windows Service Control Manager",
7814 detail: format!(
7815 "the registration was still visible {} seconds after DeleteService; \
7816 retry `service uninstall` from an elevated prompt",
7817 DELETE_TIMEOUT.as_secs()
7818 ),
7819 });
7820 }
7821 Ok(true)
7822 }
7823
7824 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7825 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7826 let service = match manager.open_service(
7827 identity.name(),
7828 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7829 ) {
7830 Ok(service) => service,
7831 Err(error) if is_missing(&error) => return Ok(None),
7832 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7833 };
7834 let config = service
7835 .query_config()
7836 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7837 let status = service
7838 .query_status()
7839 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7840 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7841 actions
7842 .actions
7843 .and_then(|actions| actions.into_iter().next())
7844 .filter(|action| action.action_type == ServiceActionType::Restart)
7845 .map(|action| action.delay)
7846 });
7847 Ok(Some(Registration {
7848 manager: DefinitionKind::WindowsService,
7849 start_mode: StartMode::Boot,
7850 command_line: config.executable_path.to_string_lossy().into_owned(),
7854 account: config
7855 .account_name
7856 .map(|account| super::windows_service_account(&account.to_string_lossy())),
7857 running: status.current_state == ServiceState::Running,
7858 starts_automatically: config.start_type == ServiceStartType::AutoStart,
7859 restart_delay,
7860 }))
7861 }
7862
7863 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7864 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7865 let service = manager
7866 .open_service(identity.name(), ServiceAccess::START)
7867 .map_err(|error| scm_error("start", identity.name(), &error))?;
7868 service
7869 .start::<&OsStr>(&[])
7870 .map_err(|error| scm_error("start", identity.name(), &error))
7871 }
7872
7873 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7874 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7875 let service = manager
7876 .open_service(
7877 identity.name(),
7878 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7879 )
7880 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7881 let status = service
7882 .query_status()
7883 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7884 if status.current_state == ServiceState::Stopped {
7885 return Ok(false);
7886 }
7887 service
7888 .stop()
7889 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7890 Ok(true)
7891 }
7892 }
7893
7894 fn is_missing(error: &windows_service::Error) -> bool {
7895 matches!(error, windows_service::Error::Winapi(io)
7896 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7897 }
7898
7899 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7900 matches!(error, windows_service::Error::Winapi(io)
7901 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7902 }
7903
7904 pub(super) fn wait_until_scm_absent(
7905 timeout: Duration,
7906 poll_interval: Duration,
7907 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7908 ) -> Result<bool, ServiceError> {
7909 let deadline = Instant::now() + timeout;
7910 loop {
7911 if probe_absent()? {
7912 return Ok(true);
7913 }
7914 if Instant::now() >= deadline {
7915 return Ok(false);
7916 }
7917 std::thread::sleep(poll_interval);
7918 }
7919 }
7920
7921 #[derive(Debug)]
7924 struct TaskControl;
7925
7926 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7927 if detail.to_ascii_lowercase().contains("access is denied") {
7928 return ServiceError::NeedsElevation {
7929 operation,
7930 name: name.to_string(),
7931 detail,
7932 remedy: ELEVATION_REMEDY,
7933 };
7934 }
7935 ServiceError::Control {
7936 operation,
7937 name: name.to_string(),
7938 manager: "Windows Task Scheduler",
7939 detail,
7940 }
7941 }
7942
7943 fn schtasks(
7944 operation: &'static str,
7945 name: &str,
7946 arguments: &[&OsStr],
7947 ) -> Result<(bool, String), ServiceError> {
7948 match run("schtasks.exe", arguments) {
7949 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7950 Err(error) => Err(task_error(operation, name, error.to_string())),
7951 }
7952 }
7953
7954 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7957 let mut bytes = vec![0xFF, 0xFE];
7958 for unit in text.encode_utf16() {
7959 bytes.extend_from_slice(&unit.to_le_bytes());
7960 }
7961 std::fs::write(path, bytes)
7962 }
7963
7964 impl ServiceControl for TaskControl {
7965 fn manager(&self) -> DefinitionKind {
7966 DefinitionKind::WindowsScheduledTask
7967 }
7968
7969 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7970 let name = plan.identity().name().to_string();
7971 let principal = TaskPrincipal::current()?;
7972 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7973 let directory = tempfile::tempdir()
7974 .map_err(|error| task_error("install", &name, error.to_string()))?;
7975 let document = directory.path().join("task.xml");
7976 write_utf16(&document, definition.text())
7977 .map_err(|error| task_error("install", &name, error.to_string()))?;
7978 let (ok, message) = schtasks(
7979 "install",
7980 &name,
7981 &[
7982 OsStr::new("/Create"),
7983 OsStr::new("/TN"),
7984 OsStr::new(&name),
7985 OsStr::new("/XML"),
7986 document.as_os_str(),
7987 ],
7988 )?;
7989 if !ok {
7990 return Err(task_error("install", &name, message));
7991 }
7992 Ok(definition)
7993 }
7994
7995 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7996 if self.query(identity)?.is_none() {
7997 return Ok(false);
7998 }
7999 let name = identity.name().to_string();
8000 let (ok, message) = schtasks(
8001 "uninstall",
8002 &name,
8003 &[
8004 OsStr::new("/Delete"),
8005 OsStr::new("/TN"),
8006 OsStr::new(&name),
8007 OsStr::new("/F"),
8008 ],
8009 )?;
8010 if !ok {
8011 return Err(task_error("uninstall", &name, message));
8012 }
8013 Ok(true)
8014 }
8015
8016 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8017 let name = identity.name().to_string();
8018 let (ok, document) = schtasks(
8019 "inspect",
8020 &name,
8021 &[
8022 OsStr::new("/Query"),
8023 OsStr::new("/TN"),
8024 OsStr::new(&name),
8025 OsStr::new("/XML"),
8026 OsStr::new("ONE"),
8027 ],
8028 )?;
8029 if !ok {
8030 return Ok(None);
8036 }
8037 let command = xml_value(&document, "Command").unwrap_or_default();
8038 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
8039 let command_line = if arguments.is_empty() {
8040 super::quote_argument(&command)
8041 } else {
8042 format!("{} {arguments}", super::quote_argument(&command))
8043 };
8044 Ok(Some(Registration {
8045 manager: DefinitionKind::WindowsScheduledTask,
8046 start_mode: StartMode::Login,
8047 command_line,
8048 account: super::scheduled_task_account(xml_value(&document, "UserId")),
8049 running: task_is_running(&name),
8050 starts_automatically: super::windows_login_task_starts_automatically(&document),
8051 restart_delay: xml_value(&document, "Interval")
8052 .as_deref()
8053 .and_then(parse_iso8601),
8054 }))
8055 }
8056
8057 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8058 let name = identity.name().to_string();
8059 let (ok, message) = schtasks(
8060 "start",
8061 &name,
8062 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
8063 )?;
8064 if ok {
8065 Ok(())
8066 } else {
8067 Err(task_error("start", &name, message))
8068 }
8069 }
8070
8071 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8072 let running = self
8073 .query(identity)?
8074 .is_some_and(|registration| registration.running);
8075 if !running {
8076 return Ok(false);
8077 }
8078 let name = identity.name().to_string();
8079 let (ok, message) = schtasks(
8080 "stop",
8081 &name,
8082 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
8083 )?;
8084 if ok {
8085 Ok(true)
8086 } else {
8087 Err(task_error("stop", &name, message))
8088 }
8089 }
8090 }
8091
8092 fn task_is_running(name: &str) -> bool {
8106 let Ok((true, stdout, _)) = run(
8107 "schtasks.exe",
8108 &[
8109 OsStr::new("/Query"),
8110 OsStr::new("/TN"),
8111 OsStr::new(name),
8112 OsStr::new("/FO"),
8113 OsStr::new("CSV"),
8114 OsStr::new("/NH"),
8115 ],
8116 ) else {
8117 return false;
8118 };
8119 stdout
8120 .lines()
8121 .filter_map(|line| line.rsplit(',').next())
8122 .any(|status| {
8123 status
8124 .trim()
8125 .trim_matches('"')
8126 .eq_ignore_ascii_case("running")
8127 })
8128 }
8129
8130 fn parse_iso8601(value: &str) -> Option<Duration> {
8134 let rest = value.strip_prefix("PT")?;
8135 if let Some(minutes) = rest.strip_suffix('M') {
8136 return minutes
8137 .parse::<u64>()
8138 .ok()
8139 .map(|minutes| Duration::from_secs(minutes * 60));
8140 }
8141 rest.strip_suffix('S')?
8142 .parse::<u64>()
8143 .ok()
8144 .map(Duration::from_secs)
8145 }
8146}
8147
8148#[cfg(unix)]
8160fn write_definition(
8161 operation: &'static str,
8162 name: &str,
8163 path: &Path,
8164 text: &str,
8165 remedy: &'static str,
8166) -> Result<(), ServiceError> {
8167 if let Some(parent) = path.parent()
8168 && let Err(error) = std::fs::create_dir_all(parent)
8169 && error.kind() != std::io::ErrorKind::AlreadyExists
8170 {
8171 return Err(definition_error(operation, name, error, remedy, parent));
8172 }
8173 std::fs::write(path, text)
8174 .map_err(|error| definition_error(operation, name, error, remedy, path))
8175}
8176
8177#[cfg(all(unix, any(target_os = "macos", test)))]
8184fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
8185 use std::io::Write as _;
8186
8187 let path = drift.path.as_path();
8188 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
8189 let failed = |error: std::io::Error, at: &Path| {
8190 definition_error("rewrite", name, error, SUDO_REMEDY, at)
8191 };
8192 let mut staged =
8193 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8194 staged
8195 .write_all(drift.rendered.as_bytes())
8196 .and_then(|()| staged.as_file().sync_all())
8197 .map_err(|error| failed(error, staged.path()))?;
8198 std::fs::set_permissions(
8199 staged.path(),
8200 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8201 )
8202 .map_err(|error| failed(error, path))?;
8203 staged
8204 .persist(path)
8205 .map(drop)
8206 .map_err(|error| failed(error.error, path))
8207}
8208
8209#[cfg(unix)]
8210fn definition_error(
8211 operation: &'static str,
8212 name: &str,
8213 error: std::io::Error,
8214 remedy: &'static str,
8215 path: &Path,
8216) -> ServiceError {
8217 let detail = format!("{}: {error}", path.display());
8218 if error.kind() == std::io::ErrorKind::PermissionDenied {
8219 ServiceError::NeedsElevation {
8220 operation,
8221 name: name.to_string(),
8222 detail,
8223 remedy,
8224 }
8225 } else {
8226 ServiceError::Control {
8227 operation,
8228 name: name.to_string(),
8229 manager: "the local service manager",
8230 detail,
8231 }
8232 }
8233}
8234
8235#[cfg(unix)]
8237const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8238 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8239 agent not running until you sign in.";
8240
8241#[cfg(target_os = "macos")]
8246mod sys {
8247 use std::ffi::OsStr;
8260 use std::path::PathBuf;
8261 use std::time::Duration;
8262
8263 use runner_manager_domain::model::StartMode;
8264
8265 use super::{
8266 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8267 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8268 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8269 write_definition, xml_value,
8270 };
8271
8272 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8273 Ok(Box::new(LaunchdControl { mode }))
8274 }
8275
8276 #[derive(Debug)]
8277 struct LaunchdControl {
8278 mode: StartMode,
8279 }
8280
8281 impl LaunchdControl {
8282 fn domain(&self) -> String {
8284 match self.mode {
8285 StartMode::Boot => "system".to_string(),
8286 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8289 }
8290 }
8291
8292 fn service_target(&self, identity: &ServiceIdentity) -> String {
8293 format!("{}/{}", self.domain(), identity.launchd_label())
8294 }
8295
8296 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8299 let file = format!("{}.plist", identity.launchd_label());
8300 match self.mode {
8301 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8302 StartMode::Login => {
8303 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8304 }
8305 }
8306 }
8307
8308 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8309 ServiceError::Control {
8310 operation,
8311 name: name.to_string(),
8312 manager: "launchd",
8313 detail,
8314 }
8315 }
8316
8317 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8318 match run("launchctl", arguments) {
8319 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8320 Err(error) => (false, error.to_string()),
8321 }
8322 }
8323 }
8324
8325 impl ServiceControl for LaunchdControl {
8326 fn manager(&self) -> DefinitionKind {
8327 DefinitionKind::LaunchdPlist
8328 }
8329
8330 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8331 let name = plan.identity().name().to_string();
8332 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8333 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8334 return Err(self.failed(
8335 "install",
8336 &name,
8337 "this account has no home directory, so there is nowhere to put a \
8338 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8339 /Library/LaunchDaemons."
8340 .to_string(),
8341 ));
8342 };
8343 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8344 let target = self.domain();
8345 let (ok, message) = self.launchctl(&[
8346 OsStr::new("bootstrap"),
8347 OsStr::new(&target),
8348 path.as_os_str(),
8349 ]);
8350 if !ok {
8351 let _ = std::fs::remove_file(&path);
8355 if message.to_ascii_lowercase().contains("permission denied") {
8356 return Err(ServiceError::NeedsElevation {
8357 operation: "install",
8358 name,
8359 detail: message,
8360 remedy: SUDO_REMEDY,
8361 });
8362 }
8363 return Err(self.failed("install", &name, message));
8364 }
8365 let service_target = self.service_target(plan.identity());
8368 enable_launchd_registration(
8369 |arguments| self.launchctl(arguments),
8370 &target,
8371 &service_target,
8372 &path,
8373 &name,
8374 SUDO_REMEDY,
8375 )?;
8376 Ok(definition)
8377 }
8378
8379 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8380 let Some(path) = self.plist_path(identity) else {
8381 return Ok(false);
8382 };
8383 if !path.exists() {
8384 return Ok(false);
8385 }
8386 let target = self.service_target(identity);
8387 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8388 if !ok
8389 && !message.to_ascii_lowercase().contains("no such process")
8390 && !message.contains("113")
8391 {
8392 if message.to_ascii_lowercase().contains("permission denied") {
8393 return Err(ServiceError::NeedsElevation {
8394 operation: "uninstall",
8395 name: identity.name().to_string(),
8396 detail: message,
8397 remedy: SUDO_REMEDY,
8398 });
8399 }
8400 return Err(self.failed("uninstall", identity.name(), message));
8401 }
8402 std::fs::remove_file(&path).map_err(|error| {
8405 super::definition_error(
8406 "uninstall",
8407 identity.name(),
8408 error,
8409 SUDO_REMEDY,
8410 path.as_path(),
8411 )
8412 })?;
8413 Ok(true)
8414 }
8415
8416 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8417 let Some(path) = self.plist_path(identity) else {
8418 return Ok(None);
8419 };
8420 let Ok(document) = std::fs::read_to_string(&path) else {
8421 return Ok(None);
8422 };
8423 let target = self.service_target(identity);
8424 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8425 Ok(Some(Registration {
8426 manager: DefinitionKind::LaunchdPlist,
8427 start_mode: self.mode,
8428 command_line: program_arguments(&document),
8429 account: plist_string_value(&document, "UserName").or_else(|| {
8432 (self.mode == StartMode::Boot)
8433 .then(|| super::ServiceAccount::Root.as_str().to_string())
8434 }),
8435 running: loaded && printed.contains("state = running"),
8436 starts_automatically: document.contains("<key>RunAtLoad</key>")
8437 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8438 restart_delay: xml_value(
8439 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8440 "integer",
8441 )
8442 .and_then(|value| value.parse::<u64>().ok())
8443 .map(Duration::from_secs),
8444 }))
8445 }
8446
8447 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8448 let target = self.service_target(identity);
8449 let (ok, message) = self.launchctl(&[
8450 OsStr::new("kickstart"),
8451 OsStr::new("-k"),
8452 OsStr::new(&target),
8453 ]);
8454 if ok {
8455 Ok(())
8456 } else {
8457 Err(self.failed("start", identity.name(), message))
8458 }
8459 }
8460
8461 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8462 let running = self
8463 .query(identity)?
8464 .is_some_and(|registration| registration.running);
8465 if !running {
8466 return Ok(false);
8467 }
8468 let target = self.service_target(identity);
8469 let (ok, message) = self.launchctl(&[
8470 OsStr::new("kill"),
8471 OsStr::new("SIGTERM"),
8472 OsStr::new(&target),
8473 ]);
8474 if ok {
8475 Ok(true)
8476 } else {
8477 Err(self.failed("stop", identity.name(), message))
8478 }
8479 }
8480 }
8481
8482 pub(super) fn reload_detached(
8497 identity: &ServiceIdentity,
8498 mode: StartMode,
8499 plist: &std::path::Path,
8500 ) -> Result<(), ServiceError> {
8501 use std::os::unix::process::CommandExt;
8502
8503 const SCRIPT: &str = r#"(
8504 sleep 1
8505 /bin/launchctl bootout "$1"
8506 tries=0
8507 until /bin/launchctl bootstrap "$2" "$3"; do
8508 tries=$((tries + 1))
8509 if [ "$tries" -ge 60 ]; then
8510 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8511 exit 1
8512 fi
8513 sleep 1
8514 done
8515) </dev/null &"#;
8516
8517 let control = LaunchdControl { mode };
8518 let reinstall = match mode {
8521 StartMode::Boot => "sudo runner-manager service install --start-at boot",
8522 StartMode::Login => "runner-manager service install --start-at login",
8523 };
8524 let mut command = std::process::Command::new("/bin/sh");
8525 command
8526 .args(["-c", SCRIPT, "runner-manager-reload"])
8527 .arg(control.service_target(identity))
8528 .arg(control.domain())
8529 .arg(plist)
8530 .arg(reinstall)
8531 .stdin(std::process::Stdio::null());
8532 unsafe {
8535 command.pre_exec(|| {
8536 if libc::setsid() == -1 {
8537 return Err(std::io::Error::last_os_error());
8538 }
8539 Ok(())
8540 });
8541 }
8542 let status = command.status().map_err(|error| {
8543 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8544 })?;
8545 if status.success() {
8546 Ok(())
8547 } else {
8548 Err(control.failed(
8549 "reload",
8550 identity.name(),
8551 format!("the reload helper exited with {status}"),
8552 ))
8553 }
8554 }
8555
8556 fn program_arguments(document: &str) -> String {
8558 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8559 return String::new();
8560 };
8561 let Some(end) = rest.find("</array>") else {
8562 return String::new();
8563 };
8564 let mut out = Vec::new();
8565 let mut cursor = &rest[..end];
8566 while let Some(open) = cursor.find("<string>") {
8567 let after = &cursor[open + "<string>".len()..];
8568 let Some(close) = after.find("</string>") else {
8569 break;
8570 };
8571 out.push(quote_argument(&super::xml_unescape(&after[..close])));
8572 cursor = &after[close..];
8573 }
8574 out.join(" ")
8575 }
8576}
8577
8578#[cfg(all(unix, not(target_os = "macos")))]
8583mod sys {
8584 use std::ffi::OsStr;
8594 use std::path::PathBuf;
8595
8596 use runner_manager_domain::model::StartMode;
8597
8598 use super::{
8599 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8600 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8601 home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8602 write_definition,
8603 };
8604
8605 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8606 Ok(Box::new(SystemdControl { mode }))
8607 }
8608
8609 #[derive(Debug)]
8610 struct SystemdControl {
8611 mode: StartMode,
8612 }
8613
8614 impl SystemdControl {
8615 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8616 let file = identity.systemd_unit();
8617 match self.mode {
8618 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8619 StartMode::Login => {
8620 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8621 }
8622 }
8623 }
8624
8625 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8627 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8628 if self.mode == StartMode::Login {
8629 all.push(OsStr::new("--user"));
8630 }
8631 all.extend(arguments.iter().map(OsStr::new));
8632 match run("systemctl", &all) {
8633 Ok((ok, stdout, stderr)) => (
8634 ok,
8635 if stdout.trim().is_empty() {
8636 stderr
8637 } else {
8638 stdout
8639 },
8640 ),
8641 Err(error) => (false, error.to_string()),
8642 }
8643 }
8644
8645 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8646 ServiceError::Control {
8647 operation,
8648 name: name.to_string(),
8649 manager: "systemd",
8650 detail,
8651 }
8652 }
8653 }
8654
8655 impl ServiceControl for SystemdControl {
8656 fn manager(&self) -> DefinitionKind {
8657 DefinitionKind::SystemdUnit
8658 }
8659
8660 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8661 use std::os::unix::fs::MetadataExt as _;
8662
8663 let name = plan.identity().name().to_string();
8664 if self.mode == StartMode::Boot {
8665 let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8668 .iter()
8669 .filter_map(|path| {
8670 let path = std::path::Path::new(path);
8671 let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8672 super::boot_directory_refusal(path, owner)
8673 })
8674 .collect::<Vec<_>>();
8675 if !refusals.is_empty() {
8676 return Err(self.failed(
8677 "install",
8678 &name,
8679 format!(
8680 "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8681 directory there that root does not own is handed back with \
8682 `sudo chown -R root:root <directory>`",
8683 refusals.join("; "),
8684 super::sudo_command("service install --start-at boot"),
8685 crate::paths::SYSTEM_DATA_ROOT,
8686 crate::paths::SYSTEM_LOGS_DIR,
8687 ),
8688 ));
8689 }
8690 }
8691 if self.mode == StartMode::Boot
8695 && let Err(detail) = crate::runner_account::RunnerPool::ensure()
8696 {
8697 return Err(self.failed("install", &name, detail));
8698 }
8699 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8700 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8701 return Err(self.failed(
8702 "install",
8703 &name,
8704 "this account has no home directory, so there is nowhere to put a systemd \
8705 user unit. Use --start-at boot, which installs a system unit under \
8706 /etc/systemd/system."
8707 .to_string(),
8708 ));
8709 };
8710 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8711 let unit = plan.identity().systemd_unit();
8712 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8713 if !reloaded {
8714 let _ = std::fs::remove_file(&path);
8715 return Err(self.failed("install", &name, message));
8716 }
8717 let (enabled, message) = self.systemctl(&["enable", &unit]);
8718 if !enabled {
8719 let _ = std::fs::remove_file(&path);
8720 let _ = self.systemctl(&["daemon-reload"]);
8721 return Err(self.failed("install", &name, message));
8722 }
8723 Ok(definition)
8724 }
8725
8726 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8727 let Some(path) = self.unit_path(identity) else {
8728 return Ok(false);
8729 };
8730 if !path.exists() {
8731 return Ok(false);
8732 }
8733 let unit = identity.systemd_unit();
8734 let _ = self.systemctl(&["disable", "--now", &unit]);
8739 std::fs::remove_file(&path).map_err(|error| {
8740 super::definition_error(
8741 "uninstall",
8742 identity.name(),
8743 error,
8744 SUDO_REMEDY,
8745 path.as_path(),
8746 )
8747 })?;
8748 let _ = self.systemctl(&["daemon-reload"]);
8749 Ok(true)
8750 }
8751
8752 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8753 let Some(path) = self.unit_path(identity) else {
8754 return Ok(None);
8755 };
8756 if !path.exists() {
8757 return Ok(None);
8758 }
8759 let unit = identity.systemd_unit();
8760 let effective = match self.systemctl(&["cat", &unit]) {
8764 (true, text) => text,
8765 _ => match std::fs::read_to_string(&path) {
8766 Ok(document) => document,
8767 Err(_) => return Ok(None),
8768 },
8769 };
8770 let directives = ini_directives(&effective, "Service");
8771 let (_, shown) = self.systemctl(&[
8773 "show",
8774 "--property=ActiveState",
8775 "--property=UnitFileState",
8776 &unit,
8777 ]);
8778 let state = |key: &str| {
8779 shown
8780 .lines()
8781 .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8782 .map(str::trim)
8783 };
8784 Ok(Some(Registration {
8785 manager: DefinitionKind::SystemdUnit,
8786 start_mode: self.mode,
8787 command_line: directives
8788 .get("ExecStart")
8789 .map(|value| systemd_exec_command_line(value))
8790 .unwrap_or_default(),
8791 account: directives
8794 .get("User")
8795 .filter(|user| !user.is_empty())
8796 .cloned()
8797 .or_else(|| {
8798 (self.mode == StartMode::Boot)
8799 .then(|| super::ServiceAccount::Root.as_str().to_string())
8800 }),
8801 running: state("ActiveState") == Some("active"),
8802 starts_automatically: state("UnitFileState") == Some("enabled"),
8803 restart_delay: directives
8804 .get("RestartSec")
8805 .and_then(|value| systemd_timespan(value)),
8806 }))
8807 }
8808
8809 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8810 let unit = identity.systemd_unit();
8811 let (ok, message) = self.systemctl(&["start", &unit]);
8812 if ok {
8813 Ok(())
8814 } else {
8815 Err(self.failed("start", identity.name(), message))
8816 }
8817 }
8818
8819 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8820 let running = self
8821 .query(identity)?
8822 .is_some_and(|registration| registration.running);
8823 if !running {
8824 return Ok(false);
8825 }
8826 let unit = identity.systemd_unit();
8827 let (ok, message) = self.systemctl(&["stop", &unit]);
8828 if ok {
8829 Ok(true)
8830 } else {
8831 Err(self.failed("stop", identity.name(), message))
8832 }
8833 }
8834 }
8835}
8836
8837#[cfg(all(test, target_os = "macos"))]
8839pub(crate) mod launchd_fixture {
8840 use std::path::{Path, PathBuf};
8841
8842 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8846
8847 pub(crate) fn enabled() -> bool {
8850 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8851 if !enabled {
8852 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8853 }
8854 enabled
8855 }
8856
8857 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8865 let binary = directory.join("launch-agent-job");
8866 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8867 .expect("test binary copied");
8868 binary
8869 }
8870
8871 pub(crate) struct LoadedAgent(String);
8873
8874 impl LoadedAgent {
8875 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8878 let domain = format!("gui/{}", unsafe { libc::getuid() });
8881 let loaded = Self(format!("{domain}/{label}"));
8883 let bootstrap = std::process::Command::new("launchctl")
8884 .arg("bootstrap")
8885 .arg(&domain)
8886 .arg(plist)
8887 .output()
8888 .expect("launchctl runs");
8889 assert!(
8890 bootstrap.status.success(),
8891 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8892 String::from_utf8_lossy(&bootstrap.stderr)
8893 );
8894 loaded
8895 }
8896
8897 pub(crate) fn target(&self) -> &str {
8899 &self.0
8900 }
8901 }
8902
8903 impl Drop for LoadedAgent {
8904 fn drop(&mut self) {
8905 let _ = std::process::Command::new("launchctl")
8906 .args(["bootout", &self.0])
8907 .output();
8908 }
8909 }
8910}
8911
8912#[cfg(test)]
8913mod tests {
8914 use super::*;
8915
8916 use std::collections::BTreeMap;
8917
8918 #[test]
8919 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8920 assert!(windows_login_task_starts_automatically(
8921 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8922 ));
8923 assert!(windows_login_task_starts_automatically(
8924 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8925 ));
8926 assert!(!windows_login_task_starts_automatically(
8927 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8928 ));
8929 assert!(!windows_login_task_starts_automatically(
8930 "<Task><BootTrigger></BootTrigger></Task>"
8931 ));
8932 }
8933
8934 fn linux_plan(mode: StartMode) -> InstallPlan {
8941 InstallPlan::unchecked(
8942 ServiceIdentity::product(),
8943 mode,
8944 "/opt/runner-manager/bin/runner-manager",
8945 ServiceDirectories {
8946 config: PathBuf::from("/var/lib/runner-manager/config"),
8947 state: PathBuf::from("/var/lib/runner-manager/state"),
8948 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8949 logs: PathBuf::from("/var/log/runner-manager"),
8950 },
8951 )
8952 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8953 }
8954
8955 fn windows_plan(mode: StartMode) -> InstallPlan {
8956 InstallPlan::unchecked(
8957 ServiceIdentity::product(),
8958 mode,
8959 "C:\\Program Files\\runner-manager\\runner-manager.exe",
8960 ServiceDirectories {
8961 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8962 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8963 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8964 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8965 },
8966 )
8967 }
8968
8969 fn edited(text: &str, from: &str, to: &str) -> String {
8977 assert!(
8978 text.contains(from),
8979 "the rendered definition does not contain `{from}`, so this test would assert \
8980 nothing about a widened one"
8981 );
8982 text.replace(from, to)
8983 }
8984
8985 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8987 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8988 let Ok(entries) = std::fs::read_dir(directory) else {
8989 return;
8990 };
8991 for entry in entries.flatten() {
8992 let path = entry.path();
8993 if path.is_dir() {
8994 walk(&path, out);
8995 } else if let Ok(bytes) = std::fs::read(&path) {
8996 out.insert(path, bytes);
8997 }
8998 }
8999 }
9000 let mut out = BTreeMap::new();
9001 for root in roots {
9002 walk(root, &mut out);
9003 }
9004 out
9005 }
9006
9007 struct Host {
9008 _root: tempfile::TempDir,
9009 paths: AppPaths,
9010 binary: PathBuf,
9011 runner_root: LocalAbsolutePath,
9020 controls: RecordingControls,
9021 }
9022
9023 impl Host {
9024 fn new() -> Self {
9025 let root = tempfile::tempdir().expect("a temporary directory");
9026 let paths = AppPaths::rooted_at(root.path());
9027 paths.create_all().expect("the four directories");
9028 let binary = root.path().join(if cfg!(windows) {
9029 "runner-manager.exe"
9030 } else {
9031 "runner-manager"
9032 });
9033 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
9034 let runner_root = LocalAbsolutePath::new(
9035 root.path()
9036 .join("runner-root")
9037 .to_str()
9038 .expect("a unicode temporary path"),
9039 )
9040 .expect("a local absolute path");
9041 Self {
9042 _root: root,
9043 paths,
9044 binary,
9045 runner_root,
9046 controls: RecordingControls::new(),
9047 }
9048 }
9049
9050 fn operations(&self) -> ServiceOperations {
9051 ServiceOperations::with_controls(
9052 self.paths.clone(),
9053 ServiceIdentity::product(),
9054 std::sync::Arc::new(self.controls.clone()),
9055 )
9056 .with_runner_root(self.runner_root.clone())
9057 }
9058
9059 fn request(&self, mode: StartMode) -> InstallRequest {
9060 InstallRequest::new(mode).for_binary(&self.binary)
9061 }
9062 }
9063
9064 #[cfg(windows)]
9065 #[test]
9066 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
9067 let probes = std::cell::Cell::new(0);
9068 let absent =
9069 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
9070 let next = probes.get() + 1;
9071 probes.set(next);
9072 Ok(next == 3)
9073 })
9074 .expect("the simulated SCM probe succeeds");
9075
9076 assert!(absent);
9077 assert_eq!(
9078 probes.get(),
9079 3,
9080 "uninstall must recheck after transient presence instead of treating it as a leak"
9081 );
9082 }
9083
9084 #[cfg(windows)]
9085 #[test]
9086 fn windows_recognises_an_already_pending_service_deletion() {
9087 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
9088
9089 assert!(super::sys::is_marked_for_delete(&error));
9090 }
9091
9092 #[test]
9093 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
9094 let root = tempfile::tempdir().expect("a temporary directory");
9095 let plist = root.path().join("fixture.plist");
9096 std::fs::write(&plist, b"fixture").expect("a plist fixture");
9097 let calls = std::cell::RefCell::new(Vec::new());
9098
9099 let error = enable_launchd_registration(
9100 |arguments| {
9101 let call = arguments
9102 .iter()
9103 .map(|argument| argument.to_string_lossy().into_owned())
9104 .collect::<Vec<_>>();
9105 let operation = call[0].clone();
9106 calls.borrow_mut().push(call);
9107 if operation == "enable" {
9108 (false, "label remains disabled".to_string())
9109 } else {
9110 (true, String::new())
9111 }
9112 },
9113 "system",
9114 "system/com.openai.runner-manager-selftest",
9115 &plist,
9116 "runner-manager-selftest",
9117 "rerun with administrative rights",
9118 )
9119 .expect_err("enable failure must fail the install");
9120
9121 assert!(
9122 matches!(
9123 error,
9124 ServiceError::Control {
9125 operation: "enable",
9126 ..
9127 }
9128 ),
9129 "{error}"
9130 );
9131 assert_eq!(calls.borrow().len(), 2);
9132 assert_eq!(calls.borrow()[0][0], "enable");
9133 assert_eq!(calls.borrow()[1][0], "bootout");
9134 assert!(!plist.exists(), "rollback must remove the plist");
9135 }
9136
9137 #[test]
9142 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
9143 let plan = windows_plan(StartMode::Boot);
9144 let command_line = plan.command_line();
9145 assert!(
9146 command_line.starts_with('"'),
9147 "a path with a space must be quoted, got {command_line}"
9148 );
9149 assert_eq!(
9150 executable_from_command_line(&command_line).as_deref(),
9151 Some(plan.binary())
9152 );
9153 }
9154
9155 #[test]
9156 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
9157 let plan = linux_plan(StartMode::Boot);
9158 let command_line = plan.command_line();
9159 assert!(!command_line.starts_with('"'), "got {command_line}");
9160 assert_eq!(
9161 executable_from_command_line(&command_line).as_deref(),
9162 Some(plan.binary())
9163 );
9164 }
9165
9166 #[test]
9167 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
9168 let awkward = r#"C:\odd "name"\rm.exe"#;
9171 let quoted = quote_argument(awkward);
9172 assert_eq!(
9173 executable_from_command_line(&format!("{quoted} daemon run"))
9174 .as_deref()
9175 .map(Path::to_string_lossy)
9176 .as_deref(),
9177 Some(awkward)
9178 );
9179 }
9180
9181 #[test]
9182 fn an_empty_command_line_has_no_executable() {
9183 assert_eq!(executable_from_command_line(" "), None);
9184 assert_eq!(executable_from_command_line(""), None);
9185 }
9186
9187 #[test]
9188 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
9189 let awkward = r#"DOMAIN\R&D <team> "ops""#;
9190 assert_eq!(
9191 xml_escape(awkward),
9192 "DOMAIN\\R&D <team> "ops""
9193 );
9194 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
9195 }
9196
9197 #[test]
9202 fn a_restart_delay_under_the_floor_is_refused() {
9203 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9204 .expect_err("half a second is under the one-second floor");
9205 assert!(
9206 matches!(error, ServiceError::RestartDelay { .. }),
9207 "{error}"
9208 );
9209 }
9210
9211 #[test]
9212 fn a_restart_delay_over_the_ceiling_is_refused() {
9213 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9214 .expect_err("an hour is over the five-minute ceiling");
9215 assert!(
9216 matches!(error, ServiceError::RestartDelay { .. }),
9217 "{error}"
9218 );
9219 }
9220
9221 #[test]
9222 fn a_reset_window_no_longer_than_the_delay_is_refused() {
9223 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9224 .expect_err("a window equal to the delay can never elapse between restarts");
9225 assert!(
9226 matches!(error, ServiceError::RestartResetWindow { .. }),
9227 "{error}"
9228 );
9229 }
9230
9231 #[test]
9232 fn a_delay_inside_the_bound_is_accepted() {
9233 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9234 .expect("twenty seconds is inside the bound");
9235 assert_eq!(policy.delay(), Duration::from_secs(20));
9236 assert_eq!(policy.reset_after(), Duration::from_secs(300));
9237 }
9238
9239 #[test]
9244 fn a_fixture_identity_can_never_be_the_product_identity() {
9245 let fixture = ServiceIdentity::fixture("abc123");
9246 assert!(fixture.is_fixture());
9247 assert!(!ServiceIdentity::product().is_fixture());
9248 assert_ne!(fixture.name(), ServiceIdentity::product().name());
9249 assert_ne!(
9250 fixture.launchd_label(),
9251 ServiceIdentity::product().launchd_label()
9252 );
9253 assert_ne!(
9254 fixture.systemd_unit(),
9255 ServiceIdentity::product().systemd_unit()
9256 );
9257 }
9258
9259 #[test]
9260 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9261 let fixture = ServiceIdentity::fixture("A b/c:\\d");
9262 assert!(
9263 fixture
9264 .name()
9265 .chars()
9266 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9267 "got {}",
9268 fixture.name()
9269 );
9270 }
9271
9272 #[test]
9277 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9278 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9279 assert!(unit.contains("KillMode=process\n"), "{unit}");
9280 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9281 assert!(unit.contains("RestartSec=15\n"), "{unit}");
9282 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9283 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9284 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9285 }
9286
9287 #[test]
9288 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9289 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9290 assert!(
9291 !unit.contains("LoadCredential="),
9292 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9293 );
9294 }
9295
9296 #[test]
9297 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9298 let unit = systemd_unit(&linux_plan(StartMode::Login));
9299 assert!(
9300 !unit.contains("LoadCredential="),
9301 "a user unit must not name a root-owned credential file, got:\n{unit}"
9302 );
9303 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9304 }
9305
9306 #[test]
9307 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9308 let plan = linux_plan(StartMode::Boot);
9309 let unit = systemd_unit(&plan);
9310 let directives = ini_directives(&unit, "Service");
9311 let listed = split_quoted(
9312 directives
9313 .get("ReadWritePaths")
9314 .expect("the unit names its writable paths"),
9315 );
9316 assert_eq!(listed.len(), 5, "{listed:?}");
9317 for path in plan.directories().all() {
9318 assert!(
9319 listed.iter().any(|entry| entry == &path.to_string_lossy()),
9320 "{} is missing from {listed:?}",
9321 path.display()
9322 );
9323 }
9324 assert!(
9325 listed
9326 .iter()
9327 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9328 "atomic credential replacement needs its parent directory in {listed:?}"
9329 );
9330 }
9331
9332 #[test]
9333 fn the_unit_records_the_absolute_binary_path() {
9334 let plan = linux_plan(StartMode::Boot);
9335 let unit = systemd_unit(&plan);
9336 assert!(
9337 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9338 "{unit}"
9339 );
9340 }
9341
9342 #[test]
9347 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9348 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9349 assert!(
9352 plist.contains(
9353 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
9354 ),
9355 "{plist}"
9356 );
9357 assert!(
9358 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
9359 "{plist}"
9360 );
9361 }
9362
9363 #[test]
9364 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9365 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9366 assert_eq!(
9367 plist_string_value(&daemon, "UserName").as_deref(),
9368 Some("root")
9369 );
9370 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9371
9372 let agent = launchd_plist(&linux_plan(StartMode::Login));
9373 assert_eq!(
9374 plist_string_value(&agent, "UserName"),
9375 None,
9376 "a LaunchAgent already runs as the operator:\n{agent}"
9377 );
9378 }
9379
9380 #[test]
9381 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9382 for mode in [StartMode::Boot, StartMode::Login] {
9383 let plan = linux_plan(mode);
9384 let plist = launchd_plist(&plan);
9385 assert_eq!(
9386 plist_string_value(&plist, "ProcessType").as_deref(),
9387 Some("Interactive"),
9388 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9389 );
9390 let review = review_least_privilege(
9391 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9392 &plan,
9393 );
9394 assert!(
9395 review
9396 .controls()
9397 .iter()
9398 .any(|control| control == "ProcessType=Interactive"),
9399 "{review}"
9400 );
9401 assert!(
9402 !review
9403 .findings()
9404 .iter()
9405 .any(|finding| finding.subject == "ProcessType"),
9406 "{review}"
9407 );
9408 }
9409 }
9410
9411 #[test]
9415 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9416 let plan = linux_plan(StartMode::Login);
9417 let rendered = launchd_plist(&plan);
9418 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
9419 for (label, throttled) in [
9420 (
9421 "Background",
9422 edited(
9423 &rendered,
9424 interactive,
9425 "<key>ProcessType</key>\n <string>Background</string>\n",
9426 ),
9427 ),
9428 ("absent", edited(&rendered, interactive, "")),
9429 ] {
9430 let review = review_least_privilege(
9431 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9432 &plan,
9433 );
9434 assert!(review.is_least_privilege(), "{label}: {review}");
9435 assert!(
9436 review.findings().iter().any(|finding| {
9437 finding.kind == FindingKind::Shortfall
9438 && finding.subject == "ProcessType"
9439 && finding.detail.contains(label)
9440 }),
9441 "{label}: {review}"
9442 );
9443 }
9444 }
9445
9446 #[test]
9447 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9448 let host = Host::new();
9449 let identity = ServiceIdentity::product();
9450 let plan = InstallPlan::resolve(
9451 identity.clone(),
9452 &host.request(StartMode::Login),
9453 ServiceDirectories::of(&host.paths),
9454 )
9455 .expect("a resolvable plan");
9456 let installed = launchd_plist(&plan);
9457 let definition =
9458 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9459 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9460 record.installed_by_version = "0.4.31".into();
9461 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9462
9463 assert_eq!(
9464 launchd_definition_drift(&identity, &record, path.clone(), &installed),
9465 None
9466 );
9467 let edited_by_operator = edited(
9468 &installed,
9469 "</dict>\n</plist>\n",
9470 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
9471 );
9472 assert_eq!(
9473 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9474 None,
9475 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9476 );
9477
9478 let written_by_0_4_31 = edited(
9479 &installed,
9480 "<string>Interactive</string>",
9481 "<string>Background</string>",
9482 );
9483 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9484 .expect("a Background plist is drift");
9485 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9486 assert_eq!(drift.start_mode, StartMode::Login);
9487 assert_eq!(
9488 drift.rendered, installed,
9489 "the repair must write exactly what `service install` writes"
9490 );
9491
9492 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9493 .with_definition_drift(Some(drift));
9494 let problem = status
9495 .problems()
9496 .iter()
9497 .find(|problem| problem.subject == "definition")
9498 .expect("the drift is reported");
9499 assert!(
9502 problem.detail.contains("0.4.31")
9503 && problem.detail.contains(
9504 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9505 )
9506 && !problem.detail.contains("service install")
9507 && !problem.detail.contains("systemctl")
9508 && !problem.detail.contains("sudo"),
9509 "{}",
9510 problem.detail
9511 );
9512 }
9513
9514 #[cfg(unix)]
9515 #[test]
9516 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9517 use std::os::unix::fs::PermissionsExt as _;
9518
9519 let root = tempfile::tempdir().expect("a temporary directory");
9520 let path = root
9521 .path()
9522 .join("io.github.IvanMurzak.runner-manager.plist");
9523 std::fs::write(&path, "<string>Background</string>").expect("old plist");
9524 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9525 let drift = DefinitionDrift {
9526 path: path.clone(),
9527 installed_by_version: "0.4.31".into(),
9528 rendered: launchd_plist(&linux_plan(StartMode::Login)),
9529 start_mode: StartMode::Login,
9530 manager: DefinitionKind::LaunchdPlist,
9531 };
9532
9533 replace_definition("runner-manager", &drift).expect("replaced");
9534
9535 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9536 assert_eq!(
9537 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9538 0o644
9539 );
9540 assert_eq!(
9541 std::fs::read_dir(root.path()).unwrap().count(),
9542 1,
9543 "no staging file is left beside the plist"
9544 );
9545 }
9546
9547 #[cfg(target_os = "macos")]
9556 #[test]
9557 #[ignore = "run only by launchd, as the job of the detached-reload test"]
9558 fn detached_reload_job() {
9559 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9560 let tag = label
9561 .split_once("-selftest-")
9562 .map(|(_, tag)| tag.to_string())
9563 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9564 let identity = ServiceIdentity::fixture(&tag);
9565 assert_eq!(identity.launchd_label(), label);
9566 let path = std::env::current_dir()
9567 .expect("working directory")
9568 .join(format!("{label}.plist"));
9569 let installed = std::fs::read_to_string(&path).expect("own plist");
9570 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9571 let drift = DefinitionDrift {
9572 path: path.clone(),
9573 installed_by_version: "0.4.31".into(),
9574 rendered: installed.replace(
9575 "<string>Background</string>",
9576 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9577 ),
9578 start_mode: StartMode::Login,
9579 manager: DefinitionKind::LaunchdPlist,
9580 };
9581 replace_definition(identity.name(), &drift).expect("plist replaced");
9582 super::sys::reload_detached(&identity, StartMode::Login, &path)
9583 .expect("helper started");
9584 }
9585 std::thread::sleep(Duration::from_secs(120));
9586 }
9587
9588 #[cfg(target_os = "macos")]
9598 #[test]
9599 fn a_job_reloads_itself_from_its_rewritten_plist() {
9600 if !super::launchd_fixture::enabled() {
9601 return;
9602 }
9603 let root = tempfile::tempdir().expect("a temporary directory");
9604 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9605 let label = identity.launchd_label();
9606 let path = root.path().join(format!("{label}.plist"));
9607 let binary = super::launchd_fixture::job_binary(root.path());
9608 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9609 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9610 plist.push_str(&plist_string("Label", &label));
9611 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
9612 for argument in [
9613 binary.to_string_lossy().as_ref(),
9614 "--ignored",
9615 "--exact",
9616 "service::tests::detached_reload_job",
9617 "--test-threads=1",
9618 ] {
9619 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
9620 }
9621 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
9622 plist.push_str(&plist_string(
9623 "WorkingDirectory",
9624 &root.path().to_string_lossy(),
9625 ));
9626 plist.push_str(&plist_string("ProcessType", "Background"));
9627 plist.push_str("</dict>\n</plist>\n");
9628 std::fs::write(&path, plist).expect("plist written");
9629
9630 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9631 let spawn_type = || {
9632 let printed = std::process::Command::new("launchctl")
9633 .args(["print", loaded.target()])
9634 .output()
9635 .expect("launchctl runs");
9636 String::from_utf8_lossy(&printed.stdout)
9637 .lines()
9638 .find_map(|line| {
9639 line.trim()
9640 .strip_prefix("spawn type = ")
9641 .map(str::to_string)
9642 })
9643 };
9644 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9645
9646 let deadline = std::time::Instant::now() + Duration::from_secs(90);
9647 while spawn_type().as_deref() != Some("interactive (4)") {
9648 assert!(
9649 std::time::Instant::now() < deadline,
9650 "the job did not come back under its rewritten plist; launchd reports {:?}",
9651 spawn_type()
9652 );
9653 std::thread::sleep(Duration::from_millis(250));
9654 }
9655 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9656 assert_eq!(
9657 plist_string_value(&rewritten, "ProcessType").as_deref(),
9658 Some(LAUNCHD_PROCESS_TYPE)
9659 );
9660 }
9661
9662 #[test]
9663 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9664 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9665 assert!(
9666 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9667 "{plist}"
9668 );
9669 assert!(plist.contains("<string>daemon</string>"), "{plist}");
9670 assert!(plist.contains("<string>run</string>"), "{plist}");
9671 }
9672
9673 #[test]
9674 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9675 assert_eq!(
9676 ServiceIdentity::product().launchd_label(),
9677 "io.github.IvanMurzak.runner-manager"
9678 );
9679 }
9680
9681 #[test]
9686 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9687 let xml = windows_scheduled_task_xml(
9688 &windows_plan(StartMode::Login),
9689 &TaskPrincipal::named("HOST\\operator"),
9690 );
9691 assert!(xml.contains("<LogonTrigger>"), "{xml}");
9692 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9693 assert!(
9694 xml.contains("<LogonType>InteractiveToken</LogonType>"),
9695 "{xml}"
9696 );
9697 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9698 assert!(
9699 xml.contains("<Interval>PT1M</Interval>"),
9700 "Task Scheduler takes whole minutes only, and rejects the registration outright \
9701 for anything finer:\n{xml}"
9702 );
9703 }
9704
9705 #[test]
9706 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9707 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9712 let policy =
9713 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9714 .expect("inside the supported range");
9715 assert_eq!(
9716 policy
9717 .effective_delay(DefinitionKind::WindowsScheduledTask)
9718 .as_secs(),
9719 enforced,
9720 "a {asked}s delay must be enforced as {enforced}s"
9721 );
9722 assert!(
9723 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9724 "rounding must never shorten the bound"
9725 );
9726 }
9727 }
9728
9729 #[test]
9730 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9731 let policy = RestartPolicy::default();
9732 for kind in [
9733 DefinitionKind::WindowsService,
9734 DefinitionKind::LaunchdPlist,
9735 DefinitionKind::SystemdUnit,
9736 ] {
9737 assert_eq!(
9738 policy.effective_delay(kind),
9739 policy.delay(),
9740 "{kind:?} takes seconds and enforces exactly what it is given"
9741 );
9742 }
9743 }
9744
9745 #[test]
9746 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9747 let host = Host::new();
9748 let operations = host.operations();
9749 operations
9750 .install(&host.request(StartMode::Login))
9751 .expect("an install at login");
9752 host.controls.edit("runner-manager", |registration| {
9754 registration.manager = DefinitionKind::WindowsScheduledTask;
9755 registration.restart_delay = Some(Duration::from_secs(60));
9756 });
9757
9758 let status = operations.status().expect("a status");
9759 assert!(
9760 status.is_healthy(),
9761 "minute granularity is the manager's, not a mis-registration: {status}"
9762 );
9763 assert!(
9764 status
9765 .notes()
9766 .iter()
9767 .any(|note| note.contains("whole minutes")),
9768 "but the operator must be told why 15 became 60: {status}"
9769 );
9770
9771 host.controls.edit("runner-manager", |registration| {
9774 registration.restart_delay = Some(Duration::from_secs(1));
9775 });
9776 assert!(
9777 !operations.status().expect("a status").is_healthy(),
9778 "a one-second delay is not what any manager was asked for"
9779 );
9780 }
9781
9782 #[test]
9783 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9784 let plan = windows_plan(StartMode::Login);
9785 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9786 assert_eq!(
9787 xml_value(&xml, "Command").as_deref(),
9788 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9789 "{xml}"
9790 );
9791 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9792 }
9793
9794 #[test]
9795 fn an_account_name_holding_xml_punctuation_is_escaped() {
9796 let xml = windows_scheduled_task_xml(
9797 &windows_plan(StartMode::Login),
9798 &TaskPrincipal::named("R&D\\ops"),
9799 );
9800 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
9801 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9802 }
9803
9804 #[test]
9809 fn the_service_starts_automatically_under_the_account_the_store_admits() {
9810 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9811 let directives = ini_directives(&text, "windows-service");
9812 assert_eq!(
9813 directives.get("StartType").map(String::as_str),
9814 Some("AutoStart")
9815 );
9816 assert_eq!(
9817 directives.get("Account").map(String::as_str),
9818 Some("NT AUTHORITY\\SYSTEM")
9819 );
9820 assert_eq!(
9821 directives.get("ServiceType").map(String::as_str),
9822 Some("OWN_PROCESS")
9823 );
9824 assert_eq!(
9825 directives
9826 .get("FailureActionRestartDelaySecs")
9827 .map(String::as_str),
9828 Some("15")
9829 );
9830 assert_eq!(
9831 directives
9832 .get("FailureActionsOnNonCrashFailures")
9833 .map(String::as_str),
9834 Some("true"),
9835 "without this flag a non-zero exit is not a failure the manager restarts"
9836 );
9837 }
9838
9839 #[test]
9840 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9841 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9842 assert_eq!(spec.account, None);
9843 assert!(spec.automatic_start);
9844 assert!(
9845 spec.command_line.contains("daemon run"),
9846 "{}",
9847 spec.command_line
9848 );
9849 }
9850
9851 #[test]
9856 fn each_definition_goes_where_its_platform_expects_it() {
9857 let home = PathBuf::from("/home/op");
9858 assert_eq!(
9859 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9860 Some(Path::new(
9861 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9862 ))
9863 );
9864 assert_eq!(
9865 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9866 Some(Path::new(
9867 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9868 ))
9869 );
9870 assert_eq!(
9871 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9872 Some(Path::new("/etc/systemd/system/runner-manager.service"))
9873 );
9874 assert_eq!(
9875 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9876 Some(Path::new(
9877 "/home/op/.config/systemd/user/runner-manager.service"
9878 ))
9879 );
9880 assert_eq!(
9881 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9882 None,
9883 "the Service Control Manager has no file"
9884 );
9885 }
9886
9887 #[test]
9888 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9889 assert_eq!(
9890 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9891 None
9892 );
9893 assert_eq!(
9894 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9895 None
9896 );
9897 }
9898
9899 #[test]
9904 fn the_rendered_definitions_are_all_least_privilege() {
9905 let linux = linux_plan(StartMode::Boot);
9906 let windows = windows_plan(StartMode::Boot);
9907 for (definition, plan) in [
9908 (ServiceDefinition::systemd(&linux, None), &linux),
9909 (ServiceDefinition::launchd(&linux, None), &linux),
9910 (ServiceDefinition::windows_service(&windows), &windows),
9911 ] {
9912 let review = review_least_privilege(&definition, plan);
9913 assert!(
9914 review.is_least_privilege(),
9915 "{:?} should be least privilege, got:\n{review}",
9916 definition.kind()
9917 );
9918 assert!(
9919 !review.controls().is_empty(),
9920 "a review that confirms nothing proves nothing: {:?}",
9921 definition.kind()
9922 );
9923 }
9924 }
9925
9926 #[test]
9927 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9928 let plan = windows_plan(StartMode::Login);
9929 let definition =
9930 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9931 let review = review_least_privilege(&definition, &plan);
9932 assert!(review.is_least_privilege(), "{review}");
9933 assert!(
9934 review
9935 .controls()
9936 .iter()
9937 .any(|control| control.contains("LeastPrivilege")),
9938 "{review}"
9939 );
9940 }
9941
9942 #[test]
9943 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9944 let plan = linux_plan(StartMode::Boot);
9945 let rendered = systemd_unit(&plan);
9946 let widened = edited(
9947 &rendered,
9948 "ReadWritePaths=/var/lib/runner-manager/config",
9949 "ReadWritePaths=/etc /var/lib/runner-manager/config",
9950 );
9951 let review = review_least_privilege(
9952 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9953 &plan,
9954 );
9955 assert!(!review.is_least_privilege(), "{review}");
9956 assert!(
9957 review
9958 .excesses()
9959 .iter()
9960 .any(|finding| finding.detail.contains("/etc")),
9961 "the review must name the directory it objects to: {review}"
9962 );
9963 }
9964
9965 #[test]
9966 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9967 let plan = linux_plan(StartMode::Boot);
9968 let rendered = systemd_unit(&plan);
9969 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9970 let review = review_least_privilege(
9971 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9972 &plan,
9973 );
9974 assert!(!review.is_least_privilege(), "{review}");
9975 assert!(
9976 review
9977 .excesses()
9978 .iter()
9979 .any(|finding| finding.subject == "NoNewPrivileges"),
9980 "{review}"
9981 );
9982 }
9983
9984 fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
9986 review_least_privilege(
9987 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
9988 plan,
9989 )
9990 .findings()
9991 .iter()
9992 .any(|finding| finding.subject == subject && finding.kind == kind)
9993 }
9994
9995 #[test]
9996 fn protected_paths_are_inaccessible_and_the_review_keeps_them_so() {
9997 let plan = linux_plan(StartMode::Boot).with_protected_paths(vec![
9998 PathBuf::from("/srv/keys"),
9999 PathBuf::from("/data/my backups"),
10000 ]);
10001 let unit = systemd_unit(&plan);
10002 let directives = ini_directives(&unit, "Service");
10003 assert_eq!(
10004 split_quoted(&directives["InaccessiblePaths"]),
10005 ["-/srv/keys", "-/data/my backups"]
10006 );
10007 assert!(!reports(
10008 &unit,
10009 &plan,
10010 "InaccessiblePaths",
10011 FindingKind::Excess
10012 ));
10013 let dropped = unit.replace(
10014 &format!("InaccessiblePaths={}\n", directives["InaccessiblePaths"]),
10015 "",
10016 );
10017 assert!(reports(
10018 &dropped,
10019 &plan,
10020 "InaccessiblePaths",
10021 FindingKind::Excess
10022 ));
10023 assert!(
10024 !ini_directives(&systemd_unit(&linux_plan(StartMode::Boot)), "Service")
10025 .contains_key("InaccessiblePaths")
10026 );
10027 }
10028
10029 #[cfg(unix)]
10031 #[test]
10032 fn a_path_every_job_or_the_service_needs_cannot_be_protected() {
10033 let own = [PathBuf::from("/var/lib/runner-manager/config")];
10034 for refused in [
10035 "/",
10036 "/usr",
10037 "/etc",
10038 "/tmp",
10039 "relative",
10040 "/srv/../etc",
10041 "/var/lib",
10042 "/var/lib/runner-manager/config/x",
10043 ] {
10044 assert!(
10045 ProtectedPaths::refusal(Path::new(refused), &own).is_some(),
10046 "{refused}"
10047 );
10048 }
10049 for accepted in ["/srv/keys", "/home/ivan/.ssh", "/var/lib/postgresql"] {
10050 assert_eq!(
10051 ProtectedPaths::refusal(Path::new(accepted), &own),
10052 None,
10053 "{accepted}"
10054 );
10055 }
10056 let directory = tempfile::tempdir().unwrap();
10057 assert_eq!(
10058 ProtectedPaths::load(directory.path()).unwrap(),
10059 ProtectedPaths::default()
10060 );
10061 let list = ProtectedPaths {
10062 paths: vec![PathBuf::from("/srv/keys")],
10063 };
10064 list.save(directory.path()).unwrap();
10065 assert_eq!(ProtectedPaths::load(directory.path()).unwrap(), list);
10066 }
10067
10068 #[test]
10069 fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
10070 for mode in [StartMode::Boot, StartMode::Login] {
10071 let unit = systemd_unit(&linux_plan(mode));
10072 assert!(
10073 !unit.contains("RestrictSUIDSGID"),
10074 "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
10075 );
10076 }
10077 }
10078
10079 #[test]
10080 fn only_a_boot_daemon_keeps_capabilities_and_boot_units_hide_home_trees() {
10081 let boot = systemd_unit(&linux_plan(StartMode::Boot));
10082 let directives = ini_directives(&boot, "Service");
10083 assert_eq!(directives["CapabilityBoundingSet"], BOOT_CAPABILITIES);
10084 assert_eq!(directives["AmbientCapabilities"], "");
10085 assert_eq!(directives["NoNewPrivileges"], "yes");
10086 assert_eq!(directives["ProtectHome"], "tmpfs");
10087 assert!(!directives.contains_key("BindPaths"));
10088
10089 let login = systemd_unit(&linux_plan(StartMode::Login));
10090 let directives = ini_directives(&login, "Service");
10091 assert_eq!(directives["CapabilityBoundingSet"], "");
10092 assert!(!directives.contains_key("ProtectHome"));
10093
10094 let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
10095 assert!(reports(
10096 &unprotected,
10097 &linux_plan(StartMode::Boot),
10098 "ProtectHome",
10099 FindingKind::Excess
10100 ));
10101 let boot_set = format!("CapabilityBoundingSet={BOOT_CAPABILITIES}\n");
10102 let inverted = boot.replace(&boot_set, "CapabilityBoundingSet=~CAP_SYS_ADMIN\n");
10103 assert!(reports(
10104 &inverted,
10105 &linux_plan(StartMode::Boot),
10106 "CapabilityBoundingSet",
10107 FindingKind::Excess
10108 ));
10109 let widened = boot.replace(
10110 &boot_set,
10111 &format!("CapabilityBoundingSet={BOOT_CAPABILITIES} CAP_SYS_ADMIN\n"),
10112 );
10113 assert!(reports(
10114 &widened,
10115 &linux_plan(StartMode::Boot),
10116 "CapabilityBoundingSet",
10117 FindingKind::Excess
10118 ));
10119 let narrowed = boot.replace(&boot_set, "CapabilityBoundingSet=CAP_SETUID\n");
10120 assert!(reports(
10121 &narrowed,
10122 &linux_plan(StartMode::Boot),
10123 "CapabilityBoundingSet",
10124 FindingKind::Shortfall
10125 ));
10126 }
10127
10128 #[test]
10129 fn status_reports_the_registered_account_and_falls_back_to_the_record() {
10130 let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
10131 for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
10132 let host = Host::new();
10133 let operations = host.operations();
10134 operations.install(&host.request(StartMode::Boot)).unwrap();
10135 host.controls.edit("runner-manager", |registration| {
10136 registration.account = registered.map(str::to_string);
10137 });
10138 let status = operations.status().unwrap().to_string();
10139 assert!(
10140 status.contains(&format!("account {shown}\n")),
10141 "{status}"
10142 );
10143 }
10144 }
10145
10146 #[cfg(target_os = "linux")]
10147 #[test]
10148 fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
10149 let host = Host::new();
10150 let operations = host.operations();
10151 operations.install(&host.request(StartMode::Boot)).unwrap();
10152 host.controls.edit("runner-manager", |registration| {
10153 registration.account = Some("ivan".to_string());
10154 });
10155 let refused = operations
10156 .install(&host.request(StartMode::Boot))
10157 .expect_err("a unit running as ivan cannot use root's tree");
10158 let message = refused.to_string();
10159 assert!(message.contains("run as ivan"), "{message}");
10160 assert!(message.contains("Remove `User=`"), "{message}");
10161 }
10162
10163 #[test]
10164 fn manager_account_spellings_match_the_record() {
10165 assert_eq!(
10166 windows_service_account("LocalSystem"),
10167 ServiceAccount::LocalSystem.as_str()
10168 );
10169 assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
10170 assert_eq!(
10171 scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
10172 None
10173 );
10174 assert_eq!(
10175 scheduled_task_account(Some("HOST\\op".into())),
10176 Some("HOST\\op".to_string())
10177 );
10178 }
10179
10180 #[test]
10181 fn exec_start_prefixes_are_not_part_of_the_binary() {
10182 for (value, expected) in [
10183 ("/opt/rm daemon run", "/opt/rm daemon run"),
10184 ("-/opt/rm daemon run", "/opt/rm daemon run"),
10185 ("+!/opt/rm daemon run", "/opt/rm daemon run"),
10186 ("!!/opt/rm daemon run", "/opt/rm daemon run"),
10187 ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
10188 ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
10189 ] {
10190 assert_eq!(systemd_exec_command_line(value), expected, "{value}");
10191 }
10192 assert_eq!(
10193 executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
10194 Some(PathBuf::from("/opt/rm"))
10195 );
10196 }
10197
10198 #[test]
10199 fn restart_sec_accepts_systemd_time_spans() {
10200 for (value, expected) in [
10201 ("15", Duration::from_secs(15)),
10202 ("15s", Duration::from_secs(15)),
10203 ("1min", Duration::from_secs(60)),
10204 ("1min 30s", Duration::from_secs(90)),
10205 ("1min30s", Duration::from_secs(90)),
10206 ("2h", Duration::from_secs(7_200)),
10207 ("500ms", Duration::from_millis(500)),
10208 ("1.5s", Duration::from_millis(1_500)),
10209 ] {
10210 assert_eq!(systemd_timespan(value), Some(expected), "{value}");
10211 }
10212 for value in ["", "infinity", "soon", "5 parsecs", "s"] {
10213 assert_eq!(systemd_timespan(value), None, "{value}");
10214 }
10215 }
10216
10217 #[cfg(unix)]
10218 #[test]
10219 fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
10220 let system = ServiceDirectories::of(&AppPaths::system());
10221 assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
10222 assert_eq!(
10223 data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
10224 Some("root")
10225 );
10226 }
10227
10228 #[test]
10229 fn a_boot_service_refuses_directories_it_could_not_open() {
10230 let var = Path::new("/var/lib/runner-manager/state");
10231 assert_eq!(boot_directory_refusal(var, Some(0)), None);
10232 let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
10233 assert!(refused.contains("uid 1000"), "{refused}");
10234 assert!(boot_directory_refusal(var, None).is_some());
10235 for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
10236 let refused =
10237 boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
10238 assert!(refused.contains("cannot see"), "{refused}");
10239 }
10240 }
10241
10242 #[cfg(unix)]
10243 #[test]
10244 fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
10245 use std::os::unix::fs::PermissionsExt as _;
10246
10247 let from = tempfile::tempdir().unwrap();
10248 let to = tempfile::tempdir().unwrap();
10249 let target = to.path().join("state");
10250 std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
10251 std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
10252 std::fs::write(from.path().join("secret"), b"x").unwrap();
10253 std::fs::set_permissions(
10254 from.path().join("secret"),
10255 std::fs::Permissions::from_mode(0o600),
10256 )
10257 .unwrap();
10258 std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
10259 std::fs::create_dir(from.path().join("bin")).unwrap();
10260
10261 copy_tree(from.path(), &target, Some("bin")).unwrap();
10262
10263 assert_eq!(
10264 std::fs::read(target.join("journal/2026/attempt")).unwrap(),
10265 b"entry"
10266 );
10267 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
10268 assert_eq!(mode(&target.join("secret")), 0o600);
10269 assert_eq!(mode(&target.join("journal")), 0o700);
10270 assert_eq!(
10271 std::fs::read_link(target.join("latest")).unwrap(),
10272 Path::new("journal")
10273 );
10274 assert!(!target.join("bin").exists());
10275
10276 std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
10278 set_aside(&target, "20261110T000000Z").unwrap();
10279 assert!(!target.exists());
10280 let aside = to.path().join("state.before-move-20261110T000000Z");
10281 assert_eq!(
10282 std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
10283 b"stale"
10284 );
10285 copy_tree(from.path(), &target, Some("bin")).unwrap();
10286 assert!(!target.join("stale.sqlite3-wal").exists());
10287 set_aside(&to.path().join("absent"), "x").unwrap();
10288 }
10289
10290 #[test]
10291 fn systemd_drop_ins_replace_base_service_directives() {
10292 let base = systemd_unit(&linux_plan(StartMode::Boot));
10293 let combined = format!(
10294 "# /etc/systemd/system/runner-manager.service\n{base}\n\
10295 # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10296 [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10297 ExecStart=/opt/runner-manager daemon run\n"
10298 );
10299 let directives = ini_directives(&combined, "Service");
10300 assert_eq!(directives["User"], "ivan");
10301 assert_eq!(directives["RestartSec"], "30");
10302 assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10303 assert_eq!(directives["ProtectSystem"], "strict");
10304 }
10305
10306 #[test]
10307 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10308 let plan = linux_plan(StartMode::Login);
10309 let rendered = systemd_unit(&plan);
10310 let widened = edited(
10311 &rendered,
10312 "CapabilityBoundingSet=\n",
10313 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10314 );
10315 let review = review_least_privilege(
10316 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10317 &plan,
10318 );
10319 assert!(!review.is_least_privilege(), "{review}");
10320 assert!(
10321 review
10322 .excesses()
10323 .iter()
10324 .any(|finding| finding.subject == "CapabilityBoundingSet"),
10325 "{review}"
10326 );
10327 }
10328
10329 #[test]
10330 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10331 let plan = linux_plan(StartMode::Boot);
10332 let rendered = systemd_unit(&plan);
10333 let widened = edited(
10334 &rendered,
10335 "[Install]",
10336 "ListenStream=127.0.0.1:9000\n\n[Install]",
10337 );
10338 let review = review_least_privilege(
10339 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10340 &plan,
10341 );
10342 assert!(
10343 !review.is_least_privilege(),
10344 "07-security.md rule 2 forbids any inbound surface: {review}"
10345 );
10346 }
10347
10348 #[test]
10349 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10350 let plan = linux_plan(StartMode::Boot);
10351 let rendered = systemd_unit(&plan);
10352 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10353 let review = review_least_privilege(
10354 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10355 &plan,
10356 );
10357 assert!(
10358 review.is_least_privilege(),
10359 "too little authority is not an excess: {review}"
10360 );
10361 assert!(
10362 review
10363 .findings()
10364 .iter()
10365 .any(|finding| finding.kind == FindingKind::Shortfall
10366 && finding.detail.contains("runtime")),
10367 "{review}"
10368 );
10369 }
10370
10371 #[test]
10372 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10373 let plan = linux_plan(StartMode::Login);
10374 let rendered = launchd_plist(&plan);
10375 let widened = edited(
10376 &rendered,
10377 "<key>ProcessType</key>",
10378 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
10379 );
10380 let review = review_least_privilege(
10381 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10382 &plan,
10383 );
10384 assert!(!review.is_least_privilege(), "{review}");
10385 assert!(
10386 review
10387 .excesses()
10388 .iter()
10389 .any(|finding| finding.subject == "UserName"),
10390 "{review}"
10391 );
10392 }
10393
10394 #[test]
10395 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10396 let plan = linux_plan(StartMode::Boot);
10397 let rendered = launchd_plist(&plan);
10398 let widened = edited(
10399 &rendered,
10400 "<key>SessionCreate</key>\n <false/>",
10401 "<key>SessionCreate</key>\n <true/>",
10402 );
10403 let review = review_least_privilege(
10404 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10405 &plan,
10406 );
10407 assert!(!review.is_least_privilege(), "{review}");
10408 assert!(
10409 review
10410 .excesses()
10411 .iter()
10412 .any(|finding| finding.subject == "SessionCreate"),
10413 "{review}"
10414 );
10415 }
10416
10417 #[test]
10418 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10419 let plan = linux_plan(StartMode::Boot);
10420 let rendered = launchd_plist(&plan);
10421 let widened = edited(
10422 &rendered,
10423 "<key>ProcessType</key>",
10424 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
10425 );
10426 let review = review_least_privilege(
10427 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10428 &plan,
10429 );
10430 assert!(!review.is_least_privilege(), "{review}");
10431 }
10432
10433 #[test]
10434 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10435 let plan = windows_plan(StartMode::Login);
10436 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10437 let widened = edited(
10438 &rendered,
10439 "<RunLevel>LeastPrivilege</RunLevel>",
10440 "<RunLevel>HighestAvailable</RunLevel>",
10441 );
10442 let review = review_least_privilege(
10443 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10444 &plan,
10445 );
10446 assert!(!review.is_least_privilege(), "{review}");
10447 assert!(
10448 review
10449 .excesses()
10450 .iter()
10451 .any(|finding| finding.subject == "RunLevel"),
10452 "{review}"
10453 );
10454 }
10455
10456 #[test]
10457 fn a_task_that_would_store_a_password_is_not_least_privilege() {
10458 let plan = windows_plan(StartMode::Login);
10459 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10460 let widened = edited(
10461 &rendered,
10462 "<LogonType>InteractiveToken</LogonType>",
10463 "<LogonType>Password</LogonType>",
10464 );
10465 let review = review_least_privilege(
10466 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10467 &plan,
10468 );
10469 assert!(!review.is_least_privilege(), "{review}");
10470 }
10471
10472 #[test]
10473 fn an_interactive_windows_service_is_not_least_privilege() {
10474 let plan = windows_plan(StartMode::Boot);
10475 let rendered = windows_service_descriptor(&plan);
10476 let widened = edited(
10477 &rendered,
10478 "ServiceType=OWN_PROCESS",
10479 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10480 );
10481 let review = review_least_privilege(
10482 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10483 &plan,
10484 );
10485 assert!(!review.is_least_privilege(), "{review}");
10486 assert!(
10487 review
10488 .excesses()
10489 .iter()
10490 .any(|finding| finding.subject == "ServiceType"),
10491 "{review}"
10492 );
10493 }
10494
10495 #[test]
10496 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10497 let plan = windows_plan(StartMode::Boot);
10498 let rendered = windows_service_descriptor(&plan);
10499 let changed = edited(
10500 &rendered,
10501 "Account=NT AUTHORITY\\SYSTEM",
10502 "Account=NT AUTHORITY\\LocalService",
10503 );
10504 let review = review_least_privilege(
10505 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10506 &plan,
10507 );
10508 assert!(review.is_least_privilege(), "{review}");
10514 assert!(
10515 review.findings().iter().any(|finding| {
10516 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10517 }),
10518 "{review}"
10519 );
10520 }
10521
10522 #[test]
10527 fn a_recorded_path_that_is_still_there_is_current() {
10528 let host = Host::new();
10529 let state = inspect_binary(&host.binary, Some(&host.binary));
10530 assert!(!state.is_error(), "{state}");
10531 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10532 }
10533
10534 #[test]
10535 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10536 let host = Host::new();
10537 let recorded = host.binary.clone();
10541 let healthy = inspect_binary(&recorded, Some(&recorded));
10542 assert!(
10543 !healthy.is_error(),
10544 "the discriminator: before the binary moves, this must be healthy"
10545 );
10546
10547 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10548
10549 let state = inspect_binary(&recorded, Some(&recorded));
10550 assert!(state.is_error(), "{state}");
10551 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10552 assert!(
10553 state.to_string().contains("npm"),
10554 "the message must name the cause an operator will not otherwise connect: {state}"
10555 );
10556 }
10557
10558 #[test]
10559 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10560 let root = tempfile::tempdir().expect("a temporary directory");
10561 let state = inspect_binary(root.path(), None);
10562 assert!(state.is_error(), "{state}");
10563 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10564 }
10565
10566 #[test]
10567 fn a_registration_naming_a_different_binary_is_a_divergence() {
10568 let host = Host::new();
10569 let other = host.binary.with_file_name("something-else");
10570 let state = inspect_binary(&host.binary, Some(&other));
10571 assert!(state.is_error(), "{state}");
10572 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10573 }
10574
10575 #[test]
10576 fn absence_is_reported_before_divergence() {
10577 let host = Host::new();
10578 let recorded = host.binary.clone();
10579 std::fs::remove_file(&recorded).expect("removable");
10580 let other = recorded.with_file_name("something-else");
10581 assert!(matches!(
10585 inspect_binary(&recorded, Some(&other)),
10586 BinaryPath::Missing { .. }
10587 ));
10588 }
10589
10590 #[test]
10595 fn the_record_round_trips_through_toml() {
10596 let host = Host::new();
10597 let plan = InstallPlan::resolve(
10598 ServiceIdentity::product(),
10599 &host.request(StartMode::Boot),
10600 ServiceDirectories::of(&host.paths),
10601 )
10602 .expect("a resolvable plan");
10603 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10604 let record = InstallRecord::of(&plan, &definition, Utc::now());
10605 record.write(&host.paths).expect("a writable record");
10606 let read = InstallRecord::read(&host.paths)
10607 .expect("a readable record")
10608 .expect("a record is there");
10609 assert_eq!(read, record);
10610 assert_eq!(read.binary, host.binary);
10611 assert!(read.binary.is_absolute());
10612 }
10613
10614 #[test]
10615 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10616 let host = Host::new();
10617 let identity = ServiceIdentity::product();
10618 let plan = InstallPlan::resolve(
10619 identity.clone(),
10620 &host.request(StartMode::Boot),
10621 ServiceDirectories::of(&host.paths),
10622 )
10623 .expect("a resolvable plan");
10624 let installed = systemd_unit(&plan);
10625 let definition =
10626 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10627 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10628 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10629
10630 assert_eq!(
10631 systemd_definition_drift(&identity, &record, path.clone(), &installed),
10632 None,
10633 "the record must rebuild exactly the unit `service install` wrote"
10634 );
10635
10636 record.installed_by_version = "0.4.7".into();
10637 let frozen = installed.replace(
10638 "[Service]\n",
10639 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10640 );
10641 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10642 .expect("an outdated unit is drift");
10643 assert_eq!(drift.installed_by_version, "0.4.7");
10644 assert_eq!(drift.rendered, installed);
10645
10646 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10647 .with_definition_drift(Some(drift));
10648 assert!(!status.is_healthy());
10649 assert!(
10650 status
10651 .problems()
10652 .iter()
10653 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10654 "{status}"
10655 );
10656 }
10657
10658 #[test]
10659 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10660 let host = Host::new();
10661 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10662 let first = Utc::now() - chrono::Duration::hours(9);
10663 record_github_credential_rejected(&host.paths, first).unwrap();
10664 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10665 let since = github_credential_rejected_since(&host.paths).unwrap();
10666 assert_eq!(since, Some(first));
10667
10668 let status = ServiceStatus::compose(
10669 ServiceIdentity::product(),
10670 None,
10671 None,
10672 None,
10673 None,
10674 &host.paths,
10675 )
10676 .with_credential_rejection(since);
10677 assert!(!status.is_healthy());
10678 assert_eq!(status.credential_rejected_since(), Some(first));
10679 assert!(status.to_string().contains("auth login"), "{status}");
10680
10681 clear_github_credential_rejection(&host.paths).unwrap();
10682 clear_github_credential_rejection(&host.paths).unwrap();
10683 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10684 }
10685
10686 #[test]
10688 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10689 use crate::launch_health::{
10690 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10691 record_launches_blocked,
10692 };
10693
10694 let host = Host::new();
10695 let compose = || {
10696 ServiceStatus::compose(
10697 ServiceIdentity::product(),
10698 None,
10699 None,
10700 None,
10701 None,
10702 &host.paths,
10703 )
10704 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
10705 };
10706 assert!(
10707 !compose()
10708 .problems()
10709 .iter()
10710 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10711 );
10712 let blocked = BlockCause::FenceHeld {
10713 directory: PathBuf::from("launch-fence"),
10714 owner: None,
10715 }
10716 .blocked_since(Utc::now(), Viewpoint::Guest);
10717 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
10718
10719 let status = compose();
10720 assert!(!status.is_healthy());
10721 assert_eq!(status.launches_blocked(), Some(&blocked));
10722 let problem = status
10723 .problems()
10724 .iter()
10725 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
10726 .expect("reported under its own subject");
10727 assert!(problem.detail.contains("systemctl restart"), "{status}");
10728
10729 clear_launches_blocked(&host.paths).unwrap();
10730 assert_eq!(compose().launches_blocked(), None);
10731 }
10732
10733 #[test]
10736 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
10737 let host = Host::new();
10738 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10739 let first = Utc::now() - chrono::Duration::minutes(40);
10740 record_credential_unreadable(&host.paths, first).unwrap();
10741 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
10742 let since = credential_unreadable_since(&host.paths).unwrap();
10743 assert_eq!(since, Some(first), "a restart loop must not move the start");
10744
10745 let status = ServiceStatus::compose(
10746 ServiceIdentity::product(),
10747 None,
10748 None,
10749 None,
10750 None,
10751 &host.paths,
10752 )
10753 .with_credential_unreadable(since);
10754 assert!(!status.is_healthy());
10755 let problem = status
10756 .problems()
10757 .iter()
10758 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
10759 .expect("reported under its own subject");
10760 assert!(
10762 problem
10763 .detail
10764 .contains(&sign_in_instruction(StartMode::default())),
10765 "{status}"
10766 );
10767
10768 clear_credential_unreadable(&host.paths).unwrap();
10769 clear_credential_unreadable(&host.paths).unwrap();
10770 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
10771 let healthy = ServiceStatus::compose(
10772 ServiceIdentity::product(),
10773 None,
10774 None,
10775 None,
10776 None,
10777 &host.paths,
10778 )
10779 .with_credential_unreadable(None);
10780 assert!(
10781 !healthy
10782 .problems()
10783 .iter()
10784 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
10785 "{healthy}"
10786 );
10787 }
10788
10789 #[test]
10790 fn a_sudo_command_names_a_binary_sudo_would_not_find() {
10791 assert_eq!(sudo_program(None), "runner-manager");
10792 assert_eq!(
10793 sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
10794 "runner-manager"
10795 );
10796 assert_eq!(
10797 sudo_program(Some(Path::new(
10798 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10799 runner-manager-linux-x64/bin/runner-manager"
10800 ))),
10801 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
10802 runner-manager-linux-x64/bin/runner-manager"
10803 );
10804 assert_eq!(
10805 sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
10806 "'/home/o'\\''p/my bin/runner-manager'"
10807 );
10808 assert_eq!(
10809 sudo_command("service status"),
10810 "sudo runner-manager service status"
10811 );
10812 }
10813
10814 #[test]
10815 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
10816 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
10817 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
10818 assert_eq!(
10819 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
10820 cfg!(unix),
10821 "a machine-scoped store needs root on Unix"
10822 );
10823 assert_eq!(
10824 sign_in_instruction(StartMode::Login).contains("SSH"),
10825 cfg!(target_os = "macos"),
10826 "only the macOS login keychain is out of an SSH session's reach"
10827 );
10828 }
10829
10830 #[cfg(unix)]
10839 #[test]
10840 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
10841 use std::os::unix::fs::PermissionsExt as _;
10842
10843 let host = Host::new();
10844 let plan = InstallPlan::resolve(
10845 ServiceIdentity::product(),
10846 &host.request(StartMode::Boot),
10847 ServiceDirectories::of(&host.paths),
10848 )
10849 .expect("a resolvable plan");
10850 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10851 InstallRecord::of(&plan, &definition, Utc::now())
10852 .write(&host.paths)
10853 .expect("a writable record");
10854
10855 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
10856 .expect("the record is there")
10857 .permissions()
10858 .mode()
10859 & 0o777;
10860 assert_eq!(
10861 mode, 0o644,
10862 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
10863 service install` wrote, and `service status` fails on their own host. It holds no \
10864 credential and sits in a 0700 directory, so 0644 discloses nothing"
10865 );
10866 }
10867
10868 #[test]
10872 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
10873 let host = Host::new();
10874 let path = InstallRecord::path(&host.paths);
10875 std::fs::write(
10876 &path,
10877 format!(
10878 "schema_version = {RECORD_SCHEMA_VERSION}
10879service_name = \"runner-manager\"
10880 manager = \"systemd\"
10881start_mode = \"boot\"
10882account = \"root\"
10883 binary = \"/x\"
10884arguments = []
10885restart_delay_secs = 15
10886 restart_reset_secs = 600
10887log_file = \"/x\"
10888 installed_at = \"2026-01-01T00:00:00Z\"
10889installed_by_version = \"0.1.0\"
10890 [directories]
10891config = \"/a\"
10892state = \"/b\"
10893runtime = \"/c\"
10894logs = \"/d\"
10895"
10896 ),
10897 )
10898 .expect("a writable record");
10899 let read = InstallRecord::read(&host.paths)
10900 .expect("a record missing an optional field is still readable")
10901 .expect("a record is there");
10902 assert_eq!(
10903 read.source_binary, None,
10904 "the legacy layout has no source, and must not invent one"
10905 );
10906 }
10907
10908 #[test]
10910 fn a_registration_remembers_the_file_it_was_copied_from() {
10911 let host = Host::new();
10912 let source = host.binary.with_file_name("npm-installed-runner-manager");
10913 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
10914 let plan = InstallPlan::resolve(
10915 ServiceIdentity::product(),
10916 &host.request(StartMode::Boot).copied_from(&source),
10917 ServiceDirectories::of(&host.paths),
10918 )
10919 .expect("a resolvable plan");
10920 let definition = ServiceDefinition::from_text(
10921 DefinitionKind::SystemdUnit,
10922 "[Service]
10923",
10924 );
10925 let record = InstallRecord::of(&plan, &definition, Utc::now());
10926 record.write(&host.paths).expect("a writable record");
10927
10928 let read = InstallRecord::read(&host.paths)
10929 .expect("a readable record")
10930 .expect("a record is there");
10931 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
10932 assert_ne!(
10933 read.source_binary.as_deref(),
10934 Some(read.binary.as_path()),
10935 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
10936 );
10937 }
10938
10939 #[test]
10940 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
10941 let host = Host::new();
10942 let path = InstallRecord::path(&host.paths);
10943 std::fs::write(
10944 &path,
10945 format!(
10946 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
10947 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
10948 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
10949 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
10950 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
10951 RECORD_SCHEMA_VERSION + 1
10952 ),
10953 )
10954 .expect("a writable record");
10955 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
10956 assert!(
10957 matches!(error, ServiceError::RecordUnreadable { .. }),
10958 "{error}"
10959 );
10960 assert!(
10961 error.to_string().contains("service uninstall"),
10962 "the message must say how to recover: {error}"
10963 );
10964 }
10965
10966 #[test]
10967 fn no_record_is_not_an_error() {
10968 let host = Host::new();
10969 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
10970 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
10971 }
10972
10973 #[test]
10978 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
10979 let host = Host::new();
10980 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
10981 }
10982
10983 #[test]
10984 fn the_heartbeat_round_trips_to_the_second() {
10985 let host = Host::new();
10986 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
10987 .expect("a valid timestamp")
10988 .with_timezone(&Utc);
10989 record_github_contact(&host.paths, at).expect("a writable heartbeat");
10990 assert_eq!(
10991 last_github_contact(&host.paths).expect("readable"),
10992 Some(at)
10993 );
10994 }
10995
10996 #[test]
10997 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
10998 let host = Host::new();
10999 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
11000 let error = last_github_contact(&host.paths)
11001 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
11002 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11003 }
11004
11005 #[test]
11011 fn a_runner_root_refusal_round_trips_for_service_status() {
11012 let host = Host::new();
11013 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11014
11015 assert!(
11016 runner_root_refusals(&host.paths)
11017 .expect("readable")
11018 .is_empty(),
11019 "no record means every policy is placing runners"
11020 );
11021
11022 record_runner_root_refusal(
11023 &host.paths,
11024 "policy-a",
11025 at,
11026 "denied_by_privacy_policy",
11027 "/Volumes/NVME/runners",
11028 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
11029 )
11030 .expect("a writable record");
11031
11032 let refusals = runner_root_refusals(&host.paths).expect("readable");
11033 assert_eq!(refusals.len(), 1);
11034 assert_eq!(refusals[0].policy, "policy-a");
11035 assert_eq!(refusals[0].at, at);
11036 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
11037 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
11038 assert!(
11039 refusals[0].detail.contains("/Volumes/NVME/runners")
11040 && refusals[0].detail.contains("Full Disk Access"),
11041 "the path and the remediation are the whole point of this file: {refusals:?}"
11042 );
11043 }
11044
11045 #[test]
11053 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
11054 let host = Host::new();
11055 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11056 record_runner_root_refusal(
11057 &host.paths,
11058 "broken",
11059 at,
11060 "denied_by_privacy_policy",
11061 "/Volumes/NVME/runners",
11062 "detail",
11063 )
11064 .expect("a writable record");
11065 record_runner_root_refusal(
11066 &host.paths,
11067 "also-broken",
11068 at,
11069 "not_writable",
11070 "/srv/other",
11071 "detail",
11072 )
11073 .expect("a writable record");
11074
11075 clear_runner_root_refusal(&host.paths, "healthy")
11077 .expect("clearing an absent policy is fine");
11078 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
11079
11080 let refusals = runner_root_refusals(&host.paths).expect("readable");
11081 assert_eq!(
11082 refusals
11083 .iter()
11084 .map(|r| r.policy.as_str())
11085 .collect::<Vec<_>>(),
11086 vec!["broken"],
11087 "the policy that is still refused must keep its record"
11088 );
11089 }
11090
11091 #[test]
11094 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
11095 let host = Host::new();
11096 record_runner_root_refusal(
11097 &host.paths,
11098 "p",
11099 Utc::now(),
11100 "not_writable",
11101 "/srv/x",
11102 "detail",
11103 )
11104 .expect("a writable record");
11105
11106 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
11107 assert!(
11108 !root_refusal_path(&host.paths).exists(),
11109 "a host with nothing refused leaves nothing behind"
11110 );
11111 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
11112 }
11113
11114 #[test]
11117 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
11118 let host = Host::new();
11119 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
11120 let error = runner_root_refusals(&host.paths)
11121 .expect_err("an unparseable record is not the same as no record");
11122 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11123 }
11124
11125 #[test]
11135 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
11136 let host = Host::new();
11137 record_runner_root_refusal(
11138 &host.paths,
11139 "policy-a",
11140 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
11141 "denied_by_privacy_policy",
11142 "/Volumes/NVME/runners",
11143 "Grant Full Disk Access to the program that runs the service",
11144 )
11145 .expect("a writable record");
11146
11147 let status = host.operations().status().expect("a readable status");
11148 let notes = status.notes().join("\n");
11149
11150 assert!(
11151 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
11152 "the directory and the remediation the log had to scrub must appear here: {notes}"
11153 );
11154 assert!(
11155 notes.contains("policy-a"),
11156 "the operator has to know which target placed no runner: {notes}"
11157 );
11158 assert!(
11159 !status
11160 .problems()
11161 .iter()
11162 .any(|problem| problem.subject == "runner root"),
11163 "a record nothing an operator types can clear must not drive the exit code"
11164 );
11165 }
11166
11167 #[test]
11173 fn install_records_the_absolute_binary_path_and_the_four_directories() {
11174 let host = Host::new();
11175 let installed = host
11176 .operations()
11177 .install(&host.request(StartMode::Boot))
11178 .expect("an install against the recording controls");
11179
11180 assert_eq!(installed.record.binary, host.binary);
11181 assert!(installed.record.binary.is_absolute());
11182 assert_eq!(installed.record.start_mode, StartMode::Boot);
11183 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
11184 assert_eq!(
11185 installed.record.directories,
11186 ServiceDirectories::of(&host.paths)
11187 );
11188 assert_eq!(
11189 installed.record.log_file,
11190 host.paths.logs_dir().join(LOG_FILE_STEM)
11191 );
11192 assert_eq!(installed.record.restart_delay_secs, 15);
11193
11194 let registrations = host.controls.registrations();
11195 assert_eq!(registrations.len(), 1);
11196 assert_eq!(registrations[0].0, StartMode::Boot);
11197 assert_eq!(registrations[0].1, "runner-manager");
11198 }
11199
11200 #[test]
11201 fn install_is_refused_while_the_single_instance_lock_is_held() {
11202 let host = Host::new();
11203 {
11205 let operations = host.operations();
11206 operations
11207 .install(&host.request(StartMode::Boot))
11208 .expect("an install with the lock free");
11209 operations.uninstall().expect("a clean slate");
11210 }
11211
11212 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
11213 .expect("this process takes the lock first");
11214
11215 let error = host
11216 .operations()
11217 .install(&host.request(StartMode::Boot))
11218 .expect_err("a second agent must not be registered while one is running");
11219 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
11220 assert!(
11221 error.to_string().contains("already running"),
11222 "the message must be actionable: {error}"
11223 );
11224 assert!(
11225 host.controls.registrations().is_empty(),
11226 "a refused install must register nothing"
11227 );
11228 assert_eq!(
11229 InstallRecord::read(&host.paths).expect("readable"),
11230 None,
11231 "a refused install must write no record"
11232 );
11233 }
11234
11235 #[test]
11243 fn installing_over_the_same_start_mode_replaces_the_registration() {
11244 let host = Host::new();
11245 let operations = host.operations();
11246 operations
11247 .install(&host.request(StartMode::Boot))
11248 .expect("the first install");
11249
11250 let again = operations
11251 .install(&host.request(StartMode::Boot))
11252 .expect("an install over the same mode replaces rather than refusing");
11253
11254 assert!(
11255 again.replaced_existing,
11256 "the operator is told this replaced something rather than made it"
11257 );
11258 assert_eq!(
11259 host.controls.registrations().len(),
11260 1,
11261 "replacing must not leave two registrations behind"
11262 );
11263 assert_eq!(
11264 InstallRecord::read(&host.paths)
11265 .expect("readable")
11266 .expect("a record")
11267 .start_mode,
11268 StartMode::Boot
11269 );
11270 }
11271
11272 #[test]
11276 fn installing_over_the_other_start_mode_is_refused() {
11277 let host = Host::new();
11278 let operations = host.operations();
11279 operations
11280 .install(&host.request(StartMode::Boot))
11281 .expect("the first install");
11282
11283 let error = operations
11284 .install(&host.request(StartMode::Login))
11285 .expect_err("a mode change is not an install");
11286 assert!(
11287 matches!(
11288 error,
11289 ServiceError::AlreadyInstalled {
11290 existing: StartMode::Boot,
11291 requested: StartMode::Login,
11292 ..
11293 }
11294 ),
11295 "{error}"
11296 );
11297 assert!(
11298 !error
11299 .to_string()
11300 .contains("switch the start mode in place,"),
11301 "the old remedy named a capability no command offers; the terminal UI is where \
11302 the start mode moves: {error}"
11303 );
11304 assert_eq!(host.controls.registrations().len(), 1);
11305 }
11306
11307 #[test]
11308 fn install_rolls_back_the_registration_when_record_persistence_fails() {
11309 let host = Host::new();
11310 let record_path = InstallRecord::path(&host.paths);
11311 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11312
11313 let error = host
11314 .operations()
11315 .install(&host.request(StartMode::Boot))
11316 .expect_err("record persistence must fail");
11317
11318 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11319 assert!(
11320 host.controls.registrations().is_empty(),
11321 "a failed install must not leave a live unrecorded registration"
11322 );
11323 assert!(
11324 host.controls
11325 .calls()
11326 .iter()
11327 .any(|call| call == "uninstall runner-manager (boot)"),
11328 "the registration must be explicitly rolled back: {:?}",
11329 host.controls.calls()
11330 );
11331 assert!(
11332 !host.runner_root.as_path().exists(),
11333 "the rollback must take the runner root this install created with it; a directory \
11334 prepared for a registration that does not exist is litter, and on Windows it is \
11335 litter with a security descriptor"
11336 );
11337 }
11338
11339 #[test]
11344 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11345 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11346
11347 assert_eq!(
11351 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11352 ServiceAccount::LocalSystem
11353 );
11354 assert_eq!(
11355 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11356 ServiceAccount::InvokingUser
11357 );
11358
11359 let boot = default_root_sddl(&RootAdmission::LocalSystem);
11360 assert!(!grants_broad_write(&boot), "{boot}");
11361 assert!(
11362 !boot.contains("S-1-5-21"),
11363 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11364 );
11365
11366 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11371 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11372 assert!(!grants_broad_write(&login), "{login}");
11373 }
11374
11375 #[test]
11376 fn an_install_reports_the_runner_root_it_prepared() {
11377 let host = Host::new();
11378 let installed = host
11379 .operations()
11380 .install(&host.request(StartMode::Boot))
11381 .expect("an install");
11382 let rendered = installed.runner_root.to_string();
11383 assert!(
11384 !rendered.contains("S-1-5-21"),
11385 "the report must add no identity to the output: {rendered}"
11386 );
11387 if cfg!(windows) {
11388 assert_eq!(
11389 installed.runner_root.path(),
11390 Some(host.runner_root.as_path())
11391 );
11392 assert!(
11393 host.runner_root.as_path().is_dir(),
11394 "the directory jobs would run in has to exist once the service is registered"
11395 );
11396 } else {
11397 assert_eq!(
11398 installed.runner_root,
11399 crate::runner_root_access::RootAccessSummary::NotApplicable,
11400 "macOS and Linux keep the runtime directory they have always used"
11401 );
11402 }
11403 }
11404
11405 #[test]
11406 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11407 let host = Host::new();
11408 let operations = host.operations();
11409 operations
11410 .install(&host.request(StartMode::Boot))
11411 .expect("an install at boot");
11412
11413 let change = operations
11414 .set_start_mode(StartMode::Login)
11415 .expect("a switch to login");
11416
11417 assert!(change.changed);
11418 if cfg!(windows) {
11419 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11420 assert!(
11421 host.runner_root.as_path().is_dir(),
11422 "the switch must not remove the directory it reconciled"
11423 );
11424 }
11425 assert!(
11428 change.to_string().contains("runner root"),
11429 "{}",
11430 change.to_string()
11431 );
11432 }
11433
11434 #[test]
11435 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11436 let host = Host::new();
11437 let operations = host.operations();
11438 operations
11439 .install(&host.request(StartMode::Boot))
11440 .expect("an install at boot");
11441
11442 let change = operations
11443 .set_start_mode(StartMode::Boot)
11444 .expect("a switch to the mode already in force");
11445
11446 assert!(!change.changed);
11447 assert_eq!(
11448 change.runner_root,
11449 crate::runner_root_access::RootAccessSummary::NotApplicable,
11450 "nothing moves, so nothing about the root's access control has to; reconciling here \
11451 would turn a no-op command into one that can fail on a permission it does not need"
11452 );
11453 }
11454
11455 #[cfg(windows)]
11456 #[test]
11457 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11458 let host = Host::new();
11459 host.controls
11460 .fail_next_install(StartMode::Boot, "injected registration failure");
11461
11462 let error = host
11463 .operations()
11464 .install(&host.request(StartMode::Boot))
11465 .expect_err("the manager refuses the registration");
11466
11467 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11468 assert!(
11469 !host.runner_root.as_path().exists(),
11470 "the directory was created for a registration that does not exist"
11471 );
11472 }
11473
11474 #[cfg(windows)]
11475 #[test]
11476 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11477 let host = Host::new();
11478 crate::runner_root_access::create_with_descriptor_for_tests(
11482 host.runner_root.as_path(),
11483 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11484 )
11485 .expect("a deliberately open runner root");
11486 let before = crate::runner_root_access::report(host.runner_root.as_path());
11487
11488 let error = host
11489 .operations()
11490 .install(&host.request(StartMode::Boot))
11491 .expect_err("an open runner root is refused");
11492
11493 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11494 assert!(
11495 error.to_string().contains("nothing was registered"),
11496 "{error}"
11497 );
11498 assert!(
11499 host.controls.registrations().is_empty(),
11500 "the refusal has to come before the platform is asked to register anything: {:?}",
11501 host.controls.calls()
11502 );
11503 assert_eq!(
11504 crate::runner_root_access::report(host.runner_root.as_path()),
11505 before,
11506 "an open directory is refused rather than tightened: its contents cannot be trusted, \
11507 so adopting it would be worse than declining it"
11508 );
11509 }
11510
11511 #[cfg(windows)]
11512 #[test]
11513 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11514 let host = Host::new();
11515 let operations = host.operations();
11516 operations
11517 .install(&host.request(StartMode::Boot))
11518 .expect("an install");
11519 assert!(host.runner_root.as_path().is_dir());
11520
11521 operations.uninstall().expect("an uninstall");
11522
11523 assert!(
11524 host.runner_root.as_path().is_dir(),
11525 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11526 runner root may hold an operator's retained workspaces."
11527 );
11528 }
11529
11530 #[test]
11531 fn install_reviews_what_it_registered() {
11532 let host = Host::new();
11533 let installed = host
11534 .operations()
11535 .install(&host.request(StartMode::Boot))
11536 .expect("an install");
11537 assert!(
11538 installed.review.is_least_privilege(),
11539 "{}",
11540 installed.review
11541 );
11542 assert!(
11543 !installed.review.controls().is_empty(),
11544 "a review that confirms nothing proves nothing: {}",
11545 installed.review
11546 );
11547 assert_eq!(
11548 installed.review.kind(),
11549 host_definition_kind(StartMode::Boot),
11550 "the review must be of the definition this host's manager was given"
11551 );
11552 assert!(
11553 !installed.review.account().justification().is_empty(),
11554 "a privileged account with no stated reason is an unreviewed one"
11555 );
11556 }
11557
11558 #[test]
11563 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11564 let host = Host::new();
11565 let operations = host.operations();
11566 operations
11567 .install(&host.request(StartMode::Boot))
11568 .expect("an install");
11569
11570 let config = host.paths.config_dir();
11574 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11575 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11576 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11577 std::fs::write(
11578 host.paths
11579 .state_dir()
11580 .join("packages/2.330.0/runner.tar.gz"),
11581 b"cached package",
11582 )
11583 .expect("writable");
11584 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11585 std::fs::write(
11586 host.paths.state_dir().join("secrets/user-access-token"),
11587 b"a stand-in for the stored credential",
11588 )
11589 .expect("writable");
11590 std::fs::write(
11591 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11592 b"diagnostics",
11593 )
11594 .expect("writable");
11595
11596 let roots: Vec<PathBuf> = host
11597 .paths
11598 .all()
11599 .iter()
11600 .map(|(_, path)| (*path).to_path_buf())
11601 .collect();
11602 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11603 let before = snapshot(&roots);
11604
11605 assert!(
11608 before.len() >= 6,
11609 "the fixture must actually contain the files this test is about, got {before:#?}"
11610 );
11611 let record_path = InstallRecord::path(&host.paths);
11612 assert!(
11613 before.contains_key(&record_path),
11614 "the install record must be present before uninstall"
11615 );
11616
11617 let uninstalled = operations.uninstall().expect("an uninstall");
11618 assert!(uninstalled.removed_registration);
11619 assert!(uninstalled.removed_record);
11620
11621 let after = snapshot(&roots);
11622
11623 let mut expected = before.clone();
11625 expected.remove(&record_path);
11626 assert_eq!(
11627 after, expected,
11628 "uninstall must remove its own record and nothing else"
11629 );
11630 assert!(
11631 !record_path.exists(),
11632 "the record itself must go, or `uninstall` did nothing at all"
11633 );
11634 assert!(
11635 uninstalled
11636 .preserved
11637 .iter()
11638 .all(|path| roots.contains(&path.as_path())),
11639 "the preserved list must name the four directories: {uninstalled}"
11640 );
11641 }
11642
11643 #[test]
11644 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11645 let host = Host::new();
11646 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11647 assert!(!uninstalled.removed_registration);
11648 assert!(!uninstalled.removed_record);
11649 }
11650
11651 #[test]
11652 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11653 let host = Host::new();
11654 let operations = host.operations();
11655 operations
11656 .install(&host.request(StartMode::Boot))
11657 .expect("an install");
11658 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11659
11660 let uninstalled = operations.uninstall().expect("an uninstall");
11661 assert!(
11662 uninstalled.removed_registration,
11663 "a lost record must not strand a registration"
11664 );
11665 assert!(host.controls.registrations().is_empty());
11666 }
11667
11668 #[test]
11673 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11674 let host = Host::new();
11675 let operations = host.operations();
11676 operations
11677 .install(&host.request(StartMode::Boot))
11678 .expect("an install at boot");
11679
11680 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11684
11685 let change = operations
11686 .set_start_mode(StartMode::Login)
11687 .expect("a switch that does not reinstall the product");
11688 assert!(change.changed);
11689 assert_eq!(change.from, StartMode::Boot);
11690 assert_eq!(change.to, StartMode::Login);
11691 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11692
11693 let record = InstallRecord::read(&host.paths)
11694 .expect("readable")
11695 .expect("a record");
11696 assert_eq!(record.start_mode, StartMode::Login);
11697 assert_eq!(
11698 record.binary, host.binary,
11699 "the recorded path must survive the switch untouched"
11700 );
11701
11702 let registrations = host.controls.registrations();
11703 assert_eq!(registrations.len(), 1, "{registrations:?}");
11704 assert_eq!(registrations[0].0, StartMode::Login);
11705 assert!(
11706 registrations[0]
11707 .2
11708 .command_line
11709 .contains(&host.binary.to_string_lossy().into_owned()),
11710 "{:?}",
11711 registrations[0].2
11712 );
11713 }
11714
11715 #[test]
11716 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
11717 let host = Host::new();
11718 let operations = host.operations();
11719 operations
11720 .install(&host.request(StartMode::Boot))
11721 .expect("an install at boot");
11722 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11723 host.controls
11724 .fail_next_install(StartMode::Login, "injected target failure");
11725
11726 let error = operations
11727 .set_start_mode(StartMode::Login)
11728 .expect_err("the target manager refuses the install");
11729
11730 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11731 assert_eq!(
11732 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11733 record_before
11734 );
11735 let registrations = host.controls.registrations();
11736 assert_eq!(registrations.len(), 1, "{registrations:?}");
11737 assert_eq!(registrations[0].0, StartMode::Boot);
11738 }
11739
11740 #[test]
11741 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
11742 let host = Host::new();
11743 let operations = host.operations();
11744 operations
11745 .install(&host.request(StartMode::Boot))
11746 .expect("an install at boot");
11747 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
11748 let config = host.paths.config_dir().to_path_buf();
11749 let hidden = config.with_file_name("config-hidden-by-fault");
11750 host.controls.hide_directory_after_install(
11751 StartMode::Login,
11752 config.clone(),
11753 hidden.clone(),
11754 );
11755
11756 let error = operations
11757 .set_start_mode(StartMode::Login)
11758 .expect_err("the injected filesystem fault prevents persistence");
11759
11760 std::fs::remove_file(&config).expect("remove the injected blocker");
11761 std::fs::rename(&hidden, &config).expect("restore the record directory");
11762 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11763 assert_eq!(
11764 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
11765 record_before
11766 );
11767 let registrations = host.controls.registrations();
11768 assert_eq!(registrations.len(), 1, "{registrations:?}");
11769 assert_eq!(registrations[0].0, StartMode::Boot);
11770 assert!(
11771 host.controls
11772 .calls()
11773 .iter()
11774 .any(|call| call == "uninstall runner-manager (login)"),
11775 "the target must be rolled back: {:?}",
11776 host.controls.calls()
11777 );
11778 }
11779
11780 #[test]
11781 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
11782 let host = Host::new();
11783 let operations = host.operations();
11784 operations
11785 .install(&host.request(StartMode::Boot))
11786 .expect("an install");
11787 let before = host.controls.calls().len();
11788
11789 let change = operations
11790 .set_start_mode(StartMode::Boot)
11791 .expect("a no-op switch");
11792 assert!(!change.changed);
11793 assert_eq!(
11794 host.controls.calls().len(),
11795 before,
11796 "a no-op switch must not touch the service manager"
11797 );
11798 }
11799
11800 #[test]
11801 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
11802 let host = Host::new();
11803 let error = host
11804 .operations()
11805 .set_start_mode(StartMode::Login)
11806 .expect_err("there is nothing to switch");
11807 assert!(
11808 matches!(error, ServiceError::NotInstalled { .. }),
11809 "{error}"
11810 );
11811 }
11812
11813 #[test]
11818 fn status_reports_the_four_facts_journey_five_asks_for() {
11819 let host = Host::new();
11820 let operations = host.operations();
11821 operations
11822 .install(&host.request(StartMode::Boot))
11823 .expect("an install");
11824 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
11825 .expect("a valid timestamp")
11826 .with_timezone(&Utc);
11827 record_github_contact(&host.paths, at).expect("a heartbeat");
11828
11829 let status = operations.status().expect("a status");
11830 assert_eq!(status.start_mode(), Some(StartMode::Boot));
11831 assert_eq!(
11832 status.binary().map(BinaryPath::recorded),
11833 Some(host.binary.as_path())
11834 );
11835 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
11836 assert_eq!(status.last_github_contact(), Some(at));
11837 assert!(status.is_installed());
11838 assert!(status.is_healthy(), "{status}");
11839
11840 let printed = status.to_string();
11841 for fragment in [
11842 "start mode",
11843 "diagnostic log",
11844 "last GitHub contact",
11845 "binary",
11846 ] {
11847 assert!(printed.contains(fragment), "{printed}");
11848 }
11849 }
11850
11851 #[cfg(unix)]
11863 #[test]
11864 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
11865 use std::os::unix::fs::PermissionsExt as _;
11866
11867 if unsafe { libc::geteuid() } == 0 {
11871 return;
11872 }
11873
11874 let host = Host::new();
11875 let operations = host.operations();
11876 operations
11877 .install(&host.request(StartMode::Boot))
11878 .expect("an install");
11879 assert!(
11880 operations.status().expect("a status").is_healthy(),
11881 "the discriminator: healthy before the record is made unreadable"
11882 );
11883
11884 let record = InstallRecord::path(&host.paths);
11885 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
11886 .expect("the mode is applied");
11887
11888 let status = operations
11889 .status()
11890 .expect("a record this account may not read is reported, not thrown");
11891 assert!(status.is_installed(), "{status}");
11892 assert!(!status.is_healthy(), "{status}");
11893
11894 let printed = status.to_string();
11895 assert!(
11896 printed.contains("this account may not read it"),
11897 "the operator is told which of the two states this is: {printed}"
11898 );
11899 assert!(
11900 !printed.contains("there is no install record"),
11901 "a record that is there and unreadable is not a record that is missing, and the \
11902 missing one's remedy starts with `service uninstall`: {printed}"
11903 );
11904
11905 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
11908 .expect("the mode is restored");
11909 }
11910
11911 #[test]
11912 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
11913 let host = Host::new();
11914 let operations = host.operations();
11915 operations
11916 .install(&host.request(StartMode::Boot))
11917 .expect("an install");
11918
11919 assert!(
11922 operations.status().expect("a status").is_healthy(),
11923 "the freshly installed host must be healthy"
11924 );
11925
11926 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
11927
11928 let status = operations.status().expect("a status");
11929 assert!(!status.is_healthy(), "{status}");
11930 assert!(
11931 status
11932 .problems()
11933 .iter()
11934 .any(|problem| problem.subject == "binary"),
11935 "{status}"
11936 );
11937 assert!(status.to_string().contains("STALE"), "{status}");
11938 }
11939
11940 #[test]
11941 fn status_reports_a_registration_that_would_not_start_at_boot() {
11942 let host = Host::new();
11943 let operations = host.operations();
11944 operations
11945 .install(&host.request(StartMode::Boot))
11946 .expect("an install");
11947 assert!(operations.status().expect("a status").is_healthy());
11948
11949 host.controls.edit("runner-manager", |registration| {
11950 registration.starts_automatically = false;
11951 });
11952
11953 let status = operations.status().expect("a status");
11954 assert!(!status.is_healthy(), "{status}");
11955 assert!(
11956 status
11957 .problems()
11958 .iter()
11959 .any(|problem| problem.detail.contains("after a reboot")),
11960 "{status}"
11961 );
11962 }
11963
11964 #[test]
11965 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
11966 let host = Host::new();
11967 let operations = host.operations();
11968 operations
11969 .install(&host.request(StartMode::Login))
11970 .expect("install and immediate start");
11971
11972 assert!(
11973 operations.status().expect("running status").is_running(),
11974 "install must not wait for the next login trigger"
11975 );
11976 operations.stop().expect("stop the registration");
11977 let status = operations.status().expect("stopped status");
11978 assert!(!status.is_healthy(), "{status}");
11979 assert!(
11980 status
11981 .problems()
11982 .iter()
11983 .any(|problem| problem.subject == "runtime"),
11984 "{status}"
11985 );
11986 let rendered = status.to_string();
11987 assert!(
11988 rendered.contains("runner-manager service install --start-at login"),
11989 "{rendered}"
11990 );
11991 assert!(
11992 !rendered.contains("runner-manager service start"),
11993 "{rendered}"
11994 );
11995 }
11996
11997 #[test]
11998 fn status_reports_a_restart_policy_something_else_edited() {
11999 let host = Host::new();
12000 let operations = host.operations();
12001 operations
12002 .install(&host.request(StartMode::Boot))
12003 .expect("an install");
12004 assert!(operations.status().expect("a status").is_healthy());
12005
12006 host.controls.edit("runner-manager", |registration| {
12007 registration.restart_delay = Some(Duration::from_secs(1));
12008 });
12009
12010 let status = operations.status().expect("a status");
12011 assert!(!status.is_healthy(), "{status}");
12012 assert!(
12013 status
12014 .problems()
12015 .iter()
12016 .any(|problem| problem.subject == "restart policy"),
12017 "{status}"
12018 );
12019 }
12020
12021 #[test]
12022 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
12023 let host = Host::new();
12024 let operations = host.operations();
12025 operations
12026 .install(&host.request(StartMode::Boot))
12027 .expect("an install");
12028 let other = host.binary.with_file_name("someone-elses.exe");
12029 std::fs::write(&other, b"x").expect("writable");
12030
12031 host.controls.edit("runner-manager", |registration| {
12032 registration.command_line = quote_argument(&other.to_string_lossy());
12033 });
12034
12035 let status = operations.status().expect("a status");
12036 assert!(!status.is_healthy(), "{status}");
12037 assert!(
12038 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12039 "{status}"
12040 );
12041 }
12042
12043 #[test]
12044 fn status_reports_a_record_no_service_manager_knows_about() {
12045 let host = Host::new();
12046 let operations = host.operations();
12047 operations
12048 .install(&host.request(StartMode::Boot))
12049 .expect("an install");
12050 for mode in [StartMode::Boot, StartMode::Login] {
12052 host.controls
12053 .control(mode)
12054 .expect("a control")
12055 .uninstall(&ServiceIdentity::product())
12056 .expect("removed");
12057 }
12058
12059 let status = operations.status().expect("a status");
12060 assert!(!status.is_healthy(), "{status}");
12061 assert!(
12062 status
12063 .problems()
12064 .iter()
12065 .any(|problem| problem.subject == "registration"),
12066 "{status}"
12067 );
12068 }
12069
12070 #[test]
12071 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
12072 let host = Host::new();
12073 let status = host.operations().status().expect("a status");
12074 assert!(!status.is_installed());
12075 assert!(
12076 status.is_healthy(),
12077 "a host that never installed the service has no fault to report: {status}"
12078 );
12079 assert!(status.to_string().contains("installed"), "{status}");
12080 }
12081
12082 #[test]
12083 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
12084 let host = Host::new();
12085 let operations = host.operations();
12086 operations
12087 .install(&host.request(StartMode::Login))
12088 .expect("an install at login");
12089 let status = operations.status().expect("a status");
12090 assert!(
12091 status
12092 .notes()
12093 .iter()
12094 .any(|note| note.contains("does not run until the operator signs in")),
12095 "05-infrastructure.md requires `service status` to say so: {status}"
12096 );
12097 }
12098
12099 #[test]
12102 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
12103 use crate::unattended_login::{AutoLogin, UnattendedLogin};
12104 let automatic = UnattendedLogin {
12105 auto_login: AutoLogin::As("ivan".into()),
12106 filevault_on: Some(false),
12107 };
12108 assert_eq!(
12109 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
12110 None
12111 );
12112
12113 let off = UnattendedLogin {
12114 auto_login: AutoLogin::Off,
12115 filevault_on: Some(false),
12116 };
12117 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
12118 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
12119 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
12120
12121 let locked = UnattendedLogin {
12122 auto_login: AutoLogin::As("ivan".into()),
12123 filevault_on: Some(true),
12124 };
12125 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
12126 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
12127
12128 let unread = UnattendedLogin {
12131 auto_login: AutoLogin::As("ivan".into()),
12132 filevault_on: None,
12133 };
12134 assert_eq!(
12135 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
12136 None
12137 );
12138
12139 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
12141 assert!(
12142 gap.reason
12143 .contains("does not run until the operator signs in")
12144 );
12145 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
12146 }
12147
12148 #[test]
12151 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
12152 let host = Host::new();
12153 let operations = host.operations();
12154 operations
12155 .install(&host.request(StartMode::Login))
12156 .expect("an install at login");
12157 let stalled_problem = |operations: &ServiceOperations| {
12158 operations
12159 .status()
12160 .expect("a status")
12161 .problems()
12162 .iter()
12163 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
12164 .map(|problem| problem.detail.clone())
12165 };
12166 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
12167 assert_eq!(stalled_problem(&operations), None, "beating");
12168
12169 crate::daemon_heartbeat::beat(
12170 &operations.paths,
12171 Utc::now() - chrono::Duration::minutes(20),
12172 )
12173 .unwrap();
12174 let detail = stalled_problem(&operations).expect("a stall is a problem");
12175 assert!(detail.contains("has made no progress since"), "{detail}");
12176 let restart = ServiceIdentity::product().restart_command(StartMode::Login);
12179 assert!(detail.contains(&restart), "{detail}");
12180 assert!(!detail.contains("runner-manager service stop"), "{detail}");
12181 assert!(!detail.contains("runner-manager service start"), "{detail}");
12182 }
12183
12184 #[test]
12188 fn the_restart_command_is_the_service_managers_own() {
12189 let identity = ServiceIdentity::product();
12190 let boot = identity.restart_command(StartMode::Boot);
12191 let login = identity.restart_command(StartMode::Login);
12192 for command in [&boot, &login] {
12193 assert!(!command.contains("runner-manager service"), "{command}");
12194 assert!(command.contains(identity.name()), "{command}");
12195 }
12196 assert_ne!(boot, login);
12197 if cfg!(windows) {
12198 assert!(
12199 boot.starts_with("Restart-Service -Name runner-manager"),
12200 "{boot}"
12201 );
12202 assert!(
12203 login.contains("schtasks /Run /TN runner-manager"),
12204 "{login}"
12205 );
12206 } else if cfg!(target_os = "macos") {
12207 assert!(
12208 boot.starts_with("sudo launchctl kickstart -k system/"),
12209 "{boot}"
12210 );
12211 assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
12212 } else {
12213 assert_eq!(boot, "sudo systemctl restart runner-manager.service");
12214 assert_eq!(login, "systemctl --user restart runner-manager.service");
12215 }
12216 }
12217
12218 #[test]
12220 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
12221 let host = Host::new();
12222 let operations = host.operations();
12223 operations
12224 .install(&host.request(StartMode::Login))
12225 .expect("an install at login");
12226 let paths = operations.paths.clone();
12227 crate::host_fitness::record_host_unfit(
12228 &paths,
12229 &[crate::host_fitness::UnfitFinding {
12230 id: "host.runner_root_responsive".into(),
12231 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
12232 remedy: Some("click Allow".into()),
12233 }],
12234 Utc::now(),
12235 )
12236 .unwrap();
12237 let status = operations.status().expect("a status");
12238 assert!(!status.is_healthy(), "{status}");
12239 let problem = status
12240 .problems()
12241 .iter()
12242 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
12243 .expect("the refusal is a problem");
12244 assert!(
12245 problem.detail.contains("/Volumes/NVME/runners"),
12246 "{problem:?}"
12247 );
12248 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
12249
12250 crate::host_fitness::clear_host_unfit(&paths).unwrap();
12253 crate::host_fitness::record_host_unfit(
12254 &paths,
12255 &[],
12256 Utc::now() - chrono::Duration::hours(2),
12257 )
12258 .unwrap();
12259 let status = operations.status().expect("a status");
12260 assert!(
12261 !status
12262 .problems()
12263 .iter()
12264 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
12265 "{status}"
12266 );
12267 }
12268
12269 #[test]
12274 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
12275 let host = Host::new();
12276 let operations = host.operations();
12277 operations
12278 .install(&host.request(StartMode::Login))
12279 .expect("an install at login");
12280 operations.start().expect("a start");
12281 assert!(operations.status().expect("a status").is_running());
12282 assert!(operations.stop().expect("a stop"));
12283 assert!(!operations.status().expect("a status").is_running());
12284 }
12285
12286 #[test]
12287 fn starting_a_host_with_no_registration_is_refused() {
12288 let host = Host::new();
12289 let error = host.operations().start().expect_err("nothing to start");
12290 assert!(
12291 matches!(error, ServiceError::NotInstalled { .. }),
12292 "{error}"
12293 );
12294 }
12295
12296 #[cfg(windows)]
12312 #[test]
12313 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12314 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12315
12316 let root = tempfile::tempdir().expect("a temporary directory");
12317 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12318 .expect("a rooted machine-scoped store");
12319 store
12320 .store(&secrecy::SecretString::from("a stand-in for the token"))
12321 .expect("the store accepts a value");
12322 let protection = store.protection().expect("the DACL can be read back");
12323
12324 assert!(
12325 protection.description().contains(";;;SY)"),
12326 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12327 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12328 protection.description()
12329 );
12330 assert_eq!(
12331 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12332 ServiceAccount::LocalSystem,
12333 "and that is the account this installer registers, which is why SY is what matters"
12334 );
12335 assert!(
12336 !protection.readable_by_other_local_users(),
12337 "the same DACL must still exclude ordinary local users: {}",
12338 protection.description()
12339 );
12340
12341 for rejected in [";;;LS)", ";;;NS)"] {
12345 assert!(
12346 !protection.description().contains(rejected),
12347 "if the store ever admitted {rejected}, the least-privilege analysis in \
12348 docs/service-account.md would need redoing: {}",
12349 protection.description()
12350 );
12351 }
12352 }
12353
12354 fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12359 let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12360 arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12361 host.request(mode).with_arguments(arguments)
12362 }
12363
12364 fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12367 ServiceOperations::with_controls(
12368 account.paths.clone(),
12369 ServiceIdentity::product(),
12370 std::sync::Arc::new(machine.controls.clone()),
12371 )
12372 .with_runner_root(account.runner_root.clone())
12373 }
12374
12375 fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12379 let owner = Host::new();
12380 owner
12381 .operations()
12382 .install(&request_with_directories(&owner, StartMode::Boot))
12383 .expect("the owner's boot install");
12384 let earlier = Host::new();
12385 earlier
12386 .operations()
12387 .install(&request_with_directories(&earlier, StartMode::Login))
12388 .expect("the second account's earlier login install");
12389 assert!(
12390 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12391 "the earlier install left its record behind"
12392 );
12393 (owner, earlier)
12394 }
12395
12396 const DESTRUCTIVE_ADVICE: [&str; 4] = [
12399 "service uninstall",
12400 "service install`",
12401 "service install --",
12402 "auth login",
12403 ];
12404
12405 #[test]
12406 fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12407 let (owner, earlier) = a_machine_with_a_stale_second_account();
12408
12409 let status = second_account(&earlier, &owner).status().expect("a status");
12410
12411 let foreign = status.foreign().expect("the service is the owner's");
12412 assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12413 assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12414 assert!(foreign.unreadable.is_none(), "{status}");
12415 assert!(
12416 status.is_healthy(),
12417 "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12418 {status}"
12419 );
12420 for subject in [
12421 "binary",
12422 "start mode",
12423 "secret store",
12424 "record",
12425 "registration",
12426 ] {
12427 assert!(
12428 !status
12429 .problems()
12430 .iter()
12431 .any(|problem| problem.subject == subject),
12432 "{subject} compared the second account's record against the owner's \
12433 registration: {status}"
12434 );
12435 }
12436 let rendered = status.to_string();
12437 for advice in DESTRUCTIVE_ADVICE {
12438 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12439 }
12440 assert!(rendered.contains("another installation's"), "{rendered}");
12441 assert!(
12442 status
12443 .notes()
12444 .iter()
12445 .any(|note| note.contains("unused local configuration")
12446 && note.contains("host forget-local")),
12447 "{status}"
12448 );
12449 assert_eq!(status.start_mode(), Some(StartMode::Boot));
12451 }
12452
12453 #[test]
12454 fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12455 let (owner, earlier) = a_machine_with_a_stale_second_account();
12456 InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12459
12460 let operations = second_account(&earlier, &owner);
12461 let status = operations.status().expect("a status");
12462 let foreign = status.foreign().expect("the service is the owner's");
12463 assert!(foreign.unreadable.is_some(), "{status}");
12464 assert!(
12465 status.is_healthy(),
12466 "running, and nothing it could judge: {status}"
12467 );
12468 let rendered = status.to_string();
12469 for advice in DESTRUCTIVE_ADVICE {
12470 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12471 }
12472 assert!(rendered.contains("running; installed from"), "{rendered}");
12473 assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12474
12475 owner.operations().stop().expect("stopped");
12477 let stopped = operations.status().expect("a status");
12478 assert!(!stopped.is_healthy(), "{stopped}");
12479 let rendered = stopped.to_string();
12480 for advice in DESTRUCTIVE_ADVICE {
12481 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12482 }
12483 }
12484
12485 #[test]
12486 fn this_accounts_own_registration_still_reports_every_mismatch() {
12487 let host = Host::new();
12488 let operations = host.operations();
12489 operations
12490 .install(&request_with_directories(&host, StartMode::Boot))
12491 .expect("an install");
12492 let other = host.binary.with_file_name("someone-elses");
12493 std::fs::write(&other, b"x").expect("writable");
12494 host.controls.edit("runner-manager", |registration| {
12496 let mut arguments = split_command_line(®istration.command_line);
12497 arguments[0] = other.to_string_lossy().into_owned();
12498 registration.command_line = arguments
12499 .iter()
12500 .map(|argument| quote_argument(argument))
12501 .collect::<Vec<_>>()
12502 .join(" ");
12503 registration.start_mode = StartMode::Login;
12504 });
12505
12506 let status = operations.status().expect("a status");
12507 assert!(status.foreign().is_none(), "{status}");
12508 assert!(
12509 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12510 "{status}"
12511 );
12512 assert!(
12513 status
12514 .problems()
12515 .iter()
12516 .any(|problem| problem.subject == "start mode"),
12517 "{status}"
12518 );
12519 assert!(status.to_string().contains("service uninstall"), "{status}");
12520 }
12521
12522 #[test]
12523 fn directories_read_back_from_a_command_line_with_spaces() {
12524 let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12525 let directories = ServiceDirectories {
12526 config: root.join("config"),
12527 state: root.join("data").join("state"),
12528 runtime: root.join("data").join("runtime"),
12529 logs: root.join("data").join("logs"),
12530 };
12531 let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12532 for argument in ["daemon", "run"]
12533 .map(OsString::from)
12534 .into_iter()
12535 .chain(directories.arguments())
12536 {
12537 command_line.push(' ');
12538 command_line.push_str("e_argument(&argument.to_string_lossy()));
12539 }
12540 assert_eq!(
12541 ServiceDirectories::from_command_line(&command_line),
12542 Some(directories.clone())
12543 );
12544 assert_eq!(directories.root(), root);
12545 assert_eq!(
12546 ServiceDirectories::from_command_line("runner-manager daemon run"),
12547 None,
12548 "a registration that names no directories cannot be judged foreign"
12549 );
12550 }
12551
12552 #[test]
12553 fn the_owner_of_a_data_root_is_read_from_its_path() {
12554 let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12555 let home = users.join("ivand");
12556 assert_eq!(
12557 data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12558 Some("yuriv")
12559 );
12560 assert_eq!(
12561 data_root_owner(
12562 Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12563 Some(&home)
12564 ),
12565 None
12566 );
12567 if cfg!(windows) {
12568 assert_eq!(
12569 data_root_owner(
12570 Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12571 Some(&home)
12572 )
12573 .as_deref(),
12574 Some("LocalSystem")
12575 );
12576 } else {
12577 assert_eq!(
12578 data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12579 Some("root")
12580 );
12581 }
12582 }
12583
12584 #[test]
12585 fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12586 let (owner, earlier) = a_machine_with_a_stale_second_account();
12587 let owner_before = snapshot(&[owner._root.path()]);
12588 let config = earlier.paths.config_dir().to_path_buf();
12589 let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12590
12591 let forgotten = second_account(&earlier, &owner)
12592 .forget_local(Utc::now())
12593 .expect("archived");
12594
12595 assert_eq!(
12596 snapshot(&[owner._root.path()]),
12597 owner_before,
12598 "the owner's directories must not change by one byte"
12599 );
12600 let archive = forgotten.archive.expect("something was archived");
12601 assert!(archive.starts_with(&config), "{}", archive.display());
12602 assert_eq!(
12603 std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12604 record,
12605 "moved, not rewritten or deleted"
12606 );
12607 assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12608 assert_eq!(
12609 forgotten.service_root,
12610 Some(ServiceDirectories::of(&owner.paths).root())
12611 );
12612 let status = second_account(&earlier, &owner).status().expect("status");
12614 assert!(
12615 !status
12616 .notes()
12617 .iter()
12618 .any(|note| note.contains("unused local configuration")),
12619 "{status}"
12620 );
12621 }
12622
12623 #[test]
12624 fn forget_local_refuses_a_configuration_a_service_runs_against() {
12625 let (owner, earlier) = a_machine_with_a_stale_second_account();
12626 let before = snapshot(&[owner._root.path()]);
12627 let refused = owner.operations().forget_local(Utc::now());
12628 assert!(
12629 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12630 "{refused:?}"
12631 );
12632 assert_eq!(snapshot(&[owner._root.path()]), before);
12633
12634 let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12636 .expect("the agent's lock");
12637 let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12638 assert!(
12639 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12640 "{refused:?}"
12641 );
12642 assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12643 }
12644
12645 #[test]
12646 fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12647 let (owner, earlier) = a_machine_with_a_stale_second_account();
12648 owner.operations().stop().expect("stopped");
12651 record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12652 record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12653
12654 let status = second_account(&earlier, &owner).status().expect("a status");
12655 assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12656 let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12657 for subject in [
12658 "runtime",
12659 "github credential",
12660 CREDENTIAL_UNREADABLE_SUBJECT,
12661 ] {
12662 assert!(subjects.contains(&subject), "{subject}: {status}");
12663 }
12664 let rendered = status.to_string();
12665 for advice in DESTRUCTIVE_ADVICE {
12666 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12667 }
12668 for problem in status.problems() {
12669 assert!(problem.detail.contains("Act on it"), "{problem}");
12670 }
12671 }
12672
12673 #[test]
12674 fn no_command_of_this_account_changes_another_installations_service() {
12675 let (owner, earlier) = a_machine_with_a_stale_second_account();
12676 let operations = second_account(&earlier, &owner);
12677 let foreign = |result: Result<(), ServiceError>| {
12678 assert!(
12679 matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12680 "{result:?}"
12681 );
12682 };
12683 foreign(
12684 operations
12685 .install(&request_with_directories(&earlier, StartMode::Boot))
12686 .map(drop),
12687 );
12688 foreign(operations.uninstall().map(drop));
12689 foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12690 foreign(operations.start());
12691 foreign(operations.stop().map(drop));
12692
12693 let registration = owner
12694 .controls
12695 .control(StartMode::Boot)
12696 .unwrap()
12697 .query(&ServiceIdentity::product())
12698 .unwrap()
12699 .expect("the owner's registration is still there");
12700 assert_eq!(
12701 ServiceDirectories::from_command_line(®istration.command_line),
12702 Some(ServiceDirectories::of(&owner.paths)),
12703 "and still runs against the owner's directories"
12704 );
12705 assert!(registration.running, "and was never stopped");
12706 assert!(
12707 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12708 "the refused uninstall kept this account's record"
12709 );
12710 }
12711
12712 #[test]
12713 fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
12714 let root = tempfile::tempdir().unwrap();
12717 let config = root.path().join("Application Support");
12718 let paths = AppPaths::from_directories(
12719 &config,
12720 config.join("state"),
12721 config.join("runtime"),
12722 config.join("logs"),
12723 );
12724 paths.create_all().unwrap();
12725 std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
12726 std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
12727 let operations = ServiceOperations::with_controls(
12728 paths.clone(),
12729 ServiceIdentity::product(),
12730 std::sync::Arc::new(RecordingControls::new()),
12731 );
12732
12733 let forgotten = operations.forget_local(Utc::now()).expect("archived");
12734
12735 assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
12736 for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
12737 assert!(kept.is_dir(), "{} was moved", kept.display());
12738 }
12739 assert!(paths.logs_dir().join("runner-manager.log").is_file());
12740 }
12741
12742 #[test]
12743 fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
12744 for (detail, diagnosis) in [
12745 (
12746 "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
12747 service install --start-at boot`; if the service manager denies replacement",
12748 "x holds an automatic registration, but the daemon is stopped",
12749 ),
12750 (
12751 "store -- MISMATCH. Run `auth login` again so the token is stored",
12752 "store -- MISMATCH",
12753 ),
12754 (
12755 "unit differs, so the daemon runs under outdated settings. Re-register it with \
12756 `sudo runner-manager service install --start-at boot`",
12757 "unit differs, so the daemon runs under outdated settings",
12758 ),
12759 (
12760 "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
12761 "it starts no runner; the log has it",
12762 ),
12763 (
12764 "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
12765 install`",
12766 "C:\\a\\runner-manager.exe -- STALE: moved",
12767 ),
12768 (
12769 "a new builder: run `runner-manager service install` here, or not",
12770 "the installation's own records report this",
12771 ),
12772 ] {
12773 assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
12774 }
12775 }
12776}