1use std::collections::BTreeMap;
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]
923 pub fn root(&self) -> PathBuf {
924 let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
925 for other in [&self.state, &self.runtime, &self.logs] {
926 let common = shared
927 .iter()
928 .zip(other.components())
929 .take_while(|(left, right)| **left == *right)
930 .count();
931 shared.truncate(common);
932 }
933 if shared.is_empty() {
934 self.config.clone()
935 } else {
936 shared.iter().collect()
937 }
938 }
939
940 #[must_use]
943 pub fn paths(&self) -> AppPaths {
944 AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
945 }
946}
947
948pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
954 "--service-config-dir",
955 "--service-state-dir",
956 "--service-runtime-dir",
957 "--service-logs-dir",
958];
959
960#[derive(Debug, Clone, PartialEq, Eq)]
972pub struct InstallRequest {
973 start_mode: StartMode,
974 binary: Option<PathBuf>,
975 source_binary: Option<PathBuf>,
976 arguments: Vec<OsString>,
977 restart: RestartPolicy,
978 on_demand: bool,
979}
980
981impl InstallRequest {
982 #[must_use]
984 pub fn new(start_mode: StartMode) -> Self {
985 Self {
986 start_mode,
987 binary: None,
988 source_binary: None,
989 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
990 restart: RestartPolicy::default(),
991 on_demand: false,
992 }
993 }
994
995 #[must_use]
1005 pub const fn started_on_demand(mut self) -> Self {
1006 self.on_demand = true;
1007 self
1008 }
1009
1010 #[must_use]
1017 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1018 self.binary = Some(binary.into());
1019 self
1020 }
1021
1022 #[must_use]
1039 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1040 self.source_binary = Some(source.into());
1041 self
1042 }
1043
1044 #[must_use]
1046 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1047 where
1048 I: IntoIterator<Item = S>,
1049 S: Into<OsString>,
1050 {
1051 self.arguments = arguments.into_iter().map(Into::into).collect();
1052 self
1053 }
1054
1055 #[must_use]
1058 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1059 self.restart = restart;
1060 self
1061 }
1062
1063 #[must_use]
1065 pub const fn start_mode(&self) -> StartMode {
1066 self.start_mode
1067 }
1068}
1069
1070#[derive(Debug, Clone, PartialEq, Eq)]
1077pub struct InstallPlan {
1078 identity: ServiceIdentity,
1079 start_mode: StartMode,
1080 binary: PathBuf,
1081 source_binary: Option<PathBuf>,
1082 arguments: Vec<OsString>,
1083 account: ServiceAccount,
1084 restart: RestartPolicy,
1085 directories: ServiceDirectories,
1086 secret_guard: Option<PathBuf>,
1087 on_demand: bool,
1088}
1089
1090impl InstallPlan {
1091 pub fn resolve(
1099 identity: ServiceIdentity,
1100 request: &InstallRequest,
1101 directories: ServiceDirectories,
1102 ) -> Result<Self, ServiceError> {
1103 let binary = match &request.binary {
1104 Some(named) => absolute(named)?,
1105 None => running_executable()?,
1106 };
1107 if !binary.is_file() {
1108 return Err(ServiceError::BinaryMissing { path: binary });
1109 }
1110 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1115 .ok()
1116 .map(|store| store.guard());
1117 Ok(Self {
1118 identity,
1119 start_mode: request.start_mode,
1120 binary,
1121 source_binary: request.source_binary.clone(),
1122 arguments: request.arguments.clone(),
1123 account: ServiceAccount::for_start_mode(request.start_mode),
1124 restart: request.restart,
1125 directories,
1126 secret_guard,
1127 on_demand: request.on_demand,
1128 })
1129 }
1130
1131 #[must_use]
1138 pub fn unchecked(
1139 identity: ServiceIdentity,
1140 start_mode: StartMode,
1141 binary: impl Into<PathBuf>,
1142 directories: ServiceDirectories,
1143 ) -> Self {
1144 Self {
1145 identity,
1146 start_mode,
1147 binary: binary.into(),
1148 source_binary: None,
1149 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1150 account: ServiceAccount::for_start_mode(start_mode),
1151 restart: RestartPolicy::default(),
1152 directories,
1153 secret_guard: None,
1154 on_demand: false,
1155 }
1156 }
1157
1158 #[must_use]
1161 pub const fn started_on_demand(mut self) -> Self {
1162 self.on_demand = true;
1163 self
1164 }
1165
1166 #[must_use]
1168 pub const fn is_on_demand(&self) -> bool {
1169 self.on_demand
1170 }
1171
1172 #[must_use]
1179 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1180 self.secret_guard = Some(guard.into());
1181 self
1182 }
1183
1184 #[must_use]
1186 pub fn secret_guard(&self) -> Option<&Path> {
1187 self.secret_guard.as_deref()
1188 }
1189
1190 #[must_use]
1192 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1193 self.restart = restart;
1194 self
1195 }
1196
1197 #[must_use]
1199 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1200 where
1201 I: IntoIterator<Item = S>,
1202 S: Into<OsString>,
1203 {
1204 self.arguments = arguments.into_iter().map(Into::into).collect();
1205 self
1206 }
1207
1208 #[must_use]
1210 pub const fn identity(&self) -> &ServiceIdentity {
1211 &self.identity
1212 }
1213
1214 #[must_use]
1216 pub const fn start_mode(&self) -> StartMode {
1217 self.start_mode
1218 }
1219
1220 #[must_use]
1222 pub fn binary(&self) -> &Path {
1223 &self.binary
1224 }
1225
1226 #[must_use]
1232 pub fn source_binary(&self) -> Option<&Path> {
1233 self.source_binary.as_deref()
1234 }
1235
1236 #[must_use]
1238 pub fn arguments(&self) -> &[OsString] {
1239 &self.arguments
1240 }
1241
1242 #[must_use]
1244 pub const fn account(&self) -> &ServiceAccount {
1245 &self.account
1246 }
1247
1248 #[must_use]
1250 pub const fn restart(&self) -> RestartPolicy {
1251 self.restart
1252 }
1253
1254 #[must_use]
1256 pub const fn directories(&self) -> &ServiceDirectories {
1257 &self.directories
1258 }
1259
1260 #[must_use]
1262 pub fn command_line(&self) -> String {
1263 let mut out = quote_argument(&self.binary.to_string_lossy());
1264 for argument in &self.arguments {
1265 out.push(' ');
1266 out.push_str("e_argument(&argument.to_string_lossy()));
1267 }
1268 out
1269 }
1270}
1271
1272fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1280 let reversal = change.revert();
1281 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1282}
1283
1284fn rolled_back<T>(
1297 retained: Option<String>,
1298 rollback: Result<T, ServiceError>,
1299 operation: &'static str,
1300 identity: &ServiceIdentity,
1301 cause: ServiceError,
1302) -> ServiceError {
1303 let left_behind = match (rollback.err(), retained) {
1304 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1305 (Some(rollback), None) => Some(rollback.to_string()),
1306 (None, retained) => retained,
1307 };
1308 match left_behind {
1309 Some(rollback) => ServiceError::Rollback {
1310 operation,
1311 name: identity.name().to_string(),
1312 cause: cause.to_string(),
1313 rollback,
1314 },
1315 None => cause,
1316 }
1317}
1318
1319fn undo_runner_root(
1322 change: &RootAccessChange,
1323 operation: &'static str,
1324 identity: &ServiceIdentity,
1325 cause: ServiceError,
1326) -> ServiceError {
1327 rolled_back(
1328 retained_runner_root(change),
1329 Ok(()),
1330 operation,
1331 identity,
1332 cause,
1333 )
1334}
1335
1336fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1350 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1351 detail: format!("{} could not be made absolute: {error}", path.display()),
1352 })
1353}
1354
1355fn running_executable() -> Result<PathBuf, ServiceError> {
1357 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1358 detail: error.to_string(),
1359 })?;
1360 absolute(&raw)
1361}
1362
1363pub(crate) fn quote_argument(argument: &str) -> String {
1372 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1373 return argument.to_string();
1374 }
1375 let mut out = String::with_capacity(argument.len() + 2);
1376 out.push('"');
1377 let mut backslashes = 0usize;
1378 for c in argument.chars() {
1379 match c {
1380 '\\' => {
1381 backslashes += 1;
1382 out.push('\\');
1383 }
1384 '"' => {
1385 for _ in 0..=backslashes {
1388 out.push('\\');
1389 }
1390 out.push('"');
1391 backslashes = 0;
1392 }
1393 other => {
1394 backslashes = 0;
1395 out.push(other);
1396 }
1397 }
1398 }
1399 for _ in 0..backslashes {
1401 out.push('\\');
1402 }
1403 out.push('"');
1404 out
1405}
1406
1407#[must_use]
1418pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1419 split_command_line(command_line)
1420 .into_iter()
1421 .next()
1422 .filter(|executable| !executable.is_empty())
1423 .map(PathBuf::from)
1424}
1425
1426#[must_use]
1432pub fn split_command_line(command_line: &str) -> Vec<String> {
1433 let mut arguments = Vec::new();
1434 let mut current = String::new();
1435 let mut started = false;
1436 let mut quoted = false;
1437 let mut backslashes = 0usize;
1438 for c in command_line.chars() {
1439 match c {
1440 '\\' => {
1441 backslashes += 1;
1442 started = true;
1443 }
1444 '"' => {
1445 current.extend(std::iter::repeat_n('\\', backslashes / 2));
1446 if backslashes.is_multiple_of(2) {
1447 quoted = !quoted;
1448 } else {
1449 current.push('"');
1450 }
1451 backslashes = 0;
1452 started = true;
1453 }
1454 ' ' | '\t' if !quoted => {
1455 current.extend(std::iter::repeat_n('\\', backslashes));
1456 backslashes = 0;
1457 if started {
1458 arguments.push(std::mem::take(&mut current));
1459 started = false;
1460 }
1461 }
1462 other => {
1463 current.extend(std::iter::repeat_n('\\', backslashes));
1464 backslashes = 0;
1465 current.push(other);
1466 started = true;
1467 }
1468 }
1469 }
1470 current.extend(std::iter::repeat_n('\\', backslashes));
1471 if started {
1472 arguments.push(current);
1473 }
1474 arguments
1475}
1476
1477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1483pub enum DefinitionKind {
1484 WindowsService,
1488 WindowsScheduledTask,
1490 LaunchdPlist,
1492 SystemdUnit,
1494}
1495
1496impl DefinitionKind {
1497 #[must_use]
1499 pub const fn manager(self) -> &'static str {
1500 match self {
1501 Self::WindowsService => "the Windows Service Control Manager",
1502 Self::WindowsScheduledTask => "Windows Task Scheduler",
1503 Self::LaunchdPlist => "launchd",
1504 Self::SystemdUnit => "systemd",
1505 }
1506 }
1507}
1508
1509impl fmt::Display for DefinitionKind {
1510 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1511 f.write_str(self.manager())
1512 }
1513}
1514
1515#[derive(Debug, Clone, PartialEq, Eq)]
1522pub struct ServiceDefinition {
1523 kind: DefinitionKind,
1524 text: String,
1525 install_path: Option<PathBuf>,
1526}
1527
1528impl ServiceDefinition {
1529 #[must_use]
1531 pub const fn kind(&self) -> DefinitionKind {
1532 self.kind
1533 }
1534
1535 #[must_use]
1537 pub fn text(&self) -> &str {
1538 &self.text
1539 }
1540
1541 #[must_use]
1548 pub fn install_path(&self) -> Option<&Path> {
1549 self.install_path.as_deref()
1550 }
1551
1552 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1565 Ok(match host_definition_kind(plan.start_mode()) {
1566 DefinitionKind::WindowsService => Self::windows_service(plan),
1567 DefinitionKind::WindowsScheduledTask => {
1568 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1569 }
1570 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1571 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1572 })
1573 }
1574
1575 #[must_use]
1577 pub fn windows_service(plan: &InstallPlan) -> Self {
1578 Self {
1579 kind: DefinitionKind::WindowsService,
1580 text: windows_service_descriptor(plan),
1581 install_path: None,
1582 }
1583 }
1584
1585 #[must_use]
1587 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1588 Self {
1589 kind: DefinitionKind::WindowsScheduledTask,
1590 text: windows_scheduled_task_xml(plan, principal),
1591 install_path: None,
1592 }
1593 }
1594
1595 #[must_use]
1600 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1601 let file = format!("{}.plist", plan.identity().launchd_label());
1602 let install_path = match plan.start_mode() {
1603 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1604 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1605 };
1606 Self {
1607 kind: DefinitionKind::LaunchdPlist,
1608 text: launchd_plist(plan),
1609 install_path,
1610 }
1611 }
1612
1613 #[must_use]
1621 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1622 Self {
1623 kind,
1624 text: text.into(),
1625 install_path: None,
1626 }
1627 }
1628
1629 #[must_use]
1634 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1635 let file = plan.identity().systemd_unit();
1636 let install_path = match plan.start_mode() {
1637 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1638 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1639 };
1640 Self {
1641 kind: DefinitionKind::SystemdUnit,
1642 text: systemd_unit(plan),
1643 install_path,
1644 }
1645 }
1646}
1647
1648pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1650pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1652pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1654pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1656
1657const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1660
1661pub const START_LIMIT_BURST: u32 = 5;
1669
1670#[must_use]
1691pub fn systemd_unit(plan: &InstallPlan) -> String {
1692 let identity = plan.identity();
1693 let restart = plan.restart();
1694 let directories = plan.directories();
1695
1696 let mut out = String::new();
1697 out.push_str("[Unit]\n");
1698 out.push_str(&format!("Description={}\n", identity.display_name()));
1699 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1700 out.push_str("After=network-online.target\n");
1701 out.push_str("Wants=network-online.target\n");
1702 out.push_str(&format!(
1705 "StartLimitIntervalSec={}\n",
1706 restart.reset_after().as_secs()
1707 ));
1708 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1709
1710 out.push_str("\n[Service]\n");
1711 out.push_str("Type=simple\n");
1712 out.push_str("KillMode=process\n");
1716 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1717 out.push_str(&format!(
1718 "WorkingDirectory={}\n",
1719 directories.state.display()
1720 ));
1721 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1722 out.push_str("Restart=on-failure\n");
1723 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1724
1725 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1726 for directive in SYSTEMD_HARDENING {
1727 out.push_str(directive);
1728 out.push('\n');
1729 }
1730 let mut writable = directories.all().to_vec();
1731 if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1732 writable.push(secret_directory);
1733 }
1734 out.push_str(&format!(
1735 "ReadWritePaths={}\n",
1736 writable
1737 .iter()
1738 .map(|path| quote_argument(&path.to_string_lossy()))
1739 .collect::<Vec<_>>()
1740 .join(" ")
1741 ));
1742
1743 out.push_str("\n[Install]\n");
1744 out.push_str(match plan.start_mode() {
1745 StartMode::Boot => "WantedBy=multi-user.target\n",
1746 StartMode::Login => "WantedBy=default.target\n",
1747 });
1748 out
1749}
1750
1751pub const SYSTEMD_HARDENING: [&str; 13] = [
1758 "NoNewPrivileges=yes",
1759 "CapabilityBoundingSet=",
1760 "AmbientCapabilities=",
1761 "PrivateTmp=yes",
1762 "PrivateDevices=yes",
1763 "ProtectSystem=strict",
1764 "ProtectKernelTunables=yes",
1765 "ProtectKernelModules=yes",
1766 "ProtectControlGroups=yes",
1767 "RestrictNamespaces=yes",
1768 "RestrictRealtime=yes",
1769 "RestrictSUIDSGID=yes",
1770 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1771];
1772
1773pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1797
1798#[must_use]
1805pub fn launchd_plist(plan: &InstallPlan) -> String {
1806 let identity = plan.identity();
1807 let directories = plan.directories();
1808 let mut out = String::new();
1809 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1810 out.push_str(
1811 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1812 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1813 );
1814 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1815 out.push_str(&plist_string("Label", &identity.launchd_label()));
1816
1817 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1818 out.push_str(&format!(
1819 " <string>{}</string>\n",
1820 xml_escape(&plan.binary().to_string_lossy())
1821 ));
1822 for argument in plan.arguments() {
1823 out.push_str(&format!(
1824 " <string>{}</string>\n",
1825 xml_escape(&argument.to_string_lossy())
1826 ));
1827 }
1828 out.push_str(" </array>\n");
1829
1830 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1831 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1832 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1833 out.push_str(" </dict>\n");
1834 out.push_str(&format!(
1835 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1836 plan.restart().delay().as_secs()
1837 ));
1838 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1839 out.push_str(&plist_string(
1840 "WorkingDirectory",
1841 &directories.state.to_string_lossy(),
1842 ));
1843 out.push_str(&plist_string(
1844 "StandardOutPath",
1845 &directories
1846 .logs
1847 .join("runner-manager.launchd.out.log")
1848 .to_string_lossy(),
1849 ));
1850 out.push_str(&plist_string(
1851 "StandardErrorPath",
1852 &directories
1853 .logs
1854 .join("runner-manager.launchd.err.log")
1855 .to_string_lossy(),
1856 ));
1857
1858 match plan.start_mode() {
1859 StartMode::Boot => {
1860 out.push_str(&plist_string(
1863 "UserName",
1864 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1865 .as_str(),
1866 ));
1867 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1869 }
1870 StartMode::Login => {
1871 }
1875 }
1876
1877 out.push_str("</dict>\n</plist>\n");
1878 out
1879}
1880
1881fn plist_string(key: &str, value: &str) -> String {
1883 format!(
1884 " <key>{}</key>\n <string>{}</string>\n",
1885 xml_escape(key),
1886 xml_escape(value)
1887 )
1888}
1889
1890#[derive(Debug, Clone, PartialEq, Eq)]
1898pub struct TaskPrincipal {
1899 user_id: String,
1900}
1901
1902impl TaskPrincipal {
1903 pub fn current() -> Result<Self, ServiceError> {
1911 let user = std::env::var("USERNAME")
1912 .ok()
1913 .filter(|value| !value.trim().is_empty());
1914 let Some(user) = user else {
1915 return Err(ServiceError::Control {
1916 operation: "identify the account for",
1917 name: SERVICE_NAME.to_string(),
1918 manager: "Windows Task Scheduler",
1919 detail: "this session reports no %USERNAME%, so there is no principal to \
1920 register a logon-triggered task for"
1921 .to_string(),
1922 });
1923 };
1924 let domain = std::env::var("USERDOMAIN")
1925 .ok()
1926 .filter(|value| !value.trim().is_empty());
1927 Ok(Self {
1928 user_id: match domain {
1929 Some(domain) => format!("{domain}\\{user}"),
1930 None => user,
1931 },
1932 })
1933 }
1934
1935 #[must_use]
1937 pub fn named(user_id: impl Into<String>) -> Self {
1938 Self {
1939 user_id: user_id.into(),
1940 }
1941 }
1942
1943 #[must_use]
1945 pub fn user_id(&self) -> &str {
1946 &self.user_id
1947 }
1948}
1949
1950#[must_use]
1957pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1958 let identity = plan.identity();
1959 let user = xml_escape(principal.user_id());
1960 let arguments = plan
1961 .arguments()
1962 .iter()
1963 .map(|argument| quote_argument(&argument.to_string_lossy()))
1964 .collect::<Vec<_>>()
1965 .join(" ");
1966 let mut out = String::new();
1967 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1968 out.push_str(
1969 "<Task version=\"1.4\" \
1970 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1971 );
1972 out.push_str(" <RegistrationInfo>\n");
1973 out.push_str(&format!(
1974 " <Description>{}</Description>\n",
1975 xml_escape(identity.description())
1976 ));
1977 out.push_str(&format!(
1978 " <URI>\\{}</URI>\n",
1979 xml_escape(identity.name())
1980 ));
1981 out.push_str(" </RegistrationInfo>\n");
1982
1983 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1984 out.push_str(" <Enabled>true</Enabled>\n");
1985 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1986 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1987
1988 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1989 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1990 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1991 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1992 out.push_str(" </Principal>\n </Principals>\n");
1993
1994 out.push_str(" <Settings>\n");
1995 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1999 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2000 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2001 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
2002 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
2003 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2004 out.push_str(" <IdleSettings>\n");
2005 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2006 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
2007 out.push_str(" </IdleSettings>\n");
2008 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2009 out.push_str(" <Enabled>true</Enabled>\n");
2010 out.push_str(" <Hidden>false</Hidden>\n");
2011 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2012 out.push_str(" <WakeToRun>false</WakeToRun>\n");
2013 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2015 out.push_str(" <Priority>7</Priority>\n");
2016 out.push_str(" <RestartOnFailure>\n");
2017 out.push_str(&format!(
2018 " <Interval>{}</Interval>\n",
2019 iso8601_minutes(
2020 plan.restart()
2021 .effective_delay(DefinitionKind::WindowsScheduledTask)
2022 )
2023 ));
2024 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
2025 out.push_str(" </RestartOnFailure>\n");
2026 out.push_str(" </Settings>\n");
2027
2028 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
2029 out.push_str(&format!(
2030 " <Command>{}</Command>\n",
2031 xml_escape(&plan.binary().to_string_lossy())
2032 ));
2033 if !arguments.is_empty() {
2034 out.push_str(&format!(
2035 " <Arguments>{}</Arguments>\n",
2036 xml_escape(&arguments)
2037 ));
2038 }
2039 out.push_str(&format!(
2040 " <WorkingDirectory>{}</WorkingDirectory>\n",
2041 xml_escape(&plan.directories().state.to_string_lossy())
2042 ));
2043 out.push_str(" </Exec>\n </Actions>\n");
2044 out.push_str("</Task>\n");
2045 out
2046}
2047
2048fn iso8601_minutes(duration: Duration) -> String {
2051 format!("PT{}M", duration.as_secs() / 60)
2052}
2053
2054pub(crate) fn xml_escape(value: &str) -> String {
2062 let mut out = String::with_capacity(value.len());
2063 for c in value.chars() {
2064 match c {
2065 '&' => out.push_str("&"),
2066 '<' => out.push_str("<"),
2067 '>' => out.push_str(">"),
2068 '"' => out.push_str("""),
2069 '\'' => out.push_str("'"),
2070 other => out.push(other),
2071 }
2072 }
2073 out
2074}
2075
2076fn xml_unescape(value: &str) -> String {
2082 value
2083 .replace("<", "<")
2084 .replace(">", ">")
2085 .replace(""", "\"")
2086 .replace("'", "'")
2087 .replace("&", "&")
2088}
2089
2090#[derive(Debug, Clone, PartialEq, Eq)]
2099pub struct WindowsServiceSpec {
2100 pub name: String,
2102 pub display_name: String,
2104 pub description: String,
2106 pub automatic_start: bool,
2108 pub account: Option<String>,
2111 pub command_line: String,
2113 pub restart: RestartPolicy,
2115}
2116
2117#[must_use]
2119pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2120 WindowsServiceSpec {
2121 name: plan.identity().name().to_string(),
2122 display_name: plan.identity().display_name().to_string(),
2123 description: plan.identity().description().to_string(),
2124 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2129 account: match ServiceAccount::for_definition(
2130 DefinitionKind::WindowsService,
2131 plan.start_mode(),
2132 ) {
2133 ServiceAccount::LocalSystem => None,
2136 other => Some(other.as_str().to_string()),
2137 },
2138 command_line: plan.command_line(),
2139 restart: plan.restart(),
2140 }
2141}
2142
2143#[must_use]
2146fn windows_service_descriptor(plan: &InstallPlan) -> String {
2147 let spec = windows_service_spec(plan);
2148 let mut out = String::new();
2149 out.push_str("[windows-service]\n");
2150 out.push_str(&format!("Name={}\n", spec.name));
2151 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2152 out.push_str(&format!("Description={}\n", spec.description));
2153 out.push_str("ServiceType=OWN_PROCESS\n");
2157 out.push_str(&format!(
2158 "StartType={}\n",
2159 if spec.automatic_start {
2160 "AutoStart"
2161 } else {
2162 "OnDemand"
2163 }
2164 ));
2165 out.push_str("ErrorControl=Normal\n");
2166 out.push_str(&format!(
2167 "Account={}\n",
2168 spec.account
2169 .as_deref()
2170 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2171 ));
2172 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2173 out.push_str(&format!(
2174 "FailureActionRestartDelaySecs={}\n",
2175 spec.restart.delay().as_secs()
2176 ));
2177 out.push_str(&format!(
2178 "FailureActionsResetPeriodSecs={}\n",
2179 spec.restart.reset_after().as_secs()
2180 ));
2181 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2185 out.push_str(&format!(
2186 "ReadWritePaths={}\n",
2187 plan.directories()
2188 .all()
2189 .iter()
2190 .map(|path| quote_argument(&path.to_string_lossy()))
2191 .collect::<Vec<_>>()
2192 .join(" ")
2193 ));
2194 out
2195}
2196
2197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2203pub enum FindingKind {
2204 Excess,
2207 Shortfall,
2210}
2211
2212impl fmt::Display for FindingKind {
2213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2214 f.write_str(match self {
2215 Self::Excess => "excess",
2216 Self::Shortfall => "shortfall",
2217 })
2218 }
2219}
2220
2221#[derive(Debug, Clone, PartialEq, Eq)]
2223pub struct PrivilegeFinding {
2224 pub kind: FindingKind,
2226 pub subject: String,
2228 pub detail: String,
2230}
2231
2232impl fmt::Display for PrivilegeFinding {
2233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2234 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2235 }
2236}
2237
2238#[derive(Debug, Clone, PartialEq, Eq)]
2252pub struct PrivilegeReview {
2253 kind: DefinitionKind,
2254 account: ServiceAccount,
2255 controls: Vec<String>,
2256 findings: Vec<PrivilegeFinding>,
2257}
2258
2259impl PrivilegeReview {
2260 #[must_use]
2262 pub fn is_least_privilege(&self) -> bool {
2263 !self
2264 .findings
2265 .iter()
2266 .any(|finding| finding.kind == FindingKind::Excess)
2267 }
2268
2269 #[must_use]
2271 pub fn findings(&self) -> &[PrivilegeFinding] {
2272 &self.findings
2273 }
2274
2275 #[must_use]
2278 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2279 self.findings
2280 .iter()
2281 .filter(|finding| finding.kind == FindingKind::Excess)
2282 .collect()
2283 }
2284
2285 #[must_use]
2291 pub fn controls(&self) -> &[String] {
2292 &self.controls
2293 }
2294
2295 #[must_use]
2297 pub const fn account(&self) -> &ServiceAccount {
2298 &self.account
2299 }
2300
2301 #[must_use]
2303 pub const fn kind(&self) -> DefinitionKind {
2304 self.kind
2305 }
2306}
2307
2308impl fmt::Display for PrivilegeReview {
2309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2310 writeln!(
2311 f,
2312 "{} runs as {} ({})",
2313 self.kind,
2314 self.account,
2315 self.account.justification()
2316 )?;
2317 for control in &self.controls {
2318 writeln!(f, " confirmed {control}")?;
2319 }
2320 for finding in &self.findings {
2321 writeln!(f, " {finding}")?;
2322 }
2323 if self.is_least_privilege() {
2324 write!(f, " verdict least privilege")
2325 } else {
2326 write!(
2327 f,
2328 " verdict NOT least privilege: {} excess(es)",
2329 self.excesses().len()
2330 )
2331 }
2332 }
2333}
2334
2335#[must_use]
2341pub fn review_least_privilege(
2342 definition: &ServiceDefinition,
2343 plan: &InstallPlan,
2344) -> PrivilegeReview {
2345 let mut controls = Vec::new();
2346 let mut findings = Vec::new();
2347 match definition.kind() {
2348 DefinitionKind::SystemdUnit => {
2349 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2350 }
2351 DefinitionKind::LaunchdPlist => {
2352 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2353 }
2354 DefinitionKind::WindowsScheduledTask => {
2355 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2356 }
2357 DefinitionKind::WindowsService => {
2358 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2359 }
2360 }
2361 PrivilegeReview {
2362 kind: definition.kind(),
2363 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2368 controls,
2369 findings,
2370 }
2371}
2372
2373fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2375 let mut permitted = plan
2376 .directories()
2377 .all()
2378 .iter()
2379 .map(|path| path.to_string_lossy().into_owned())
2380 .collect::<Vec<_>>();
2381 if kind == DefinitionKind::SystemdUnit
2382 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2383 {
2384 permitted.push(secret_directory.to_string_lossy().into_owned());
2385 }
2386 permitted
2387}
2388
2389fn same_path_text(left: &str, right: &str) -> bool {
2396 if cfg!(windows) {
2397 left.eq_ignore_ascii_case(right)
2398 } else {
2399 left == right
2400 }
2401}
2402
2403fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2406 match kind {
2407 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2408 left.eq_ignore_ascii_case(right)
2409 }
2410 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2411 }
2412}
2413
2414fn review_writable_paths(
2416 kind: DefinitionKind,
2417 subject: &str,
2418 listed: &[String],
2419 plan: &InstallPlan,
2420 controls: &mut Vec<String>,
2421 findings: &mut Vec<PrivilegeFinding>,
2422) {
2423 let permitted = permitted_paths(kind, plan);
2424 for entry in listed {
2425 if !permitted
2426 .iter()
2427 .any(|allowed| same_path_for(kind, allowed, entry))
2428 {
2429 findings.push(PrivilegeFinding {
2430 kind: FindingKind::Excess,
2431 subject: subject.to_string(),
2432 detail: format!(
2433 "{entry} is writable but is not one of this registration's required paths"
2434 ),
2435 });
2436 }
2437 }
2438 for allowed in &permitted {
2439 if !listed
2440 .iter()
2441 .any(|entry| same_path_for(kind, allowed, entry))
2442 {
2443 findings.push(PrivilegeFinding {
2444 kind: FindingKind::Shortfall,
2445 subject: subject.to_string(),
2446 detail: format!(
2447 "{allowed} is one of this registration's directories but is not writable, \
2448 so the daemon cannot use it"
2449 ),
2450 });
2451 }
2452 }
2453 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2454 controls.push(format!(
2455 "{subject} names exactly the required application-data and credential paths"
2456 ));
2457 }
2458}
2459
2460fn review_inbound_surface(
2468 text: &str,
2469 markers: &[(&str, &str)],
2470 controls: &mut Vec<String>,
2471 findings: &mut Vec<PrivilegeFinding>,
2472) {
2473 let mut clean = true;
2474 for (marker, detail) in markers {
2475 if text.contains(marker) {
2476 clean = false;
2477 findings.push(PrivilegeFinding {
2478 kind: FindingKind::Excess,
2479 subject: (*marker).to_string(),
2480 detail: (*detail).to_string(),
2481 });
2482 }
2483 }
2484 if clean {
2485 controls.push(
2486 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2487 );
2488 }
2489}
2490
2491fn review_systemd(
2492 text: &str,
2493 plan: &InstallPlan,
2494 controls: &mut Vec<String>,
2495 findings: &mut Vec<PrivilegeFinding>,
2496) {
2497 let directives = ini_directives(text, "Service");
2498 for expected in SYSTEMD_HARDENING {
2499 let (key, value) = expected
2500 .split_once('=')
2501 .expect("every hardening directive is written as key=value");
2502 match directives.get(key) {
2503 Some(actual) if actual == value => controls.push((*expected).to_string()),
2504 Some(actual) => findings.push(PrivilegeFinding {
2505 kind: FindingKind::Excess,
2506 subject: key.to_string(),
2507 detail: format!(
2508 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2509 requirement does not ask for"
2510 ),
2511 }),
2512 None => findings.push(PrivilegeFinding {
2513 kind: FindingKind::Excess,
2514 subject: key.to_string(),
2515 detail: format!(
2516 "is absent, so the unit inherits systemd's default rather than `{value}`"
2517 ),
2518 }),
2519 }
2520 }
2521
2522 match directives.get("ReadWritePaths") {
2523 Some(value) => {
2524 let listed = split_quoted(value);
2525 review_writable_paths(
2526 DefinitionKind::SystemdUnit,
2527 "ReadWritePaths",
2528 &listed,
2529 plan,
2530 controls,
2531 findings,
2532 );
2533 }
2534 None => findings.push(PrivilegeFinding {
2535 kind: FindingKind::Shortfall,
2536 subject: "ReadWritePaths".to_string(),
2537 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2538 .to_string(),
2539 }),
2540 }
2541
2542 if directives.contains_key("PrivateUsers")
2545 && directives.get("PrivateUsers") == Some(&"no".to_string())
2546 {
2547 findings.push(PrivilegeFinding {
2548 kind: FindingKind::Excess,
2549 subject: "PrivateUsers".to_string(),
2550 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2551 .to_string(),
2552 });
2553 }
2554
2555 review_inbound_surface(
2556 text,
2557 &[
2558 (
2559 "ListenStream=",
2560 "asks systemd to open a listening socket for this service, which \
2561 07-security.md rule 2 forbids the product to have",
2562 ),
2563 (
2564 "ListenDatagram=",
2565 "asks systemd to open a listening socket for this service, which \
2566 07-security.md rule 2 forbids the product to have",
2567 ),
2568 ],
2569 controls,
2570 findings,
2571 );
2572}
2573
2574fn review_launchd(
2575 text: &str,
2576 plan: &InstallPlan,
2577 controls: &mut Vec<String>,
2578 findings: &mut Vec<PrivilegeFinding>,
2579) {
2580 match plist_string_value(text, "ProcessType") {
2584 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2585 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2586 }
2587 found => findings.push(PrivilegeFinding {
2588 kind: FindingKind::Shortfall,
2589 subject: "ProcessType".to_string(),
2590 detail: format!(
2591 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2592 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2593 when it next starts with no runner; `service status` says how to restart it",
2594 found.map_or_else(
2595 || "absent (launchd's `Standard`)".to_string(),
2596 |value| format!("`{value}`")
2597 )
2598 ),
2599 }),
2600 }
2601
2602 match plan.start_mode() {
2603 StartMode::Boot => {
2604 if plist_bool_value(text, "SessionCreate") == Some(true) {
2605 findings.push(PrivilegeFinding {
2606 kind: FindingKind::Excess,
2607 subject: "SessionCreate".to_string(),
2608 detail: "asks launchd to create a security session for a job that runs \
2609 outside every login session and has no use for one"
2610 .to_string(),
2611 });
2612 } else {
2613 controls.push("SessionCreate is not requested".to_string());
2614 }
2615 match plist_string_value(text, "UserName").as_deref() {
2616 Some("root") => {
2617 controls.push("UserName=root, stated rather than inherited".to_string())
2618 }
2619 Some(other) => findings.push(PrivilegeFinding {
2620 kind: FindingKind::Shortfall,
2621 subject: "UserName".to_string(),
2622 detail: format!(
2623 "is `{other}`, which cannot unlock the System Keychain: \
2624 /var/db/SystemKey is root-only, so the daemon would start and then \
2625 find no credential"
2626 ),
2627 }),
2628 None => findings.push(PrivilegeFinding {
2629 kind: FindingKind::Shortfall,
2630 subject: "UserName".to_string(),
2631 detail: "is absent, so the account is launchd's implicit default and this \
2632 review cannot confirm it"
2633 .to_string(),
2634 }),
2635 }
2636 }
2637 StartMode::Login => {
2638 if let Some(named) = plist_string_value(text, "UserName") {
2639 findings.push(PrivilegeFinding {
2640 kind: FindingKind::Excess,
2641 subject: "UserName".to_string(),
2642 detail: format!(
2643 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2644 naming an account here asks launchd for a switch a login-mode \
2645 registration has no reason to want"
2646 ),
2647 });
2648 } else {
2649 controls
2650 .push("no UserName: the agent runs as the operator and no other".to_string());
2651 }
2652 }
2653 }
2654
2655 review_inbound_surface(
2656 text,
2657 &[
2658 (
2659 "<key>Sockets</key>",
2660 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2661 forbids the product to have",
2662 ),
2663 (
2664 "<key>MachServices</key>",
2665 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2666 forbids the product to have",
2667 ),
2668 ],
2669 controls,
2670 findings,
2671 );
2672}
2673
2674fn review_scheduled_task(
2675 text: &str,
2676 controls: &mut Vec<String>,
2677 findings: &mut Vec<PrivilegeFinding>,
2678) {
2679 match xml_value(text, "RunLevel").as_deref() {
2680 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2681 Some(other) => findings.push(PrivilegeFinding {
2682 kind: FindingKind::Excess,
2683 subject: "RunLevel".to_string(),
2684 detail: format!(
2685 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2686 an administrator"
2687 ),
2688 }),
2689 None => findings.push(PrivilegeFinding {
2690 kind: FindingKind::Excess,
2691 subject: "RunLevel".to_string(),
2692 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2693 .to_string(),
2694 }),
2695 }
2696
2697 match xml_value(text, "LogonType").as_deref() {
2698 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2699 Some(other) => findings.push(PrivilegeFinding {
2700 kind: FindingKind::Excess,
2701 subject: "LogonType".to_string(),
2702 detail: format!(
2703 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2704 task; an interactive token needs neither"
2705 ),
2706 }),
2707 None => findings.push(PrivilegeFinding {
2708 kind: FindingKind::Shortfall,
2709 subject: "LogonType".to_string(),
2710 detail: "is absent, so this review cannot confirm that no credential is stored"
2711 .to_string(),
2712 }),
2713 }
2714}
2715
2716fn review_windows_service(
2717 text: &str,
2718 plan: &InstallPlan,
2719 controls: &mut Vec<String>,
2720 findings: &mut Vec<PrivilegeFinding>,
2721) {
2722 let directives = ini_directives(text, "windows-service");
2723
2724 match directives.get("ServiceType").map(String::as_str) {
2725 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2726 Some(other) => findings.push(PrivilegeFinding {
2727 kind: FindingKind::Excess,
2728 subject: "ServiceType".to_string(),
2729 detail: format!(
2730 "is `{other}`; an interactive or shared-process service reaches further than a \
2731 daemon that only talks to GitHub over HTTPS"
2732 ),
2733 }),
2734 None => findings.push(PrivilegeFinding {
2735 kind: FindingKind::Shortfall,
2736 subject: "ServiceType".to_string(),
2737 detail: "is absent, so this review cannot confirm the service is not interactive"
2738 .to_string(),
2739 }),
2740 }
2741
2742 match directives.get("Account").map(String::as_str) {
2746 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2747 controls.push(format!(
2748 "Account={account}: the only stock account the machine-scoped store's DACL \
2749 (SY, BA, OW) admits"
2750 ));
2751 }
2752 Some(other) => findings.push(PrivilegeFinding {
2753 kind: FindingKind::Shortfall,
2754 subject: "Account".to_string(),
2755 detail: format!(
2756 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2757 daemon would start and then find no credential. Widening that DACL is not this \
2758 registration's to do: an ACE reaching `{other}` would also reach every other \
2759 service running under it"
2760 ),
2761 }),
2762 None => findings.push(PrivilegeFinding {
2763 kind: FindingKind::Shortfall,
2764 subject: "Account".to_string(),
2765 detail: "is absent, so this review cannot confirm which account was registered"
2766 .to_string(),
2767 }),
2768 }
2769
2770 match directives.get("ReadWritePaths") {
2771 Some(value) => {
2772 let listed = split_quoted(value);
2773 review_writable_paths(
2774 DefinitionKind::WindowsService,
2775 "ReadWritePaths",
2776 &listed,
2777 plan,
2778 controls,
2779 findings,
2780 );
2781 }
2782 None => findings.push(PrivilegeFinding {
2783 kind: FindingKind::Shortfall,
2784 subject: "ReadWritePaths".to_string(),
2785 detail: "is absent, so the directories the service was installed against are not \
2786 recorded"
2787 .to_string(),
2788 }),
2789 }
2790}
2791
2792fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2800 let mut out = BTreeMap::new();
2801 let mut inside = false;
2802 for line in text.lines() {
2803 let line = line.trim();
2804 if line.starts_with('[') && line.ends_with(']') {
2805 inside = &line[1..line.len() - 1] == section;
2806 continue;
2807 }
2808 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2809 continue;
2810 }
2811 if let Some((key, value)) = line.split_once('=') {
2812 out.insert(key.trim().to_string(), value.trim().to_string());
2813 }
2814 }
2815 out
2816}
2817
2818fn split_quoted(value: &str) -> Vec<String> {
2821 let mut out = Vec::new();
2822 let mut rest = value.trim();
2823 while !rest.is_empty() {
2824 if rest.starts_with('"') {
2825 if let Some(parsed) = executable_from_command_line(rest) {
2828 out.push(parsed.to_string_lossy().into_owned());
2829 }
2830 let mut depth = 0usize;
2832 let mut end = rest.len();
2833 for (index, c) in rest.char_indices() {
2834 match c {
2835 '\\' => depth += 1,
2836 '"' => {
2837 if depth.is_multiple_of(2) && index > 0 {
2838 end = index + 1;
2839 break;
2840 }
2841 depth = 0;
2842 }
2843 _ => depth = 0,
2844 }
2845 }
2846 rest = rest[end..].trim_start();
2847 } else {
2848 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2849 out.push(rest[..end].to_string());
2850 rest = rest[end..].trim_start();
2851 }
2852 }
2853 out
2854}
2855
2856pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2868 let open = format!("<{tag}>");
2869 let close = format!("</{tag}>");
2870 let start = text.find(&open)? + open.len();
2871 let end = text[start..].find(&close)? + start;
2872 Some(xml_unescape(text[start..end].trim()))
2873}
2874
2875#[cfg(any(windows, test))]
2881fn windows_login_task_starts_automatically(document: &str) -> bool {
2882 document.contains("<LogonTrigger>")
2883 && xml_value(document, "Enabled").as_deref() != Some("false")
2884}
2885
2886fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2888 let marker = format!("<key>{key}</key>");
2889 let start = text.find(&marker)? + marker.len();
2890 Some(text[start..].trim_start())
2891}
2892
2893pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
2894 let rest = plist_value_after_key(text, key)?;
2895 if !rest.starts_with("<string>") {
2896 return None;
2897 }
2898 xml_value(rest, "string")
2899}
2900
2901fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2902 let rest = plist_value_after_key(text, key)?;
2903 if rest.starts_with("<true/>") {
2904 Some(true)
2905 } else if rest.starts_with("<false/>") {
2906 Some(false)
2907 } else {
2908 None
2909 }
2910}
2911
2912pub const RECORD_SCHEMA_VERSION: u32 = 1;
2923
2924#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2936pub struct InstallRecord {
2937 pub schema_version: u32,
2939 pub service_name: String,
2941 pub manager: String,
2943 pub start_mode: StartMode,
2945 pub account: ServiceAccount,
2947 pub binary: PathBuf,
2949 #[serde(default)]
2957 pub source_binary: Option<PathBuf>,
2958 pub arguments: Vec<String>,
2960 pub restart_delay_secs: u64,
2962 pub restart_reset_secs: u64,
2964 pub log_file: PathBuf,
2966 #[serde(default)]
2974 pub starts_on_demand: bool,
2975 pub definition_path: Option<PathBuf>,
2977 pub installed_at: DateTime<Utc>,
2979 pub installed_by_version: String,
2981 pub directories: ServiceDirectories,
2986}
2987
2988impl InstallRecord {
2989 #[must_use]
2991 pub fn path(paths: &AppPaths) -> PathBuf {
2992 paths.config_dir().join(RECORD_FILE)
2993 }
2994
2995 #[must_use]
2997 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2998 Self {
2999 schema_version: RECORD_SCHEMA_VERSION,
3000 service_name: plan.identity().name().to_string(),
3001 manager: definition.kind().manager().to_string(),
3002 start_mode: plan.start_mode(),
3003 account: plan.account().clone(),
3004 binary: plan.binary().to_path_buf(),
3005 arguments: plan
3006 .arguments()
3007 .iter()
3008 .map(|argument| argument.to_string_lossy().into_owned())
3009 .collect(),
3010 restart_delay_secs: plan.restart().delay().as_secs(),
3011 restart_reset_secs: plan.restart().reset_after().as_secs(),
3012 starts_on_demand: plan.is_on_demand(),
3013 source_binary: plan.source_binary().map(Path::to_path_buf),
3014 log_file: plan.directories().log_file(),
3015 definition_path: definition.install_path().map(Path::to_path_buf),
3016 installed_at: at,
3017 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3018 directories: plan.directories().clone(),
3019 }
3020 }
3021
3022 #[must_use]
3030 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3031 let plan = InstallPlan::unchecked(
3032 identity,
3033 self.start_mode,
3034 self.binary.clone(),
3035 self.directories.clone(),
3036 )
3037 .with_arguments(self.arguments.clone())
3038 .with_restart(self.restart());
3039 let plan = if self.starts_on_demand {
3040 plan.started_on_demand()
3041 } else {
3042 plan
3043 };
3044 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3045 Ok(store) => plan.with_secret_guard(store.guard()),
3046 Err(_) => plan,
3047 }
3048 }
3049
3050 #[must_use]
3054 pub fn restart(&self) -> RestartPolicy {
3055 RestartPolicy::new(
3056 Duration::from_secs(self.restart_delay_secs),
3057 Duration::from_secs(self.restart_reset_secs),
3058 )
3059 .unwrap_or_default()
3060 }
3061
3062 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3073 let path = Self::path(paths);
3074 let text = match std::fs::read_to_string(&path) {
3075 Ok(text) => text,
3076 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3077 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3080 return Err(ServiceError::RecordNotPermitted {
3081 path,
3082 detail: error.to_string(),
3083 });
3084 }
3085 Err(error) => {
3086 return Err(ServiceError::Record {
3087 operation: "read",
3088 path,
3089 detail: error.to_string(),
3090 });
3091 }
3092 };
3093 let record: Self =
3094 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3095 path: path.clone(),
3096 detail: error.to_string(),
3097 })?;
3098 if record.schema_version != RECORD_SCHEMA_VERSION {
3099 return Err(ServiceError::RecordUnreadable {
3100 path,
3101 detail: format!(
3102 "it declares schema version {} and this build reads version {}",
3103 record.schema_version, RECORD_SCHEMA_VERSION
3104 ),
3105 });
3106 }
3107 Ok(Some(record))
3108 }
3109
3110 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3116 use std::io::Write as _;
3117
3118 let path = Self::path(paths);
3119 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3120 operation: "encode",
3121 path: path.clone(),
3122 detail: error.to_string(),
3123 })?;
3124 if let Some(parent) = path.parent() {
3125 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3126 operation: "write",
3127 path: path.clone(),
3128 detail: error.to_string(),
3129 })?;
3130 }
3131 let parent = path.parent().ok_or_else(|| ServiceError::Record {
3132 operation: "write",
3133 path: path.clone(),
3134 detail: "the record path has no parent directory".to_string(),
3135 })?;
3136 let mut temporary =
3137 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3138 operation: "write",
3139 path: path.clone(),
3140 detail: error.to_string(),
3141 })?;
3142 temporary
3143 .write_all(text.as_bytes())
3144 .and_then(|()| temporary.as_file().sync_all())
3145 .map_err(|error| ServiceError::Record {
3146 operation: "write",
3147 path: path.clone(),
3148 detail: error.to_string(),
3149 })?;
3150 #[cfg(unix)]
3166 {
3167 use std::os::unix::fs::PermissionsExt as _;
3168
3169 temporary
3170 .as_file()
3171 .set_permissions(std::fs::Permissions::from_mode(0o644))
3172 .map_err(|error| ServiceError::Record {
3173 operation: "write",
3174 path: path.clone(),
3175 detail: error.to_string(),
3176 })?;
3177 }
3178 temporary
3179 .persist(&path)
3180 .map(|_| ())
3181 .map_err(|error| ServiceError::Record {
3182 operation: "write",
3183 path,
3184 detail: error.error.to_string(),
3185 })
3186 }
3187
3188 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3194 let path = Self::path(paths);
3195 match std::fs::remove_file(&path) {
3196 Ok(()) => Ok(true),
3197 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3198 Err(error) => Err(ServiceError::Record {
3199 operation: "remove",
3200 path,
3201 detail: error.to_string(),
3202 }),
3203 }
3204 }
3205}
3206
3207const CONTACT_SCHEMA_VERSION: u32 = 1;
3213
3214#[derive(Debug, Clone, Serialize, Deserialize)]
3215struct ContactRecord {
3216 schema_version: u32,
3217 last_success: DateTime<Utc>,
3218}
3219
3220pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3241 write_state_record(
3242 &contact_path(paths),
3243 &ContactRecord {
3244 schema_version: CONTACT_SCHEMA_VERSION,
3245 last_success: at,
3246 },
3247 )
3248}
3249
3250pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3262 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3263}
3264
3265#[must_use]
3267pub fn contact_path(paths: &AppPaths) -> PathBuf {
3268 paths.state_dir().join(CONTACT_FILE)
3269}
3270
3271pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3274 let failed = |detail: String| ServiceError::Record {
3275 operation: "write",
3276 path: path.to_path_buf(),
3277 detail,
3278 };
3279 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3280 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3281 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3282 let temporary = path.with_extension("toml.new");
3283 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3284 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3285}
3286
3287pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3290 path: &Path,
3291) -> Result<Option<T>, ServiceError> {
3292 let failed = |detail: String| ServiceError::Record {
3293 operation: "read",
3294 path: path.to_path_buf(),
3295 detail,
3296 };
3297 let text = match std::fs::read_to_string(path) {
3298 Ok(text) => text,
3299 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3300 Err(error) => return Err(failed(error.to_string())),
3301 };
3302 toml::from_str(&text)
3303 .map(Some)
3304 .map_err(|error| failed(error.to_string()))
3305}
3306
3307pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3309 match std::fs::remove_file(path) {
3310 Ok(()) => Ok(()),
3311 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3312 Err(error) => Err(ServiceError::Record {
3313 operation: "remove",
3314 path: path.to_path_buf(),
3315 detail: error.to_string(),
3316 }),
3317 }
3318}
3319
3320#[derive(Debug, Serialize, Deserialize)]
3325#[serde(deny_unknown_fields)]
3326struct CredentialRejectionRecord {
3327 schema_version: u32,
3328 rejected_since: DateTime<Utc>,
3329}
3330
3331pub fn record_github_credential_rejected(
3347 paths: &AppPaths,
3348 at: DateTime<Utc>,
3349) -> Result<(), ServiceError> {
3350 if github_credential_rejected_since(paths)
3351 .ok()
3352 .flatten()
3353 .is_some()
3354 {
3355 return Ok(());
3356 }
3357 write_state_record(
3358 &credential_rejection_path(paths),
3359 &CredentialRejectionRecord {
3360 schema_version: CONTACT_SCHEMA_VERSION,
3361 rejected_since: at,
3362 },
3363 )
3364}
3365
3366pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3372 remove_state_record(&credential_rejection_path(paths))
3373}
3374
3375pub fn github_credential_rejected_since(
3381 paths: &AppPaths,
3382) -> Result<Option<DateTime<Utc>>, ServiceError> {
3383 Ok(
3384 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3385 .map(|record| record.rejected_since),
3386 )
3387}
3388
3389#[must_use]
3391pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3392 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3393}
3394
3395#[derive(Debug, Serialize, Deserialize)]
3396#[serde(deny_unknown_fields)]
3397struct CredentialUnreadableRecord {
3398 schema_version: u32,
3399 unreadable_since: DateTime<Utc>,
3400}
3401
3402pub fn record_credential_unreadable(
3421 paths: &AppPaths,
3422 at: DateTime<Utc>,
3423) -> Result<(), ServiceError> {
3424 if credential_unreadable_since(paths).ok().flatten().is_some() {
3425 return Ok(());
3426 }
3427 write_state_record(
3428 &credential_unreadable_path(paths),
3429 &CredentialUnreadableRecord {
3430 schema_version: CONTACT_SCHEMA_VERSION,
3431 unreadable_since: at,
3432 },
3433 )
3434}
3435
3436pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3442 remove_state_record(&credential_unreadable_path(paths))
3443}
3444
3445pub fn credential_unreadable_since(
3452 paths: &AppPaths,
3453) -> Result<Option<DateTime<Utc>>, ServiceError> {
3454 Ok(
3455 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3456 .map(|record| record.unreadable_since),
3457 )
3458}
3459
3460#[must_use]
3462pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3463 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3464}
3465
3466#[must_use]
3474pub fn sign_in_instruction(mode: StartMode) -> String {
3475 let (command, place) = match mode {
3476 StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
3477 StartMode::Boot => (
3478 "runner-manager auth login --start-at boot",
3479 " from an elevated terminal",
3480 ),
3481 StartMode::Login => (
3482 "runner-manager auth login --start-at login",
3483 MACOS_LOGIN_PLACE,
3484 ),
3485 };
3486 format!("`{command}`{place}")
3487}
3488
3489pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3492 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3493 keychain)"
3494} else {
3495 ""
3496};
3497
3498#[derive(Debug, Clone, PartialEq, Eq)]
3515pub struct DefinitionDrift {
3516 pub path: PathBuf,
3518 pub installed_by_version: String,
3520 pub rendered: String,
3522 pub start_mode: StartMode,
3524 pub manager: DefinitionKind,
3526}
3527
3528fn definition_drift(
3536 controls: &dyn ControlFactory,
3537 identity: &ServiceIdentity,
3538 record: &InstallRecord,
3539) -> Option<DefinitionDrift> {
3540 let compare = match host_definition_kind(record.start_mode) {
3541 DefinitionKind::SystemdUnit => systemd_definition_drift,
3542 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3543 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3544 };
3545 let path = record.definition_path.clone()?;
3546 let installed = controls.installed_definition(&path)?;
3547 compare(identity, record, path, &installed)
3548}
3549
3550fn launchd_definition_drift(
3559 identity: &ServiceIdentity,
3560 record: &InstallRecord,
3561 path: PathBuf,
3562 installed: &str,
3563) -> Option<DefinitionDrift> {
3564 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3565 || DefinitionDrift {
3566 path,
3567 installed_by_version: record.installed_by_version.clone(),
3568 rendered: launchd_plist(&record.plan(identity.clone())),
3569 start_mode: record.start_mode,
3570 manager: DefinitionKind::LaunchdPlist,
3571 },
3572 )
3573}
3574
3575fn systemd_definition_drift(
3576 identity: &ServiceIdentity,
3577 record: &InstallRecord,
3578 path: PathBuf,
3579 installed: &str,
3580) -> Option<DefinitionDrift> {
3581 let rendered = systemd_unit(&record.plan(identity.clone()));
3582 (installed != rendered).then(|| DefinitionDrift {
3583 path,
3584 installed_by_version: record.installed_by_version.clone(),
3585 rendered,
3586 start_mode: record.start_mode,
3587 manager: DefinitionKind::SystemdUnit,
3588 })
3589}
3590
3591const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3596
3597#[derive(Debug, Clone, Serialize, Deserialize)]
3598struct RootRefusalFile {
3599 schema_version: u32,
3600 #[serde(default)]
3602 refusals: BTreeMap<String, RootRefusalEntry>,
3603}
3604
3605#[derive(Debug, Clone, Serialize, Deserialize)]
3606struct RootRefusalEntry {
3607 at: DateTime<Utc>,
3608 kind: String,
3609 root: String,
3610 detail: String,
3611}
3612
3613#[derive(Debug, Clone, PartialEq, Eq)]
3615pub struct RunnerRootRefusal {
3616 pub policy: String,
3618 pub at: DateTime<Utc>,
3620 pub kind: String,
3622 pub root: String,
3624 pub detail: String,
3627}
3628
3629pub fn record_runner_root_refusal(
3663 paths: &AppPaths,
3664 policy: &str,
3665 at: DateTime<Utc>,
3666 kind: &str,
3667 root: &str,
3668 detail: &str,
3669) -> Result<(), ServiceError> {
3670 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3671 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3672 refusals: BTreeMap::new(),
3673 });
3674 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3675 file.refusals.insert(
3676 policy.to_owned(),
3677 RootRefusalEntry {
3678 at,
3679 kind: kind.to_owned(),
3680 root: root.to_owned(),
3681 detail: detail.to_owned(),
3682 },
3683 );
3684 write_refusal_file(paths, &file)
3685}
3686
3687pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3698 let Some(mut file) = read_refusal_file(paths)? else {
3699 return Ok(());
3700 };
3701 if file.refusals.remove(policy).is_none() {
3702 return Ok(());
3703 }
3704 if file.refusals.is_empty() {
3705 let path = root_refusal_path(paths);
3706 return match std::fs::remove_file(&path) {
3707 Ok(()) => Ok(()),
3708 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3709 Err(error) => Err(ServiceError::Record {
3710 operation: "remove",
3711 path,
3712 detail: error.to_string(),
3713 }),
3714 };
3715 }
3716 write_refusal_file(paths, &file)
3717}
3718
3719pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3731 Ok(read_refusal_file(paths)?
3732 .map(|file| {
3733 file.refusals
3734 .into_iter()
3735 .map(|(policy, entry)| RunnerRootRefusal {
3736 policy,
3737 at: entry.at,
3738 kind: entry.kind,
3739 root: entry.root,
3740 detail: entry.detail,
3741 })
3742 .collect()
3743 })
3744 .unwrap_or_default())
3745}
3746
3747fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3748 read_state_record(&root_refusal_path(paths))
3749}
3750
3751fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3752 write_state_record(&root_refusal_path(paths), file)
3753}
3754
3755#[must_use]
3757pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3758 paths.state_dir().join(ROOT_REFUSAL_FILE)
3759}
3760
3761#[derive(Debug, Clone, PartialEq, Eq)]
3772pub enum BinaryPath {
3773 Current {
3775 path: PathBuf,
3777 },
3778 Missing {
3785 recorded: PathBuf,
3787 },
3788 NotExecutable {
3791 recorded: PathBuf,
3793 detail: String,
3795 },
3796 Diverged {
3798 recorded: PathBuf,
3800 registered: PathBuf,
3802 },
3803}
3804
3805impl BinaryPath {
3806 #[must_use]
3808 pub const fn is_error(&self) -> bool {
3809 !matches!(self, Self::Current { .. })
3810 }
3811
3812 #[must_use]
3814 pub fn recorded(&self) -> &Path {
3815 match self {
3816 Self::Current { path } => path,
3817 Self::Missing { recorded }
3818 | Self::NotExecutable { recorded, .. }
3819 | Self::Diverged { recorded, .. } => recorded,
3820 }
3821 }
3822}
3823
3824impl fmt::Display for BinaryPath {
3825 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3826 match self {
3827 Self::Current { path } => write!(f, "{}", path.display()),
3828 Self::Missing { recorded } => write!(
3829 f,
3830 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3831 package manager that moved the binary is the usual cause; an `npm i -g` \
3832 installation moves with the active Node version. Run `service install` again \
3833 from the binary that is now installed.",
3834 recorded.display()
3835 ),
3836 Self::NotExecutable { recorded, detail } => write!(
3837 f,
3838 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3839 from the installed binary.",
3840 recorded.display()
3841 ),
3842 Self::Diverged {
3843 recorded,
3844 registered,
3845 } => write!(
3846 f,
3847 "{} -- STALE: the service manager is registered to start {} instead. Something \
3848 has edited the registration since it was installed. Run `service uninstall` and \
3849 `service install`; neither touches configuration, secrets, or the cache.",
3850 recorded.display(),
3851 registered.display()
3852 ),
3853 }
3854 }
3855}
3856
3857#[must_use]
3865pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3866 match std::fs::metadata(recorded) {
3867 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3868 return BinaryPath::Missing {
3869 recorded: recorded.to_path_buf(),
3870 };
3871 }
3872 Err(error) => {
3873 return BinaryPath::NotExecutable {
3874 recorded: recorded.to_path_buf(),
3875 detail: format!("it cannot be inspected ({error})"),
3876 };
3877 }
3878 Ok(metadata) if !metadata.is_file() => {
3879 return BinaryPath::NotExecutable {
3880 recorded: recorded.to_path_buf(),
3881 detail: "what is there is not a file".to_string(),
3882 };
3883 }
3884 Ok(_) => {}
3885 }
3886 if let Some(registered) = registered
3887 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3888 {
3889 return BinaryPath::Diverged {
3890 recorded: recorded.to_path_buf(),
3891 registered: registered.to_path_buf(),
3892 };
3893 }
3894 BinaryPath::Current {
3895 path: recorded.to_path_buf(),
3896 }
3897}
3898
3899#[derive(Debug, Clone, PartialEq, Eq)]
3905pub struct Registration {
3906 pub manager: DefinitionKind,
3911 pub start_mode: StartMode,
3913 pub command_line: String,
3915 pub account: Option<String>,
3917 pub running: bool,
3919 pub starts_automatically: bool,
3926 pub restart_delay: Option<Duration>,
3935}
3936
3937impl Registration {
3938 #[must_use]
3940 pub fn binary(&self) -> Option<PathBuf> {
3941 executable_from_command_line(&self.command_line)
3942 }
3943}
3944
3945pub trait ServiceControl: fmt::Debug {
3951 fn manager(&self) -> DefinitionKind;
3953
3954 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3960
3961 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3972
3973 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3979
3980 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3987
3988 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3994}
3995
3996pub trait ControlFactory: fmt::Debug + Send + Sync {
4002 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4008
4009 fn installed_definition(&self, _path: &Path) -> Option<String> {
4013 None
4014 }
4015
4016 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4021 None
4022 }
4023}
4024
4025#[derive(Debug, Clone, Copy, Default)]
4027pub struct HostControls;
4028
4029#[derive(Debug, Clone)]
4035pub struct Installed {
4036 pub plan: InstallPlan,
4038 pub definition: ServiceDefinition,
4040 pub record: InstallRecord,
4042 pub review: PrivilegeReview,
4044 pub runner_root: RootAccessSummary,
4049 pub replaced_existing: bool,
4057}
4058
4059#[derive(Debug, Clone, PartialEq, Eq)]
4061pub struct Uninstalled {
4062 pub removed_registration: bool,
4064 pub removed_record: bool,
4066 pub removed_definition: Option<PathBuf>,
4068 pub preserved: Vec<PathBuf>,
4075}
4076
4077impl fmt::Display for Uninstalled {
4078 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4079 if self.removed_registration {
4080 writeln!(f, "The service registration was removed.")?;
4081 } else {
4082 writeln!(f, "There was no service registration to remove.")?;
4083 }
4084 writeln!(f, "Nothing else was deleted. These are untouched:")?;
4085 for path in &self.preserved {
4086 writeln!(f, " {}", path.display())?;
4087 }
4088 write!(
4089 f,
4090 "The stored GitHub token is untouched too; `auth logout` is what purges it."
4091 )
4092 }
4093}
4094
4095#[derive(Debug, Clone, PartialEq, Eq)]
4097pub struct StartModeChange {
4098 pub from: StartMode,
4100 pub to: StartMode,
4102 pub changed: bool,
4104 pub store_scope: crate::secrets::SecretScope,
4107 pub runner_root: RootAccessSummary,
4113}
4114
4115impl fmt::Display for StartModeChange {
4116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4117 if !self.changed {
4118 return write!(f, "The service already starts at {}.", self.to);
4119 }
4120 write!(
4121 f,
4122 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4123 if the token was stored under the other scope, run `auth login` again. {}",
4124 self.to, self.from, self.store_scope, self.runner_root
4125 )
4126 }
4127}
4128
4129#[derive(Debug, Clone)]
4137pub struct ServiceOperations {
4138 paths: AppPaths,
4139 identity: ServiceIdentity,
4140 controls: std::sync::Arc<dyn ControlFactory>,
4141 runner_root: Option<LocalAbsolutePath>,
4142}
4143
4144impl ServiceOperations {
4145 #[must_use]
4147 pub fn on_this_host(paths: AppPaths) -> Self {
4148 Self::with_controls(
4149 paths,
4150 ServiceIdentity::product(),
4151 std::sync::Arc::new(HostControls),
4152 )
4153 }
4154
4155 #[must_use]
4163 pub fn with_controls(
4164 paths: AppPaths,
4165 identity: ServiceIdentity,
4166 controls: std::sync::Arc<dyn ControlFactory>,
4167 ) -> Self {
4168 Self {
4169 paths,
4170 identity,
4171 controls,
4172 runner_root: None,
4173 }
4174 }
4175
4176 #[must_use]
4195 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4196 self.runner_root = Some(root);
4197 self
4198 }
4199
4200 #[must_use]
4202 pub const fn paths(&self) -> &AppPaths {
4203 &self.paths
4204 }
4205
4206 #[must_use]
4208 pub const fn identity(&self) -> &ServiceIdentity {
4209 &self.identity
4210 }
4211
4212 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4232 self.paths
4233 .create_all()
4234 .map_err(|source| ServiceError::Paths {
4235 source: Box::new(source),
4236 })?;
4237
4238 let _guard = self.refuse_while_an_agent_runs()?;
4241 self.refuse_foreign("replace")?;
4245
4246 let (replacing, previous_was_running) = match self.find_registration()? {
4275 Some((existing, registration)) if existing == request.start_mode() => {
4276 (true, registration.running)
4277 }
4278 Some((existing, _)) => {
4279 return Err(ServiceError::AlreadyInstalled {
4280 name: self.identity.name().to_string(),
4281 existing,
4282 requested: request.start_mode(),
4283 });
4284 }
4285 None => (false, false),
4286 };
4287
4288 let plan = InstallPlan::resolve(
4289 self.identity.clone(),
4290 request,
4291 ServiceDirectories::of(&self.paths),
4292 )?;
4293
4294 let control = self.controls.control(plan.start_mode())?;
4299
4300 let root = self.prepare_runner_root(plan.start_mode())?;
4306
4307 let previous = if replacing {
4312 InstallRecord::read(&self.paths).ok().flatten()
4313 } else {
4314 None
4315 };
4316 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4317 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4318 }
4319
4320 let definition = match control.install(&plan) {
4321 Ok(definition) => definition,
4322 Err(cause) => {
4323 let restored =
4326 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4327 return Err(rolled_back(
4328 retained_runner_root(&root),
4329 restored,
4330 "install",
4331 &self.identity,
4332 cause,
4333 ));
4334 }
4335 };
4336 let review = review_least_privilege(&definition, &plan);
4337 let record = InstallRecord::of(&plan, &definition, Utc::now());
4338 if let Err(cause) = record.write(&self.paths) {
4339 let rollback = control
4340 .uninstall(&self.identity)
4341 .map(|_| ())
4342 .and_then(|()| {
4343 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4344 });
4345 return Err(rolled_back(
4346 retained_runner_root(&root),
4347 rollback,
4348 "install",
4349 &self.identity,
4350 cause,
4351 ));
4352 }
4353
4354 if let Err(cause) = control.start(&self.identity) {
4361 let remove_new = control.uninstall(&self.identity).map(|_| ());
4362 let restore_registration = remove_new.and_then(|()| {
4363 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4364 });
4365 let restore_record = restore_registration.and_then(|()| match &previous {
4366 Some(previous) => previous.write(&self.paths),
4367 None => InstallRecord::remove(&self.paths).map(|_| ()),
4368 });
4369 return Err(rolled_back(
4370 retained_runner_root(&root),
4371 restore_record,
4372 "install and start",
4373 &self.identity,
4374 cause,
4375 ));
4376 }
4377 Ok(Installed {
4378 plan,
4379 definition,
4380 record,
4381 review,
4382 runner_root: root.summary().clone(),
4383 replaced_existing: replacing,
4384 })
4385 }
4386
4387 fn reinstate(
4398 &self,
4399 control: &dyn ServiceControl,
4400 previous: Option<&InstallRecord>,
4401 was_running: bool,
4402 ) -> Result<(), ServiceError> {
4403 let Some(record) = previous else {
4404 return Ok(());
4405 };
4406 control.install(&record.plan(self.identity.clone()))?;
4407 if was_running {
4408 control.start(&self.identity)?;
4409 }
4410 Ok(())
4411 }
4412
4413 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4422 let record = InstallRecord::read(&self.paths).ok().flatten();
4423 let removed_definition = record
4424 .as_ref()
4425 .and_then(|record| record.definition_path.clone());
4426
4427 let mut removed_registration = false;
4428 let mut foreign = None;
4435 for mode in [StartMode::Boot, StartMode::Login] {
4436 let control = self.controls.control(mode)?;
4437 if let Some(directories) = control
4438 .query(&self.identity)
4439 .ok()
4440 .flatten()
4441 .and_then(|registration| self.foreign_directories(®istration))
4442 {
4443 foreign = Some(directories);
4444 continue;
4445 }
4446 if control.uninstall(&self.identity)? {
4447 removed_registration = true;
4448 }
4449 }
4450 if !removed_registration && let Some(directories) = foreign {
4451 return Err(self.foreign_refusal("uninstall", directories));
4452 }
4453 let removed_record = InstallRecord::remove(&self.paths)?;
4454 Ok(Uninstalled {
4455 removed_registration,
4456 removed_record,
4457 removed_definition: removed_definition.filter(|_| removed_registration),
4458 preserved: self
4459 .paths
4460 .all()
4461 .iter()
4462 .map(|(_, path)| (*path).to_path_buf())
4463 .collect(),
4464 })
4465 }
4466
4467 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4489 let Some(record) = InstallRecord::read(&self.paths)? else {
4490 return Err(ServiceError::NotInstalled {
4491 name: self.identity.name().to_string(),
4492 operation: "switch the start mode of",
4493 });
4494 };
4495 let from = record.start_mode;
4496 if from == to {
4497 return Ok(StartModeChange {
4501 from,
4502 to,
4503 changed: false,
4504 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4505 runner_root: RootAccessSummary::NotApplicable,
4506 });
4507 }
4508
4509 self.refuse_foreign("switch the start mode of")?;
4512
4513 #[cfg(windows)]
4514 let arguments = {
4515 let mut arguments = record.arguments.clone();
4516 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4517 if to == StartMode::Boot {
4518 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4519 }
4520 arguments
4521 };
4522 #[cfg(not(windows))]
4523 let arguments = record.arguments.clone();
4524 let plan = InstallPlan::unchecked(
4525 self.identity.clone(),
4526 to,
4527 record.binary.clone(),
4528 record.directories.clone(),
4529 )
4530 .with_arguments(arguments)
4531 .with_restart(record.restart());
4532 let plan = if record.starts_on_demand {
4533 plan.started_on_demand()
4534 } else {
4535 plan
4536 };
4537 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4538 Ok(store) => plan.with_secret_guard(store.guard()),
4539 Err(_) => plan,
4540 };
4541
4542 let target = self.controls.control(to)?;
4549 let previous = self.controls.control(from)?;
4554
4555 let root = self.prepare_runner_root(to)?;
4563
4564 let definition = match target.install(&plan) {
4565 Ok(definition) => definition,
4566 Err(cause) => {
4567 return Err(undo_runner_root(
4568 &root,
4569 "switch start mode",
4570 &self.identity,
4571 cause,
4572 ));
4573 }
4574 };
4575 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4576 if let Err(cause) = next_record.write(&self.paths) {
4577 return Err(rolled_back(
4578 retained_runner_root(&root),
4579 target.uninstall(&self.identity),
4580 "switch start mode",
4581 &self.identity,
4582 cause,
4583 ));
4584 }
4585
4586 if let Err(cause) = previous.uninstall(&self.identity) {
4590 let target_rollback = target.uninstall(&self.identity);
4591 let record_rollback = record.write(&self.paths);
4592 return Err(rolled_back(
4593 retained_runner_root(&root),
4594 target_rollback.and(record_rollback),
4595 "switch start mode",
4596 &self.identity,
4597 cause,
4598 ));
4599 }
4600 Ok(StartModeChange {
4601 from,
4602 to,
4603 changed: true,
4604 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4605 runner_root: root.summary().clone(),
4606 })
4607 }
4608
4609 pub fn start(&self) -> Result<(), ServiceError> {
4615 let mode = self.own_registration_mode("start")?;
4616 self.controls.control(mode)?.start(&self.identity)
4617 }
4618
4619 fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
4627 match self.registration_for_status()? {
4628 None => Err(ServiceError::NotInstalled {
4629 name: self.identity.name().to_string(),
4630 operation,
4631 }),
4632 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4633 Some((registration, None)) => Ok(registration.start_mode),
4634 }
4635 }
4636
4637 pub fn stop(&self) -> Result<bool, ServiceError> {
4643 let mode = self.own_registration_mode("stop")?;
4644 self.controls.control(mode)?.stop(&self.identity)
4645 }
4646
4647 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4678 match self.registration_for_status()? {
4679 Some((registration, Some(directories))) => {
4680 Ok(self.foreign_status(registration, directories))
4681 }
4682 found => {
4683 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4684 Ok(record) => (record, None),
4685 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4686 Err(error) => return Err(error),
4687 };
4688 self.status_at(
4689 &self.paths,
4690 record,
4691 record_refused.as_ref(),
4692 found.map(|(registration, _)| registration),
4693 )
4694 }
4695 }
4696 }
4697
4698 fn status_at(
4701 &self,
4702 paths: &AppPaths,
4703 record: Option<InstallRecord>,
4704 record_refused: Option<&ServiceError>,
4705 found: Option<Registration>,
4706 ) -> Result<ServiceStatus, ServiceError> {
4707 let last_github_contact = last_github_contact(paths)?;
4708 let drift = record
4709 .as_ref()
4710 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4711 let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
4712 let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
4713 let probed = self.controls.unattended_login();
4714 let unattended = record.as_ref().and_then(|record| {
4715 let account = probed
4718 .as_ref()
4719 .map_or_else(String::new, |_| service_account_name(&record.account));
4720 unattended_gap(record.start_mode, &account, probed.as_ref())
4721 });
4722 let now = Utc::now();
4723 let installed = record.is_some() || found.is_some();
4724 let status = ServiceStatus::compose(
4725 self.identity.clone(),
4726 record,
4727 record_refused,
4728 found,
4729 last_github_contact,
4730 paths,
4731 )
4732 .with_definition_drift(drift)
4733 .with_credential_rejection(credential_rejected_since)
4734 .with_credential_unreadable(credential_unreadable_since)
4735 .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
4736 .with_unattended_gap(unattended)
4737 .with_host_unfit(
4738 crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
4739 );
4740 let restart = status.restart_command();
4741 Ok(status.with_stall(
4742 crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
4743 ))
4744 }
4745
4746 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4753 Ok(InstallRecord::read(&self.paths)?
4754 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4755 }
4756
4757 #[cfg(target_os = "macos")]
4779 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4780 let root = unsafe { libc::getuid() } == 0;
4786 if root != (drift.start_mode == StartMode::Boot) {
4787 return Err(ServiceError::Control {
4788 operation: "rewrite",
4789 name: self.identity.name().to_string(),
4790 manager: "launchd",
4791 detail: format!(
4792 "the install record says --start-at {}, but this daemon runs as {}; the plist \
4793 is left as it is",
4794 drift.start_mode,
4795 if root { "root" } else { "the operator" }
4796 ),
4797 });
4798 }
4799 replace_definition(self.identity.name(), drift)?;
4800 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4804 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4805 let _ = record.write(&self.paths);
4806 }
4807 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4808 }
4809
4810 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4820 #[cfg(not(windows))]
4821 {
4822 let _ = (mode, &self.runner_root);
4825 Ok(RootAccessChange::not_applicable())
4826 }
4827 #[cfg(windows)]
4828 {
4829 let wrap = |source| ServiceError::RunnerRoot {
4830 source: Box::new(source),
4831 };
4832 if let Some(root) = self
4850 .runner_root
4851 .as_ref()
4852 .filter(|_| self.identity.is_fixture() || cfg!(test))
4853 {
4854 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4855 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4856 .map_err(wrap);
4857 }
4858
4859 let admission = match ServiceAccount::for_start_mode(mode) {
4860 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4863 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4867 RootAdmission::of_this_account().map_err(wrap)?
4868 }
4869 };
4870 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4871 }
4872 }
4873
4874 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4876 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4877 |source| match source {
4878 held @ LockError::Held { .. } => ServiceError::LockHeld {
4879 source: Box::new(held),
4880 },
4881 other => ServiceError::LockUnreadable {
4882 source: Box::new(other),
4883 },
4884 },
4885 )
4886 }
4887
4888 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4890 for mode in [StartMode::Boot, StartMode::Login] {
4891 let control = self.controls.control(mode)?;
4892 if let Some(registration) = control.query(&self.identity)? {
4893 return Ok(Some((registration.start_mode, registration)));
4894 }
4895 }
4896 Ok(None)
4897 }
4898
4899 fn registration_for_status(
4908 &self,
4909 ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
4910 let Some((mode, found)) = self.find_registration()? else {
4911 return Ok(None);
4912 };
4913 let Some(directories) = self.foreign_directories(&found) else {
4914 return Ok(Some((found, None)));
4915 };
4916 let other = match mode {
4917 StartMode::Boot => StartMode::Login,
4918 StartMode::Login => StartMode::Boot,
4919 };
4920 let own = self
4921 .controls
4922 .control(other)
4923 .ok()
4924 .and_then(|control| control.query(&self.identity).ok().flatten())
4925 .filter(|registration| self.foreign_directories(registration).is_none());
4926 Ok(Some(match own {
4927 Some(own) => (own, None),
4928 None => (found, Some(directories)),
4929 }))
4930 }
4931
4932 fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
4935 match self.registration_for_status()? {
4936 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4937 _ => Ok(()),
4938 }
4939 }
4940
4941 fn foreign_refusal(
4944 &self,
4945 operation: &'static str,
4946 directories: ServiceDirectories,
4947 ) -> ServiceError {
4948 let foreign = self.foreign_service_at(directories);
4949 ServiceError::ForeignInstallation {
4950 name: self.identity.name().to_string(),
4951 operation,
4952 installed_by: foreign.installed_by(),
4953 act_as: foreign.act_as(),
4954 }
4955 }
4956
4957 fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
4960 ServiceDirectories::from_command_line(®istration.command_line)
4961 .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
4962 }
4963
4964 #[must_use]
4969 pub fn foreign_service(&self) -> Option<ForeignService> {
4970 let (_, directories) = self.registration_for_status().ok()??;
4971 Some(self.foreign_service_at(directories?))
4972 }
4973
4974 fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
4977 let root = directories.root();
4978 let home = host_home();
4979 let this_account = home
4982 .as_deref()
4983 .filter(|home| contains_directory(home, &root))
4984 .and_then(|_| {
4985 if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
4986 Some(format!(
4987 "with `--data-dir {}`",
4988 quote_argument(&root.to_string_lossy())
4989 ))
4990 } else {
4991 AppPaths::discover()
4992 .ok()
4993 .filter(|standard| directories == ServiceDirectories::of(standard))
4994 .map(|_| "without `--data-dir`".to_owned())
4995 }
4996 });
4997 ForeignService {
4998 owner: data_root_owner(&root, home.as_deref()),
4999 this_account,
5000 root,
5001 directories,
5002 local_root: ServiceDirectories::of(&self.paths).root(),
5003 local_record: InstallRecord::path(&self.paths),
5004 unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5005 unreadable: None,
5006 }
5007 }
5008
5009 fn foreign_status(
5016 &self,
5017 registration: Registration,
5018 directories: ServiceDirectories,
5019 ) -> ServiceStatus {
5020 let service_paths = directories.paths();
5021 let mut foreign = self.foreign_service_at(directories);
5022 let unreadable = match InstallRecord::read(&service_paths) {
5023 Ok(Some(record)) => {
5024 match self.status_at(
5025 &service_paths,
5026 Some(record),
5027 None,
5028 Some(registration.clone()),
5029 ) {
5030 Ok(status) => return status.with_foreign(foreign),
5031 Err(error) => error.to_string(),
5032 }
5033 }
5034 Ok(None) => format!(
5035 "there is no install record at {}",
5036 InstallRecord::path(&service_paths).display()
5037 ),
5038 Err(
5043 ServiceError::RecordNotPermitted { path, detail }
5044 | ServiceError::RecordUnreadable { path, detail }
5045 | ServiceError::Record { path, detail, .. },
5046 ) => format!("this account cannot read {} ({detail})", path.display()),
5047 Err(error) => error.to_string(),
5048 };
5049 foreign.unreadable = Some(unreadable);
5050 ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5051 }
5052
5053 pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5077 let config = self.paths.config_dir();
5078 let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5079 let mut service_root = None;
5080 for mode in [StartMode::Boot, StartMode::Login] {
5081 let registration = self
5082 .controls
5083 .control(mode)
5084 .and_then(|control| control.query(&self.identity))
5085 .map_err(|error| {
5086 in_use(format!(
5087 "whether a {mode} registration runs against it could not be read ({error})"
5088 ))
5089 })?;
5090 let Some(registration) = registration else {
5091 continue;
5092 };
5093 let Some(directories) = self.foreign_directories(®istration) else {
5094 return Err(in_use(format!(
5095 "{} holds a registration that runs against {}. `service status` describes it",
5096 registration.manager,
5097 config.display()
5098 )));
5099 };
5100 if let Some(overlap) = directories.all().into_iter().find(|directory| {
5101 contains_directory(directory, config) || contains_directory(config, directory)
5102 }) {
5103 return Err(in_use(format!(
5104 "the machine's service runs against {}, which overlaps {}",
5105 overlap.display(),
5106 config.display()
5107 )));
5108 }
5109 service_root.get_or_insert_with(|| directories.root());
5110 }
5111 let _lock =
5112 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5113 in_use(format!(
5114 "an agent may be running against these directories: {source}"
5115 ))
5116 })?;
5117 let failed = |operation: &'static str, path: &Path| {
5118 let path = path.to_path_buf();
5119 move |error: std::io::Error| ServiceError::Record {
5120 operation,
5121 path,
5122 detail: error.to_string(),
5123 }
5124 };
5125 let kept = [
5127 self.paths.state_dir(),
5128 self.paths.runtime_dir(),
5129 self.paths.logs_dir(),
5130 ];
5131 let mut entries = Vec::new();
5132 for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5133 let entry = entry.map_err(failed("list", config))?.path();
5134 let archived = entry
5135 .file_name()
5136 .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5137 if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5138 entries.push(entry);
5139 }
5140 }
5141 if entries.is_empty() {
5142 return Ok(ForgottenLocal {
5143 archive: None,
5144 moved: entries,
5145 service_root,
5146 });
5147 }
5148 entries.sort();
5149 let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5150 std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5151 let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5152 for (index, entry) in entries.iter().enumerate() {
5153 if let Err(error) = std::fs::rename(entry, inside(entry)) {
5154 let stranded: Vec<String> = entries[..index]
5155 .iter()
5156 .rev()
5157 .filter(|done| std::fs::rename(inside(done), done).is_err())
5158 .map(|done| inside(done).display().to_string())
5159 .collect();
5160 let _ = std::fs::remove_dir(&archive);
5161 let mut detail = error.to_string();
5162 if !stranded.is_empty() {
5163 detail.push_str(&format!(
5164 "; these could not be put back and are still in the archive: {}",
5165 stranded.join(", ")
5166 ));
5167 }
5168 return Err(ServiceError::Record {
5169 operation: "archive",
5170 path: entry.clone(),
5171 detail,
5172 });
5173 }
5174 }
5175 Ok(ForgottenLocal {
5176 archive: Some(archive),
5177 moved: entries,
5178 service_root,
5179 })
5180 }
5181}
5182
5183pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5186
5187#[derive(Debug, Clone, PartialEq, Eq)]
5189pub struct ForgottenLocal {
5190 pub archive: Option<PathBuf>,
5192 pub moved: Vec<PathBuf>,
5194 pub service_root: Option<PathBuf>,
5197}
5198
5199impl fmt::Display for ForgottenLocal {
5200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5201 let Some(archive) = &self.archive else {
5202 return write!(
5203 f,
5204 "Nothing to archive: this account's configuration directory is empty."
5205 );
5206 };
5207 writeln!(f, "Archived this account's unused local configuration")?;
5208 writeln!(f, " archive {}", archive.display())?;
5209 for path in &self.moved {
5210 writeln!(f, " moved {}", path.display())?;
5211 }
5212 if let Some(root) = &self.service_root {
5213 writeln!(
5214 f,
5215 " service data root {} (untouched)",
5216 root.display()
5217 )?;
5218 }
5219 write!(
5220 f,
5221 " restore move everything in the archive back into its parent \
5222 directory"
5223 )
5224 }
5225}
5226
5227fn same_directory(left: &Path, right: &Path) -> bool {
5230 let text = |path: &Path| {
5231 path.to_string_lossy()
5232 .trim_end_matches(['/', '\\'])
5233 .to_owned()
5234 };
5235 same_path_text(&text(left), &text(right))
5236 || matches!(
5237 (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5238 (Ok(left), Ok(right)) if left == right
5239 )
5240}
5241
5242const REMEDY_OPENINGS: [&str; 8] = [
5245 ". Run ",
5246 ". If nothing is waiting",
5247 ". Re-register",
5248 ". Switch the start mode",
5249 " Sign in again",
5250 ". Fix: ",
5251 "; to do it now",
5252 "; if the service manager denies",
5253];
5254
5255fn diagnosis_only(detail: &str) -> String {
5264 let cut = REMEDY_OPENINGS
5265 .iter()
5266 .filter_map(|opening| detail.find(opening))
5267 .min()
5268 .unwrap_or(detail.len());
5269 let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5270 let names_a_change = |text: &str| {
5271 [
5272 "service install",
5273 "service uninstall",
5274 "auth login",
5275 "launchctl",
5276 "systemctl",
5277 ]
5278 .iter()
5279 .any(|command| text.contains(command))
5280 };
5281 if !names_a_change(diagnosis) {
5282 return diagnosis.to_owned();
5283 }
5284 let end = [", ", "; ", ". "]
5286 .iter()
5287 .filter_map(|separator| diagnosis.find(separator))
5288 .min()
5289 .unwrap_or(diagnosis.len());
5290 let first = &diagnosis[..end];
5291 if names_a_change(first) {
5292 "the installation's own records report this".to_owned()
5293 } else {
5294 first.to_owned()
5295 }
5296}
5297
5298fn path_key(path: &Path) -> Vec<String> {
5301 path.components()
5302 .map(|component| {
5303 let text = component.as_os_str().to_string_lossy();
5304 if cfg!(windows) {
5305 text.to_lowercase()
5306 } else {
5307 text.into_owned()
5308 }
5309 })
5310 .collect()
5311}
5312
5313fn contains_directory(outer: &Path, inner: &Path) -> bool {
5316 path_key(inner).starts_with(&path_key(outer))
5317 || matches!(
5318 (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5319 (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5320 )
5321}
5322
5323#[must_use]
5328pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5329 let target = path_key(root);
5330 if cfg!(windows) {
5331 if target
5332 .windows(3)
5333 .any(|run| run == ["system32", "config", "systemprofile"])
5334 {
5335 return Some("LocalSystem".to_owned());
5336 }
5337 } else if ["/root", "/var/root", "/private/var/root"]
5338 .iter()
5339 .any(|home| root.starts_with(home))
5340 {
5341 return Some("root".to_owned());
5342 }
5343 let users = path_key(home?.parent()?);
5344 if users.len() < 2 {
5347 return None;
5348 }
5349 if target.len() > users.len() && target[..users.len()] == users[..] {
5350 root.components()
5351 .nth(users.len())
5352 .map(|name| name.as_os_str().to_string_lossy().into_owned())
5353 } else {
5354 None
5355 }
5356}
5357
5358#[derive(Debug, Clone, PartialEq, Eq)]
5372pub struct ForeignService {
5373 pub directories: ServiceDirectories,
5375 pub root: PathBuf,
5377 pub owner: Option<String>,
5379 pub this_account: Option<String>,
5384 pub local_root: PathBuf,
5386 pub local_record: PathBuf,
5388 pub unused_local_record: Option<InstallRecord>,
5390 pub unreadable: Option<String>,
5393}
5394
5395impl ForeignService {
5396 #[must_use]
5398 pub fn where_to_look(&self) -> String {
5399 if let Some(reach) = &self.this_account {
5400 return reach.clone();
5401 }
5402 let elevated = if cfg!(windows) {
5403 "from an elevated terminal (Run as administrator)"
5404 } else {
5405 "with sudo"
5406 };
5407 match &self.owner {
5408 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5409 format!("{elevated}, or signed in as {owner}")
5410 }
5411 _ => elevated.to_owned(),
5412 }
5413 }
5414
5415 #[must_use]
5420 pub fn act_as(&self) -> String {
5421 if let Some(reach) = &self.this_account {
5422 return reach.clone();
5423 }
5424 match &self.owner {
5425 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5426 format!("signed in as {owner}")
5427 }
5428 _ => format!(
5429 "from the account whose data root {} is",
5430 self.root.display()
5431 ),
5432 }
5433 }
5434
5435 #[must_use]
5438 pub const fn is_unread(&self) -> bool {
5439 self.unreadable.is_some()
5440 }
5441
5442 #[must_use]
5444 pub fn installed_by(&self) -> String {
5445 match &self.owner {
5446 Some(owner) => format!("{owner}'s data root {}", self.root.display()),
5447 None => format!("the data root {}", self.root.display()),
5448 }
5449 }
5450
5451 #[must_use]
5453 pub fn label(&self) -> String {
5454 match &self.owner {
5455 Some(owner) => format!("{} ({owner})", self.root.display()),
5456 None => self.root.display().to_string(),
5457 }
5458 }
5459
5460 #[must_use]
5462 pub fn unused_local_note(&self) -> Option<String> {
5463 let record = self.unused_local_record.as_ref()?;
5464 Some(format!(
5465 "unused local configuration from an earlier install: {} records a {} registration \
5466 made by runner-manager {} on {}, and no service on this machine runs against it. \
5467 `runner-manager host forget-local` moves it aside; it never touches the service's \
5468 own directories or any secret.",
5469 self.local_record.display(),
5470 record.start_mode,
5471 record.installed_by_version,
5472 record.installed_at.format("%Y-%m-%d"),
5473 ))
5474 }
5475}
5476
5477#[derive(Debug, Clone, PartialEq, Eq)]
5483pub struct StatusProblem {
5484 pub subject: &'static str,
5486 pub detail: String,
5488}
5489
5490impl fmt::Display for StatusProblem {
5491 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5492 write!(f, "{}: {}", self.subject, self.detail)
5493 }
5494}
5495
5496#[derive(Debug, Clone)]
5505pub struct ServiceStatus {
5506 identity: ServiceIdentity,
5507 record: Option<InstallRecord>,
5508 registration: Option<Registration>,
5509 binary: Option<BinaryPath>,
5510 log_file: PathBuf,
5511 store: Option<crate::secrets::ActiveStore>,
5512 last_github_contact: Option<DateTime<Utc>>,
5513 runner_root: Option<(PathBuf, RootAccessReport)>,
5514 credential_rejected_since: Option<DateTime<Utc>>,
5515 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
5516 unattended_gap: Option<UnattendedGap>,
5517 foreign: Option<ForeignService>,
5518 problems: Vec<StatusProblem>,
5519 notes: Vec<String>,
5520}
5521
5522#[derive(Debug, Clone, PartialEq, Eq)]
5525pub struct UnattendedGap {
5526 pub reason: String,
5528 pub remedy: String,
5530}
5531
5532#[must_use]
5535pub fn service_account_name(account: &ServiceAccount) -> String {
5536 match account {
5537 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
5539 .ok()
5540 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
5541 .or_else(crate::unattended_login::current_account)
5542 .unwrap_or_else(|| "the operator".to_owned()),
5543 other => other.as_str().to_owned(),
5544 }
5545}
5546
5547#[must_use]
5556pub fn unattended_gap(
5557 mode: StartMode,
5558 account: &str,
5559 found: Option<&crate::unattended_login::UnattendedLogin>,
5560) -> Option<UnattendedGap> {
5561 let Some(found) = found else {
5562 return (mode == StartMode::Login).then(|| UnattendedGap {
5563 reason: "This registration starts at login, so the agent does not run until the \
5564 operator signs in; this host does not resume work after an unattended \
5565 reboot."
5566 .to_owned(),
5567 remedy: "For machine-on operation, move the service and credential to `--start-at \
5568 boot` from an elevated terminal."
5569 .to_owned(),
5570 });
5571 };
5572 let resume = crate::unattended_login::resume(found, mode, account);
5573 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
5574 return None;
5577 }
5578 Some(UnattendedGap {
5579 reason: resume.explain(account)?,
5580 remedy: resume.remedy().unwrap_or_else(|| {
5581 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
5582 FileVault can be read."
5583 .to_owned()
5584 }),
5585 })
5586}
5587
5588impl ServiceStatus {
5589 fn compose(
5594 identity: ServiceIdentity,
5595 record: Option<InstallRecord>,
5596 record_refused: Option<&ServiceError>,
5597 registration: Option<Registration>,
5598 last_github_contact: Option<DateTime<Utc>>,
5599 paths: &AppPaths,
5600 ) -> Self {
5601 let mut problems = Vec::new();
5602 let mut notes = Vec::new();
5603
5604 if let Some(refusal) = record_refused {
5605 problems.push(StatusProblem {
5610 subject: "install record",
5611 detail: refusal.to_string(),
5612 });
5613 }
5614
5615 let log_file = record.as_ref().map_or_else(
5616 || ServiceDirectories::of(paths).log_file(),
5617 |record| record.log_file.clone(),
5618 );
5619
5620 let binary = record.as_ref().map(|record| {
5621 inspect_binary(
5622 &record.binary,
5623 registration
5624 .as_ref()
5625 .and_then(Registration::binary)
5626 .as_deref(),
5627 )
5628 });
5629 if let Some(state) = &binary
5630 && state.is_error()
5631 {
5632 problems.push(StatusProblem {
5633 subject: "binary",
5634 detail: state.to_string(),
5635 });
5636 }
5637
5638 match (&record, ®istration) {
5639 (Some(_), None) => problems.push(StatusProblem {
5640 subject: "registration",
5641 detail: "this host has a service record but no service manager knows the \
5642 registration. Run `service install` again; it deletes nothing."
5643 .to_string(),
5644 }),
5645 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
5650 subject: "record",
5651 detail: format!(
5652 "{} holds a registration for this service but there is no install record, so \
5653 the path it was installed from and the directories it was installed against \
5654 are unknown. Run `service uninstall` and `service install`.",
5655 found.manager
5656 ),
5657 }),
5658 (None, Some(_)) => {}
5659 (Some(record), Some(found)) => {
5660 if record.start_mode != found.start_mode {
5661 problems.push(StatusProblem {
5662 subject: "start mode",
5663 detail: format!(
5664 "the record says {} and {} holds a {} registration. Switch the start \
5665 mode again to make them agree.",
5666 record.start_mode, found.manager, found.start_mode
5667 ),
5668 });
5669 }
5670 if record.start_mode == StartMode::Boot && !found.starts_automatically {
5671 problems.push(StatusProblem {
5672 subject: "start mode",
5673 detail: format!(
5674 "{} holds the registration but will not start it by itself, so this \
5675 host does not resume work after a reboot.",
5676 found.manager
5677 ),
5678 });
5679 }
5680 if let Some(actual) = found.restart_delay {
5681 let expected = record.restart().effective_delay(found.manager);
5682 if actual != expected {
5683 problems.push(StatusProblem {
5684 subject: "restart policy",
5685 detail: format!(
5686 "the record says the service restarts after {}s and {} reports \
5687 {}s. Something has edited the registration since it was \
5688 installed.",
5689 expected.as_secs(),
5690 found.manager,
5691 actual.as_secs()
5692 ),
5693 });
5694 } else if expected != record.restart().delay() {
5695 notes.push(format!(
5700 "{} expresses the restart delay in whole minutes, so the {}s asked \
5701 for is enforced as {}s. The service therefore never restarts faster \
5702 than the configured bound.",
5703 found.manager,
5704 record.restart().delay().as_secs(),
5705 expected.as_secs()
5706 ));
5707 }
5708 }
5709 if found.starts_automatically && !found.running {
5710 problems.push(StatusProblem {
5711 subject: "runtime",
5712 detail: format!(
5713 "{} holds an automatic registration, but the daemon is stopped. Run \
5714 `runner-manager service install --start-at {}`; if the service \
5715 manager denies replacement, retry from an elevated terminal. If it \
5716 stops again, inspect {}.",
5717 found.manager,
5718 found.start_mode,
5719 log_file.display()
5720 ),
5721 });
5722 }
5723 }
5724 (None, None) => {}
5725 }
5726
5727 let store = record.as_ref().and_then(|record| {
5732 let registered_mode = registration
5733 .as_ref()
5734 .map_or(record.start_mode, |found| found.start_mode);
5735 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
5736 .ok()
5737 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
5738 });
5739 if let Some(store) = &store
5740 && !store.agrees_with_start_mode()
5741 {
5742 problems.push(StatusProblem {
5743 subject: "secret store",
5744 detail: format!(
5745 "{store}. Run `auth login` again so the token is stored where the registered \
5746 start mode can read it."
5747 ),
5748 });
5749 }
5750
5751 if last_github_contact.is_none() {
5752 notes.push(
5753 "GitHub has not been reached successfully since this host's state directory was \
5754 created."
5755 .to_string(),
5756 );
5757 }
5758
5759 let runner_root = crate::runner_root::default_runner_root(paths)
5766 .ok()
5767 .map(|root| {
5768 let path = root.as_path().to_path_buf();
5769 let report = crate::runner_root_access::report(&path);
5770 (path, report)
5771 });
5772 if let Some((
5782 path,
5783 RootAccessReport::Present {
5784 broad_write: true, ..
5785 },
5786 )) = &runner_root
5787 {
5788 notes.push(format!(
5789 "the platform default runner root {} can be written by ordinary local users, so \
5790 it is not a safe place to run jobs. `service install` refuses it rather than \
5791 tightening it, because the contents of a directory anybody could write cannot \
5792 be trusted: remove or empty it, or choose another root with `runner-manager \
5793 host set-runtime-root --path <PATH>`.",
5794 path.display()
5795 ));
5796 }
5797
5798 match runner_root_refusals(paths) {
5821 Ok(refusals) => {
5822 for refusal in refusals {
5823 notes.push(format!(
5824 "policy {} started no runner: its runner root {} refused the launch \
5825 ({}), last at {}. {} This clears when that policy next places a \
5826 runner.",
5827 refusal.policy,
5828 refusal.root,
5829 refusal.kind,
5830 refusal.at.to_rfc3339(),
5831 refusal.detail,
5832 ));
5833 }
5834 }
5835 Err(error) => notes.push(format!(
5836 "whether the agent could use its runner roots could not be read: {error}"
5837 )),
5838 }
5839
5840 Self {
5841 identity,
5842 record,
5843 registration,
5844 binary,
5845 log_file,
5846 store,
5847 last_github_contact,
5848 runner_root,
5849 credential_rejected_since: None,
5850 launches_blocked: None,
5851 unattended_gap: None,
5852 foreign: None,
5853 problems,
5854 notes,
5855 }
5856 }
5857
5858 fn with_foreign(mut self, foreign: ForeignService) -> Self {
5869 let act_as = foreign.act_as();
5870 for problem in &mut self.problems {
5871 problem.detail = format!(
5872 "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
5873 own directories, not the service's.",
5874 diagnosis_only(&problem.detail)
5875 );
5876 }
5877 if let Some(note) = foreign.unused_local_note() {
5878 self.notes.push(note);
5879 }
5880 self.foreign = Some(foreign);
5881 self
5882 }
5883
5884 fn foreign_unread(
5892 identity: ServiceIdentity,
5893 registration: Registration,
5894 foreign: ForeignService,
5895 ) -> Self {
5896 let mut problems = Vec::new();
5897 let look = foreign.where_to_look();
5898 if registration.starts_automatically && !registration.running {
5899 problems.push(StatusProblem {
5900 subject: "runtime",
5901 detail: format!(
5902 "{} holds an automatic registration for the service installed from {}, but \
5903 it is stopped. Run `runner-manager service status` {look} to see why; do not \
5904 reinstall it from this account.",
5905 registration.manager,
5906 foreign.installed_by(),
5907 ),
5908 });
5909 }
5910 if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
5911 problems.push(StatusProblem {
5912 subject: "start mode",
5913 detail: format!(
5914 "{} holds the boot registration installed from {} but will not start it by \
5915 itself. Run `runner-manager service status` {look}.",
5916 registration.manager,
5917 foreign.installed_by(),
5918 ),
5919 });
5920 }
5921 let mut notes = Vec::new();
5922 if let Some(reason) = &foreign.unreadable {
5923 notes.push(format!(
5924 "its health is not known here: {reason}. Run `runner-manager service status` \
5925 {look} to see it."
5926 ));
5927 }
5928 notes.extend(foreign.unused_local_note());
5929 Self {
5930 identity,
5931 record: None,
5932 log_file: foreign.directories.log_file(),
5933 registration: Some(registration),
5934 binary: None,
5935 store: None,
5936 last_github_contact: None,
5937 runner_root: None,
5938 credential_rejected_since: None,
5939 launches_blocked: None,
5940 unattended_gap: None,
5941 foreign: Some(foreign),
5942 problems,
5943 notes,
5944 }
5945 }
5946
5947 #[must_use]
5950 pub const fn foreign(&self) -> Option<&ForeignService> {
5951 self.foreign.as_ref()
5952 }
5953
5954 #[must_use]
5957 pub fn is_unread(&self) -> bool {
5958 self.foreign.as_ref().is_some_and(ForeignService::is_unread)
5959 }
5960
5961 #[must_use]
5963 pub fn verdict(&self) -> String {
5964 if !self.is_healthy() {
5965 return "NOT healthy".to_owned();
5966 }
5967 match &self.foreign {
5968 Some(foreign) if foreign.is_unread() => format!(
5969 "{}; installed from {}, so its health is read {}",
5970 if self.is_running() {
5971 "running"
5972 } else {
5973 "not running"
5974 },
5975 foreign.installed_by(),
5976 foreign.where_to_look()
5977 ),
5978 _ => "healthy".to_owned(),
5979 }
5980 }
5981
5982 #[must_use]
5984 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
5985 if let Some(drift) = drift {
5986 if drift.manager == DefinitionKind::LaunchdPlist {
5987 let restart = match drift.start_mode {
5991 StartMode::Boot => format!(
5992 "sudo launchctl kickstart -k system/{}",
5993 self.identity.launchd_label()
5994 ),
5995 StartMode::Login => format!(
5996 "launchctl kickstart -k gui/$(id -u)/{}",
5997 self.identity.launchd_label()
5998 ),
5999 };
6000 self.problems.push(StatusProblem {
6001 subject: "definition",
6002 detail: format!(
6003 "{} was written by runner-manager {} with a `ProcessType` other than \
6004 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6005 daemon starts, so their jobs run below normal priority. The daemon \
6006 rewrites it and reloads itself when it starts with no runner, which it \
6007 does after every upgrade; to do it now, restart the service while no \
6008 job is running: `{restart}`. Configuration and credentials are kept.",
6009 drift.path.display(),
6010 drift.installed_by_version,
6011 ),
6012 });
6013 return self;
6014 }
6015 let repair = match drift.start_mode {
6016 StartMode::Boot => {
6017 "`sudo systemctl stop runner-manager && sudo runner-manager service install \
6018 --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
6019 --distribution <name>` from Windows)"
6020 }
6021 StartMode::Login => {
6022 "`systemctl --user stop runner-manager && runner-manager service install \
6023 --start-at login`"
6024 }
6025 };
6026 self.problems.push(StatusProblem {
6027 subject: "definition",
6028 detail: format!(
6029 "{} was written by runner-manager {} and differs from what {} renders, so \
6030 the daemon runs under outdated settings (for example a credential frozen \
6031 at service start). Re-register it with {repair}; configuration and \
6032 credentials are kept.",
6033 drift.path.display(),
6034 drift.installed_by_version,
6035 env!("CARGO_PKG_VERSION"),
6036 ),
6037 });
6038 }
6039 self
6040 }
6041
6042 #[must_use]
6044 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6045 if let Some(since) = since {
6046 self.credential_rejected_since = Some(since);
6047 self.problems.push(StatusProblem {
6048 subject: "github credential",
6049 detail: format!(
6050 "GitHub has rejected the service's credential since {}, so the service \
6051 cannot see queued jobs or start runners. Run `runner-manager auth login` \
6052 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6053 from Windows).",
6054 since.to_rfc3339()
6055 ),
6056 });
6057 }
6058 self
6059 }
6060
6061 #[must_use]
6063 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6064 self.credential_rejected_since
6065 }
6066
6067 #[must_use]
6070 pub fn with_launches_blocked(
6071 mut self,
6072 blocked: Option<crate::launch_health::LaunchesBlocked>,
6073 ) -> Self {
6074 if let Some(blocked) = &blocked {
6075 self.problems.push(StatusProblem {
6076 subject: LAUNCHES_BLOCKED_SUBJECT,
6077 detail: format!(
6078 "the service has started no runner {blocked}. Fix: {}.",
6079 blocked.remedy
6080 ),
6081 });
6082 }
6083 self.launches_blocked = blocked;
6084 self
6085 }
6086
6087 #[must_use]
6089 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6090 self.launches_blocked.as_ref()
6091 }
6092
6093 #[must_use]
6096 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6097 if let Some(since) = since {
6098 let mode = self.start_mode().unwrap_or_default();
6099 self.problems.push(StatusProblem {
6100 subject: CREDENTIAL_UNREADABLE_SUBJECT,
6101 detail: format!(
6102 "the service has been unable to read its stored GitHub credential since {}, \
6103 so it starts no runner; the diagnostic log has the store's own answer.{} \
6104 Sign in again: {}.",
6105 since.to_rfc3339(),
6106 if cfg!(target_os = "macos") {
6107 " That is what a keychain item written by a different build looks like."
6108 } else {
6109 ""
6110 },
6111 sign_in_instruction(mode)
6112 ),
6113 });
6114 }
6115 self
6116 }
6117
6118 #[must_use]
6126 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6127 self.runner_root
6128 .as_ref()
6129 .map(|(path, report)| (path.as_path(), report))
6130 }
6131
6132 #[must_use]
6134 pub const fn is_installed(&self) -> bool {
6135 self.registration.is_some() || self.record.is_some()
6136 }
6137
6138 #[must_use]
6140 pub fn is_running(&self) -> bool {
6141 self.registration
6142 .as_ref()
6143 .is_some_and(|registration| registration.running)
6144 }
6145
6146 #[must_use]
6148 pub fn is_healthy(&self) -> bool {
6149 self.problems.is_empty()
6150 }
6151
6152 #[must_use]
6154 pub fn problems(&self) -> &[StatusProblem] {
6155 &self.problems
6156 }
6157
6158 #[must_use]
6161 pub fn notes(&self) -> &[String] {
6162 &self.notes
6163 }
6164
6165 #[must_use]
6170 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6171 if let Some(unfit) = unfit {
6172 self.problems.push(StatusProblem {
6173 subject: HOST_UNFIT_SUBJECT,
6174 detail: format!(
6175 "the service has started no runner since {}, because a required host check \
6176 fails from its own session: {}",
6177 unfit.since.to_rfc3339(),
6178 unfit.describe()
6179 ),
6180 });
6181 }
6182 self
6183 }
6184
6185 #[must_use]
6189 pub fn with_stall(mut self, stall: Option<String>) -> Self {
6190 if let Some(detail) = stall {
6191 self.problems.push(StatusProblem {
6192 subject: DAEMON_STALLED_SUBJECT,
6193 detail,
6194 });
6195 }
6196 self
6197 }
6198
6199 #[must_use]
6202 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6203 if let Some(gap) = &gap {
6204 self.notes.push(gap.reason.clone());
6205 }
6206 self.unattended_gap = gap;
6207 self
6208 }
6209
6210 #[must_use]
6213 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6214 self.unattended_gap.as_ref()
6215 }
6216
6217 #[must_use]
6220 pub fn restart_command(&self) -> String {
6221 self.identity
6222 .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
6223 }
6224
6225 #[must_use]
6229 pub fn start_mode(&self) -> Option<StartMode> {
6230 self.record
6231 .as_ref()
6232 .map(|record| record.start_mode)
6233 .or_else(|| {
6234 self.registration
6235 .as_ref()
6236 .filter(|_| self.is_unread())
6237 .map(|registration| registration.start_mode)
6238 })
6239 }
6240
6241 #[must_use]
6245 pub fn service_binary(&self) -> Option<PathBuf> {
6246 self.record
6247 .as_ref()
6248 .map(|record| record.binary.clone())
6249 .or_else(|| {
6250 self.registration
6251 .as_ref()
6252 .filter(|_| self.is_unread())
6253 .and_then(Registration::binary)
6254 })
6255 }
6256
6257 #[must_use]
6260 pub const fn binary(&self) -> Option<&BinaryPath> {
6261 self.binary.as_ref()
6262 }
6263
6264 #[must_use]
6266 pub fn log_file(&self) -> &Path {
6267 &self.log_file
6268 }
6269
6270 #[must_use]
6272 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6273 self.last_github_contact
6274 }
6275
6276 #[must_use]
6279 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6280 self.store.as_ref()
6281 }
6282
6283 #[must_use]
6285 pub const fn record(&self) -> Option<&InstallRecord> {
6286 self.record.as_ref()
6287 }
6288
6289 #[must_use]
6291 pub const fn registration(&self) -> Option<&Registration> {
6292 self.registration.as_ref()
6293 }
6294}
6295
6296impl fmt::Display for ServiceStatus {
6297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6298 writeln!(f, "Service: {}", self.identity)?;
6299 match (&self.record, &self.registration) {
6300 (None, None) => {
6301 writeln!(
6302 f,
6303 " installed no. `service install` registers `{} {}`.",
6304 SERVICE_NAME,
6305 DAEMON_ARGUMENTS.join(" ")
6306 )?;
6307 }
6308 _ => {
6309 let manager = self
6310 .registration
6311 .as_ref()
6312 .map(|registration| registration.manager.manager());
6313 writeln!(
6314 f,
6315 " installed {}",
6316 manager.unwrap_or("yes, but no service manager knows it")
6317 )?;
6318 writeln!(
6319 f,
6320 " state {}",
6321 if self.is_running() {
6322 "running"
6323 } else {
6324 "not running"
6325 }
6326 )?;
6327 }
6328 }
6329 if let Some(foreign) = &self.foreign {
6330 writeln!(
6331 f,
6332 " installed from {} -- another installation's, not this account's ({})",
6333 foreign.installed_by(),
6334 foreign.local_root.display()
6335 )?;
6336 if foreign.is_unread()
6338 && let Some(registration) = &self.registration
6339 {
6340 writeln!(f, " start mode {}", registration.start_mode)?;
6341 if let Some(account) = ®istration.account {
6342 writeln!(f, " account {account}")?;
6343 }
6344 if let Some(binary) = self.service_binary() {
6345 writeln!(f, " binary {}", binary.display())?;
6346 }
6347 }
6348 }
6349 if let Some(record) = &self.record {
6350 writeln!(f, " start mode {}", record.start_mode)?;
6351 writeln!(f, " account {}", record.account)?;
6352 writeln!(f, " restart on failure {}", record.restart())?;
6353 writeln!(
6354 f,
6355 " arguments {}",
6356 record.arguments.join(" ")
6357 )?;
6358 }
6359 if let Some(binary) = &self.binary {
6360 writeln!(f, " binary {binary}")?;
6361 }
6362 writeln!(f, " diagnostic log {}", self.log_file.display())?;
6363 if let Some((path, report)) = &self.runner_root {
6364 writeln!(f, " default runner root {}", path.display())?;
6371 if *report != RootAccessReport::NotApplicable {
6372 writeln!(f, " default root access {report}")?;
6373 }
6374 }
6375 if let Some(store) = &self.store {
6376 writeln!(f, " secret store {store}")?;
6377 }
6378 if !self.is_unread() {
6381 writeln!(
6382 f,
6383 " last GitHub contact {}",
6384 match self.last_github_contact {
6385 Some(at) => at.to_rfc3339(),
6386 None => "never".to_string(),
6387 }
6388 )?;
6389 }
6390 for note in &self.notes {
6391 writeln!(f, " note {note}")?;
6392 }
6393 for problem in &self.problems {
6394 writeln!(f, " ERROR {problem}")?;
6395 }
6396 write!(f, " verdict {}", self.verdict())
6397 }
6398}
6399
6400#[derive(Debug, Clone, Default)]
6417pub struct RecordingControls {
6418 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6419}
6420
6421#[derive(Debug, Default)]
6422struct RecordingState {
6423 registrations: BTreeMap<(StartMode, String), Registration>,
6424 definitions: BTreeMap<String, ServiceDefinition>,
6425 calls: Vec<String>,
6426 #[cfg(test)]
6427 install_failures: BTreeMap<StartMode, String>,
6428 #[cfg(test)]
6429 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
6430}
6431
6432#[cfg(test)]
6433#[derive(Debug, Clone)]
6434enum TestInstallSideEffect {
6435 HideDirectory { directory: PathBuf, hidden: PathBuf },
6436}
6437
6438impl RecordingControls {
6439 #[must_use]
6441 pub fn new() -> Self {
6442 Self::default()
6443 }
6444
6445 #[must_use]
6448 pub fn calls(&self) -> Vec<String> {
6449 self.state.lock().expect("not poisoned").calls.clone()
6450 }
6451
6452 #[must_use]
6454 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
6455 self.state
6456 .lock()
6457 .expect("not poisoned")
6458 .registrations
6459 .iter()
6460 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
6461 .collect()
6462 }
6463
6464 #[must_use]
6466 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
6467 self.state
6468 .lock()
6469 .expect("not poisoned")
6470 .definitions
6471 .get(name)
6472 .cloned()
6473 }
6474
6475 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
6485 let mut state = self.state.lock().expect("not poisoned");
6486 if let Some((_, registration)) = state
6487 .registrations
6488 .iter_mut()
6489 .find(|((_, held), _)| held == name)
6490 {
6491 edit(registration);
6492 }
6493 }
6494
6495 #[cfg(test)]
6496 fn fail_next_install(&self, mode: StartMode, detail: &str) {
6497 self.state
6498 .lock()
6499 .expect("not poisoned")
6500 .install_failures
6501 .insert(mode, detail.to_string());
6502 }
6503
6504 #[cfg(test)]
6505 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
6506 self.state
6507 .lock()
6508 .expect("not poisoned")
6509 .after_install
6510 .insert(
6511 mode,
6512 TestInstallSideEffect::HideDirectory { directory, hidden },
6513 );
6514 }
6515}
6516
6517impl ControlFactory for RecordingControls {
6518 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6519 Ok(Box::new(RecordingControl {
6520 mode,
6521 state: std::sync::Arc::clone(&self.state),
6522 }))
6523 }
6524}
6525
6526#[derive(Debug)]
6527struct RecordingControl {
6528 mode: StartMode,
6529 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6530}
6531
6532impl RecordingControl {
6533 fn note(&self, operation: &str, name: &str) {
6534 self.state
6535 .lock()
6536 .expect("not poisoned")
6537 .calls
6538 .push(format!("{operation} {name} ({})", self.mode));
6539 }
6540}
6541
6542impl ServiceControl for RecordingControl {
6543 fn manager(&self) -> DefinitionKind {
6544 host_definition_kind(self.mode)
6548 }
6549
6550 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6551 self.note("install", plan.identity().name());
6552 #[cfg(test)]
6553 if let Some(detail) = self
6554 .state
6555 .lock()
6556 .expect("not poisoned")
6557 .install_failures
6558 .remove(&self.mode)
6559 {
6560 return Err(ServiceError::Control {
6561 operation: "install",
6562 name: plan.identity().name().to_string(),
6563 manager: "recording control",
6564 detail,
6565 });
6566 }
6567 let definition = ServiceDefinition::for_host(plan)?;
6572 let mut state = self.state.lock().expect("not poisoned");
6573 state.registrations.insert(
6574 (self.mode, plan.identity().name().to_string()),
6575 Registration {
6576 manager: host_definition_kind(self.mode),
6577 start_mode: self.mode,
6578 command_line: plan.command_line(),
6579 account: Some(plan.account().as_str().to_string()),
6580 running: false,
6581 starts_automatically: true,
6582 restart_delay: Some(plan.restart().delay()),
6583 },
6584 );
6585 state
6586 .definitions
6587 .insert(plan.identity().name().to_string(), definition.clone());
6588 #[cfg(test)]
6589 let side_effect = state.after_install.remove(&self.mode);
6590 drop(state);
6591 #[cfg(test)]
6592 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
6593 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
6594 std::fs::write(&directory, b"blocks recreation")
6595 .expect("test fault can block record directory recreation");
6596 }
6597 Ok(definition)
6598 }
6599
6600 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6601 self.note("uninstall", identity.name());
6602 let mut state = self.state.lock().expect("not poisoned");
6603 state.definitions.remove(identity.name());
6604 Ok(state
6605 .registrations
6606 .remove(&(self.mode, identity.name().to_string()))
6607 .is_some())
6608 }
6609
6610 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6611 self.note("query", identity.name());
6612 Ok(self
6613 .state
6614 .lock()
6615 .expect("not poisoned")
6616 .registrations
6617 .get(&(self.mode, identity.name().to_string()))
6618 .cloned())
6619 }
6620
6621 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6622 self.note("start", identity.name());
6623 let mut state = self.state.lock().expect("not poisoned");
6624 match state
6625 .registrations
6626 .get_mut(&(self.mode, identity.name().to_string()))
6627 {
6628 Some(registration) => {
6629 registration.running = true;
6630 Ok(())
6631 }
6632 None => Err(ServiceError::NotInstalled {
6633 name: identity.name().to_string(),
6634 operation: "start",
6635 }),
6636 }
6637 }
6638
6639 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6640 self.note("stop", identity.name());
6641 let mut state = self.state.lock().expect("not poisoned");
6642 match state
6643 .registrations
6644 .get_mut(&(self.mode, identity.name().to_string()))
6645 {
6646 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
6647 None => Err(ServiceError::NotInstalled {
6648 name: identity.name().to_string(),
6649 operation: "stop",
6650 }),
6651 }
6652 }
6653}
6654
6655impl ControlFactory for HostControls {
6656 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6657 sys::control(mode)
6658 }
6659
6660 fn installed_definition(&self, path: &Path) -> Option<String> {
6661 std::fs::read_to_string(path).ok()
6662 }
6663
6664 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
6665 crate::unattended_login::probe()
6666 }
6667}
6668
6669#[must_use]
6676pub fn host_home() -> Option<PathBuf> {
6677 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
6678}
6679
6680#[cfg(unix)]
6682fn home_directory() -> Option<PathBuf> {
6683 host_home()
6684}
6685
6686fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
6695 let output = std::process::Command::new(program)
6696 .args(arguments)
6697 .output()?;
6698 Ok((
6699 output.status.success(),
6700 String::from_utf8_lossy(&output.stdout).into_owned(),
6701 String::from_utf8_lossy(&output.stderr).into_owned(),
6702 ))
6703}
6704
6705#[must_use]
6708pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
6709 if cfg!(windows) {
6710 match mode {
6711 StartMode::Boot => DefinitionKind::WindowsService,
6712 StartMode::Login => DefinitionKind::WindowsScheduledTask,
6713 }
6714 } else if cfg!(target_os = "macos") {
6715 DefinitionKind::LaunchdPlist
6716 } else {
6717 DefinitionKind::SystemdUnit
6718 }
6719}
6720
6721#[cfg(any(target_os = "macos", test))]
6728fn enable_launchd_registration(
6729 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
6730 domain: &str,
6731 service_target: &str,
6732 plist: &Path,
6733 name: &str,
6734 elevation_remedy: &'static str,
6735) -> Result<(), ServiceError> {
6736 let (enabled, cause) = launchctl(&[
6737 std::ffi::OsStr::new("enable"),
6738 std::ffi::OsStr::new(service_target),
6739 ]);
6740 if enabled {
6741 return Ok(());
6742 }
6743
6744 let (booted_out, bootout_detail) = launchctl(&[
6745 std::ffi::OsStr::new("bootout"),
6746 std::ffi::OsStr::new(service_target),
6747 ]);
6748 let removed = std::fs::remove_file(plist);
6749 if !booted_out || removed.is_err() {
6750 return Err(ServiceError::Rollback {
6751 operation: "enable launchd registration",
6752 name: name.to_string(),
6753 cause,
6754 rollback: format!(
6755 "launchctl bootout {domain}: {}; remove {}: {}",
6756 if booted_out {
6757 "succeeded".to_string()
6758 } else {
6759 bootout_detail
6760 },
6761 plist.display(),
6762 removed
6763 .err()
6764 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
6765 ),
6766 });
6767 }
6768
6769 if cause.to_ascii_lowercase().contains("permission denied") {
6770 Err(ServiceError::NeedsElevation {
6771 operation: "enable",
6772 name: name.to_string(),
6773 detail: cause,
6774 remedy: elevation_remedy,
6775 })
6776 } else {
6777 Err(ServiceError::Control {
6778 operation: "enable",
6779 name: name.to_string(),
6780 manager: "launchd",
6781 detail: cause,
6782 })
6783 }
6784}
6785
6786#[derive(Debug, Clone)]
6795pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
6796
6797impl ServiceShutdown {
6798 pub async fn wait(mut self) {
6800 if !*self.0.borrow() {
6801 let _ = self.0.changed().await;
6802 }
6803 }
6804}
6805
6806#[cfg(windows)]
6813pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
6814where
6815 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
6816{
6817 windows_host::run(Box::new(run))
6818}
6819
6820#[cfg(windows)]
6821mod windows_host {
6822 use std::ffi::OsString;
6823 use std::sync::{Arc, Mutex, OnceLock, mpsc};
6824 use std::time::Duration;
6825
6826 use windows_service::service::{
6827 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
6828 ServiceType,
6829 };
6830 use windows_service::service_control_handler::{
6831 self, ServiceControlHandlerResult, ServiceStatusHandle,
6832 };
6833
6834 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
6835
6836 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
6837
6838 struct Invocation {
6839 run: Runner,
6840 result: mpsc::SyncSender<Result<u8, String>>,
6841 }
6842
6843 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
6844
6845 windows_service::define_windows_service!(ffi_service_main, service_main);
6846
6847 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
6848 let (result_tx, result_rx) = mpsc::sync_channel(1);
6849 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
6850 let mut invocation = slot
6851 .lock()
6852 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
6853 if invocation.is_some() {
6854 return Err(host_error(
6855 "prepare",
6856 "the service-host slot was already used",
6857 ));
6858 }
6859 *invocation = Some(Invocation {
6860 run,
6861 result: result_tx,
6862 });
6863 drop(invocation);
6864
6865 windows_service::service_dispatcher::start("", ffi_service_main)
6866 .map_err(|error| host_error("connect", &error.to_string()))?;
6867 result_rx
6868 .recv()
6869 .map_err(|error| host_error("finish", &error.to_string()))?
6870 .map_err(|detail| host_error("run", &detail))
6871 }
6872
6873 fn service_main(_arguments: Vec<OsString>) {
6874 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
6875 return;
6876 };
6877 let result = run_service(invocation.run);
6878 let _ = invocation.result.send(result);
6879 }
6880
6881 fn run_service(run: Runner) -> Result<u8, String> {
6882 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
6883 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
6884 let handler_status = Arc::clone(&status);
6885 let handler = move |control| match control {
6886 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
6887 ServiceControl::Stop | ServiceControl::Shutdown => {
6888 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
6889 let _ = handle.set_service_status(service_status(
6890 ServiceState::StopPending,
6891 ServiceControlAccept::empty(),
6892 1,
6893 Duration::from_secs(300),
6894 0,
6895 ));
6896 }
6897 let _ = stop_tx.send(true);
6898 ServiceControlHandlerResult::NoError
6899 }
6900 _ => ServiceControlHandlerResult::NotImplemented,
6901 };
6902 let handle = service_control_handler::register("", handler)
6903 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
6904 *status
6905 .lock()
6906 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
6907 handle
6908 .set_service_status(service_status(
6909 ServiceState::Running,
6910 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
6911 0,
6912 Duration::default(),
6913 0,
6914 ))
6915 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
6916
6917 let exit = run(ServiceShutdown(stop_rx));
6918 handle
6919 .set_service_status(service_status(
6920 ServiceState::Stopped,
6921 ServiceControlAccept::empty(),
6922 0,
6923 Duration::default(),
6924 u32::from(exit),
6925 ))
6926 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
6927 Ok(exit)
6928 }
6929
6930 fn service_status(
6931 state: ServiceState,
6932 accepted: ServiceControlAccept,
6933 checkpoint: u32,
6934 wait_hint: Duration,
6935 exit: u32,
6936 ) -> ServiceStatus {
6937 ServiceStatus {
6938 service_type: ServiceType::OWN_PROCESS,
6939 current_state: state,
6940 controls_accepted: accepted,
6941 exit_code: if exit == 0 {
6942 ServiceExitCode::Win32(0)
6943 } else {
6944 ServiceExitCode::ServiceSpecific(exit)
6945 },
6946 checkpoint,
6947 wait_hint,
6948 process_id: None,
6949 }
6950 }
6951
6952 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
6953 ServiceError::Control {
6954 operation,
6955 name: SERVICE_NAME.to_string(),
6956 manager: "the Windows Service Control Manager",
6957 detail: detail.to_string(),
6958 }
6959 }
6960}
6961
6962#[cfg(windows)]
6963mod sys {
6964 use std::ffi::{OsStr, OsString};
6974 use std::time::{Duration, Instant};
6975
6976 use runner_manager_domain::model::StartMode;
6977 use windows_service::service::{
6978 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
6979 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
6980 ServiceState, ServiceType,
6981 };
6982 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
6983
6984 use super::{
6985 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
6986 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
6987 };
6988
6989 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
6992 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
6996 const ACCESS_DENIED: i32 = 5;
6998 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
6999 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7000
7001 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7002 choose \"Run as administrator\".";
7003
7004 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7005 Ok(match mode {
7006 StartMode::Boot => Box::new(ScmControl),
7007 StartMode::Login => Box::new(TaskControl),
7008 })
7009 }
7010
7011 #[derive(Debug)]
7014 struct ScmControl;
7015
7016 fn scm_error(
7020 operation: &'static str,
7021 name: &str,
7022 error: &windows_service::Error,
7023 ) -> ServiceError {
7024 let raw = match error {
7025 windows_service::Error::Winapi(io) => io.raw_os_error(),
7026 _ => None,
7027 };
7028 let detail = match error {
7029 windows_service::Error::Winapi(io) => io.to_string(),
7030 other => other.to_string(),
7031 };
7032 if raw == Some(ACCESS_DENIED) {
7033 return ServiceError::NeedsElevation {
7034 operation,
7035 name: name.to_string(),
7036 detail,
7037 remedy: ELEVATION_REMEDY,
7038 };
7039 }
7040 ServiceError::Control {
7041 operation,
7042 name: name.to_string(),
7043 manager: "the Windows Service Control Manager",
7044 detail,
7045 }
7046 }
7047
7048 fn open_manager(
7049 access: ServiceManagerAccess,
7050 operation: &'static str,
7051 name: &str,
7052 ) -> Result<ServiceManager, ServiceError> {
7053 ServiceManager::local_computer(None::<&OsStr>, access)
7054 .map_err(|error| scm_error(operation, name, &error))
7055 }
7056
7057 impl ServiceControl for ScmControl {
7058 fn manager(&self) -> DefinitionKind {
7059 DefinitionKind::WindowsService
7060 }
7061
7062 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7063 let spec = windows_service_spec(plan);
7064 let manager = open_manager(
7065 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7066 "install",
7067 &spec.name,
7068 )?;
7069 let info = ServiceInfo {
7070 name: OsString::from(&spec.name),
7071 display_name: OsString::from(&spec.display_name),
7072 service_type: ServiceType::OWN_PROCESS,
7074 start_type: if spec.automatic_start {
7075 ServiceStartType::AutoStart
7076 } else {
7077 ServiceStartType::OnDemand
7078 },
7079 error_control: ServiceErrorControl::Normal,
7080 executable_path: plan.binary().to_path_buf(),
7081 launch_arguments: plan.arguments().to_vec(),
7082 dependencies: Vec::new(),
7083 account_name: spec.account.as_ref().map(OsString::from),
7085 account_password: None,
7088 };
7089 let service = manager
7090 .create_service(
7091 &info,
7092 ServiceAccess::CHANGE_CONFIG
7093 | ServiceAccess::QUERY_CONFIG
7094 | ServiceAccess::QUERY_STATUS
7095 | ServiceAccess::START
7096 | ServiceAccess::STOP
7097 | ServiceAccess::DELETE,
7098 )
7099 .map_err(|error| scm_error("install", &spec.name, &error))?;
7100 service
7101 .set_description(&spec.description)
7102 .map_err(|error| scm_error("describe", &spec.name, &error))?;
7103 service
7104 .update_failure_actions(ServiceFailureActions {
7105 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7106 reboot_msg: None,
7107 command: None,
7108 actions: Some(vec![
7115 ServiceAction {
7116 action_type: ServiceActionType::Restart,
7117 delay: spec.restart.delay(),
7118 };
7119 3
7120 ]),
7121 })
7122 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7123 service
7126 .set_failure_actions_on_non_crash_failures(true)
7127 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7128 Ok(ServiceDefinition::windows_service(plan))
7129 }
7130
7131 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7132 let manager =
7133 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7134 let service = match manager.open_service(
7135 identity.name(),
7136 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7137 ) {
7138 Ok(service) => Some(service),
7139 Err(error) if is_missing(&error) => return Ok(false),
7140 Err(error) if is_marked_for_delete(&error) => None,
7145 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7146 };
7147 if let Some(service) = service {
7148 if let Ok(status) = service.query_status()
7152 && status.current_state != ServiceState::Stopped
7153 {
7154 let _ = service.stop();
7155 }
7156 match service.delete() {
7157 Ok(()) => {}
7158 Err(error) if is_marked_for_delete(&error) => {}
7162 Err(error) => {
7163 return Err(scm_error("uninstall", identity.name(), &error));
7164 }
7165 }
7166 drop(service);
7167 }
7168
7169 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7176 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7177 Ok(service) => {
7178 drop(service);
7179 Ok(false)
7180 }
7181 Err(error) if is_missing(&error) => Ok(true),
7182 Err(error) if is_marked_for_delete(&error) => Ok(false),
7183 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7184 }
7185 })?;
7186 if !absent {
7187 return Err(ServiceError::Control {
7188 operation: "verify uninstall of",
7189 name: identity.name().to_string(),
7190 manager: "the Windows Service Control Manager",
7191 detail: format!(
7192 "the registration was still visible {} seconds after DeleteService; \
7193 retry `service uninstall` from an elevated prompt",
7194 DELETE_TIMEOUT.as_secs()
7195 ),
7196 });
7197 }
7198 Ok(true)
7199 }
7200
7201 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7202 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7203 let service = match manager.open_service(
7204 identity.name(),
7205 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7206 ) {
7207 Ok(service) => service,
7208 Err(error) if is_missing(&error) => return Ok(None),
7209 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7210 };
7211 let config = service
7212 .query_config()
7213 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7214 let status = service
7215 .query_status()
7216 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7217 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7218 actions
7219 .actions
7220 .and_then(|actions| actions.into_iter().next())
7221 .filter(|action| action.action_type == ServiceActionType::Restart)
7222 .map(|action| action.delay)
7223 });
7224 Ok(Some(Registration {
7225 manager: DefinitionKind::WindowsService,
7226 start_mode: StartMode::Boot,
7227 command_line: config.executable_path.to_string_lossy().into_owned(),
7231 account: config
7232 .account_name
7233 .map(|account| account.to_string_lossy().into_owned()),
7234 running: status.current_state == ServiceState::Running,
7235 starts_automatically: config.start_type == ServiceStartType::AutoStart,
7236 restart_delay,
7237 }))
7238 }
7239
7240 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7241 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7242 let service = manager
7243 .open_service(identity.name(), ServiceAccess::START)
7244 .map_err(|error| scm_error("start", identity.name(), &error))?;
7245 service
7246 .start::<&OsStr>(&[])
7247 .map_err(|error| scm_error("start", identity.name(), &error))
7248 }
7249
7250 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7251 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7252 let service = manager
7253 .open_service(
7254 identity.name(),
7255 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7256 )
7257 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7258 let status = service
7259 .query_status()
7260 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7261 if status.current_state == ServiceState::Stopped {
7262 return Ok(false);
7263 }
7264 service
7265 .stop()
7266 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7267 Ok(true)
7268 }
7269 }
7270
7271 fn is_missing(error: &windows_service::Error) -> bool {
7272 matches!(error, windows_service::Error::Winapi(io)
7273 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7274 }
7275
7276 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7277 matches!(error, windows_service::Error::Winapi(io)
7278 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7279 }
7280
7281 pub(super) fn wait_until_scm_absent(
7282 timeout: Duration,
7283 poll_interval: Duration,
7284 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7285 ) -> Result<bool, ServiceError> {
7286 let deadline = Instant::now() + timeout;
7287 loop {
7288 if probe_absent()? {
7289 return Ok(true);
7290 }
7291 if Instant::now() >= deadline {
7292 return Ok(false);
7293 }
7294 std::thread::sleep(poll_interval);
7295 }
7296 }
7297
7298 #[derive(Debug)]
7301 struct TaskControl;
7302
7303 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7304 if detail.to_ascii_lowercase().contains("access is denied") {
7305 return ServiceError::NeedsElevation {
7306 operation,
7307 name: name.to_string(),
7308 detail,
7309 remedy: ELEVATION_REMEDY,
7310 };
7311 }
7312 ServiceError::Control {
7313 operation,
7314 name: name.to_string(),
7315 manager: "Windows Task Scheduler",
7316 detail,
7317 }
7318 }
7319
7320 fn schtasks(
7321 operation: &'static str,
7322 name: &str,
7323 arguments: &[&OsStr],
7324 ) -> Result<(bool, String), ServiceError> {
7325 match run("schtasks.exe", arguments) {
7326 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7327 Err(error) => Err(task_error(operation, name, error.to_string())),
7328 }
7329 }
7330
7331 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7334 let mut bytes = vec![0xFF, 0xFE];
7335 for unit in text.encode_utf16() {
7336 bytes.extend_from_slice(&unit.to_le_bytes());
7337 }
7338 std::fs::write(path, bytes)
7339 }
7340
7341 impl ServiceControl for TaskControl {
7342 fn manager(&self) -> DefinitionKind {
7343 DefinitionKind::WindowsScheduledTask
7344 }
7345
7346 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7347 let name = plan.identity().name().to_string();
7348 let principal = TaskPrincipal::current()?;
7349 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7350 let directory = tempfile::tempdir()
7351 .map_err(|error| task_error("install", &name, error.to_string()))?;
7352 let document = directory.path().join("task.xml");
7353 write_utf16(&document, definition.text())
7354 .map_err(|error| task_error("install", &name, error.to_string()))?;
7355 let (ok, message) = schtasks(
7356 "install",
7357 &name,
7358 &[
7359 OsStr::new("/Create"),
7360 OsStr::new("/TN"),
7361 OsStr::new(&name),
7362 OsStr::new("/XML"),
7363 document.as_os_str(),
7364 ],
7365 )?;
7366 if !ok {
7367 return Err(task_error("install", &name, message));
7368 }
7369 Ok(definition)
7370 }
7371
7372 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7373 if self.query(identity)?.is_none() {
7374 return Ok(false);
7375 }
7376 let name = identity.name().to_string();
7377 let (ok, message) = schtasks(
7378 "uninstall",
7379 &name,
7380 &[
7381 OsStr::new("/Delete"),
7382 OsStr::new("/TN"),
7383 OsStr::new(&name),
7384 OsStr::new("/F"),
7385 ],
7386 )?;
7387 if !ok {
7388 return Err(task_error("uninstall", &name, message));
7389 }
7390 Ok(true)
7391 }
7392
7393 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7394 let name = identity.name().to_string();
7395 let (ok, document) = schtasks(
7396 "inspect",
7397 &name,
7398 &[
7399 OsStr::new("/Query"),
7400 OsStr::new("/TN"),
7401 OsStr::new(&name),
7402 OsStr::new("/XML"),
7403 OsStr::new("ONE"),
7404 ],
7405 )?;
7406 if !ok {
7407 return Ok(None);
7413 }
7414 let command = xml_value(&document, "Command").unwrap_or_default();
7415 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
7416 let command_line = if arguments.is_empty() {
7417 super::quote_argument(&command)
7418 } else {
7419 format!("{} {arguments}", super::quote_argument(&command))
7420 };
7421 Ok(Some(Registration {
7422 manager: DefinitionKind::WindowsScheduledTask,
7423 start_mode: StartMode::Login,
7424 command_line,
7425 account: xml_value(&document, "UserId"),
7426 running: task_is_running(&name),
7427 starts_automatically: super::windows_login_task_starts_automatically(&document),
7428 restart_delay: xml_value(&document, "Interval")
7429 .as_deref()
7430 .and_then(parse_iso8601),
7431 }))
7432 }
7433
7434 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7435 let name = identity.name().to_string();
7436 let (ok, message) = schtasks(
7437 "start",
7438 &name,
7439 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
7440 )?;
7441 if ok {
7442 Ok(())
7443 } else {
7444 Err(task_error("start", &name, message))
7445 }
7446 }
7447
7448 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7449 let running = self
7450 .query(identity)?
7451 .is_some_and(|registration| registration.running);
7452 if !running {
7453 return Ok(false);
7454 }
7455 let name = identity.name().to_string();
7456 let (ok, message) = schtasks(
7457 "stop",
7458 &name,
7459 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
7460 )?;
7461 if ok {
7462 Ok(true)
7463 } else {
7464 Err(task_error("stop", &name, message))
7465 }
7466 }
7467 }
7468
7469 fn task_is_running(name: &str) -> bool {
7483 let Ok((true, stdout, _)) = run(
7484 "schtasks.exe",
7485 &[
7486 OsStr::new("/Query"),
7487 OsStr::new("/TN"),
7488 OsStr::new(name),
7489 OsStr::new("/FO"),
7490 OsStr::new("CSV"),
7491 OsStr::new("/NH"),
7492 ],
7493 ) else {
7494 return false;
7495 };
7496 stdout
7497 .lines()
7498 .filter_map(|line| line.rsplit(',').next())
7499 .any(|status| {
7500 status
7501 .trim()
7502 .trim_matches('"')
7503 .eq_ignore_ascii_case("running")
7504 })
7505 }
7506
7507 fn parse_iso8601(value: &str) -> Option<Duration> {
7511 let rest = value.strip_prefix("PT")?;
7512 if let Some(minutes) = rest.strip_suffix('M') {
7513 return minutes
7514 .parse::<u64>()
7515 .ok()
7516 .map(|minutes| Duration::from_secs(minutes * 60));
7517 }
7518 rest.strip_suffix('S')?
7519 .parse::<u64>()
7520 .ok()
7521 .map(Duration::from_secs)
7522 }
7523}
7524
7525#[cfg(unix)]
7537fn write_definition(
7538 operation: &'static str,
7539 name: &str,
7540 path: &Path,
7541 text: &str,
7542 remedy: &'static str,
7543) -> Result<(), ServiceError> {
7544 if let Some(parent) = path.parent()
7545 && let Err(error) = std::fs::create_dir_all(parent)
7546 && error.kind() != std::io::ErrorKind::AlreadyExists
7547 {
7548 return Err(definition_error(operation, name, error, remedy, parent));
7549 }
7550 std::fs::write(path, text)
7551 .map_err(|error| definition_error(operation, name, error, remedy, path))
7552}
7553
7554#[cfg(all(unix, any(target_os = "macos", test)))]
7561fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
7562 use std::io::Write as _;
7563
7564 let path = drift.path.as_path();
7565 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
7566 let failed = |error: std::io::Error, at: &Path| {
7567 definition_error("rewrite", name, error, SUDO_REMEDY, at)
7568 };
7569 let mut staged =
7570 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
7571 staged
7572 .write_all(drift.rendered.as_bytes())
7573 .and_then(|()| staged.as_file().sync_all())
7574 .map_err(|error| failed(error, staged.path()))?;
7575 std::fs::set_permissions(
7576 staged.path(),
7577 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
7578 )
7579 .map_err(|error| failed(error, path))?;
7580 staged
7581 .persist(path)
7582 .map(drop)
7583 .map_err(|error| failed(error.error, path))
7584}
7585
7586#[cfg(unix)]
7587fn definition_error(
7588 operation: &'static str,
7589 name: &str,
7590 error: std::io::Error,
7591 remedy: &'static str,
7592 path: &Path,
7593) -> ServiceError {
7594 let detail = format!("{}: {error}", path.display());
7595 if error.kind() == std::io::ErrorKind::PermissionDenied {
7596 ServiceError::NeedsElevation {
7597 operation,
7598 name: name.to_string(),
7599 detail,
7600 remedy,
7601 }
7602 } else {
7603 ServiceError::Control {
7604 operation,
7605 name: name.to_string(),
7606 manager: "the local service manager",
7607 detail,
7608 }
7609 }
7610}
7611
7612#[cfg(unix)]
7614const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
7615 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
7616 agent not running until you sign in.";
7617
7618#[cfg(target_os = "macos")]
7623mod sys {
7624 use std::ffi::OsStr;
7637 use std::path::PathBuf;
7638 use std::time::Duration;
7639
7640 use runner_manager_domain::model::StartMode;
7641
7642 use super::{
7643 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
7644 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
7645 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
7646 write_definition, xml_value,
7647 };
7648
7649 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7650 Ok(Box::new(LaunchdControl { mode }))
7651 }
7652
7653 #[derive(Debug)]
7654 struct LaunchdControl {
7655 mode: StartMode,
7656 }
7657
7658 impl LaunchdControl {
7659 fn domain(&self) -> String {
7661 match self.mode {
7662 StartMode::Boot => "system".to_string(),
7663 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
7666 }
7667 }
7668
7669 fn service_target(&self, identity: &ServiceIdentity) -> String {
7670 format!("{}/{}", self.domain(), identity.launchd_label())
7671 }
7672
7673 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
7676 let file = format!("{}.plist", identity.launchd_label());
7677 match self.mode {
7678 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
7679 StartMode::Login => {
7680 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
7681 }
7682 }
7683 }
7684
7685 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
7686 ServiceError::Control {
7687 operation,
7688 name: name.to_string(),
7689 manager: "launchd",
7690 detail,
7691 }
7692 }
7693
7694 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
7695 match run("launchctl", arguments) {
7696 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
7697 Err(error) => (false, error.to_string()),
7698 }
7699 }
7700 }
7701
7702 impl ServiceControl for LaunchdControl {
7703 fn manager(&self) -> DefinitionKind {
7704 DefinitionKind::LaunchdPlist
7705 }
7706
7707 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7708 let name = plan.identity().name().to_string();
7709 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
7710 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
7711 return Err(self.failed(
7712 "install",
7713 &name,
7714 "this account has no home directory, so there is nowhere to put a \
7715 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
7716 /Library/LaunchDaemons."
7717 .to_string(),
7718 ));
7719 };
7720 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
7721 let target = self.domain();
7722 let (ok, message) = self.launchctl(&[
7723 OsStr::new("bootstrap"),
7724 OsStr::new(&target),
7725 path.as_os_str(),
7726 ]);
7727 if !ok {
7728 let _ = std::fs::remove_file(&path);
7732 if message.to_ascii_lowercase().contains("permission denied") {
7733 return Err(ServiceError::NeedsElevation {
7734 operation: "install",
7735 name,
7736 detail: message,
7737 remedy: SUDO_REMEDY,
7738 });
7739 }
7740 return Err(self.failed("install", &name, message));
7741 }
7742 let service_target = self.service_target(plan.identity());
7745 enable_launchd_registration(
7746 |arguments| self.launchctl(arguments),
7747 &target,
7748 &service_target,
7749 &path,
7750 &name,
7751 SUDO_REMEDY,
7752 )?;
7753 Ok(definition)
7754 }
7755
7756 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7757 let Some(path) = self.plist_path(identity) else {
7758 return Ok(false);
7759 };
7760 if !path.exists() {
7761 return Ok(false);
7762 }
7763 let target = self.service_target(identity);
7764 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
7765 if !ok
7766 && !message.to_ascii_lowercase().contains("no such process")
7767 && !message.contains("113")
7768 {
7769 if message.to_ascii_lowercase().contains("permission denied") {
7770 return Err(ServiceError::NeedsElevation {
7771 operation: "uninstall",
7772 name: identity.name().to_string(),
7773 detail: message,
7774 remedy: SUDO_REMEDY,
7775 });
7776 }
7777 return Err(self.failed("uninstall", identity.name(), message));
7778 }
7779 std::fs::remove_file(&path).map_err(|error| {
7782 super::definition_error(
7783 "uninstall",
7784 identity.name(),
7785 error,
7786 SUDO_REMEDY,
7787 path.as_path(),
7788 )
7789 })?;
7790 Ok(true)
7791 }
7792
7793 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7794 let Some(path) = self.plist_path(identity) else {
7795 return Ok(None);
7796 };
7797 let Ok(document) = std::fs::read_to_string(&path) else {
7798 return Ok(None);
7799 };
7800 let target = self.service_target(identity);
7801 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
7802 Ok(Some(Registration {
7803 manager: DefinitionKind::LaunchdPlist,
7804 start_mode: self.mode,
7805 command_line: program_arguments(&document),
7806 account: plist_string_value(&document, "UserName")
7807 .or_else(|| Some("the invoking user".to_string())),
7808 running: loaded && printed.contains("state = running"),
7809 starts_automatically: document.contains("<key>RunAtLoad</key>")
7810 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
7811 restart_delay: xml_value(
7812 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
7813 "integer",
7814 )
7815 .and_then(|value| value.parse::<u64>().ok())
7816 .map(Duration::from_secs),
7817 }))
7818 }
7819
7820 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7821 let target = self.service_target(identity);
7822 let (ok, message) = self.launchctl(&[
7823 OsStr::new("kickstart"),
7824 OsStr::new("-k"),
7825 OsStr::new(&target),
7826 ]);
7827 if ok {
7828 Ok(())
7829 } else {
7830 Err(self.failed("start", identity.name(), message))
7831 }
7832 }
7833
7834 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7835 let running = self
7836 .query(identity)?
7837 .is_some_and(|registration| registration.running);
7838 if !running {
7839 return Ok(false);
7840 }
7841 let target = self.service_target(identity);
7842 let (ok, message) = self.launchctl(&[
7843 OsStr::new("kill"),
7844 OsStr::new("SIGTERM"),
7845 OsStr::new(&target),
7846 ]);
7847 if ok {
7848 Ok(true)
7849 } else {
7850 Err(self.failed("stop", identity.name(), message))
7851 }
7852 }
7853 }
7854
7855 pub(super) fn reload_detached(
7870 identity: &ServiceIdentity,
7871 mode: StartMode,
7872 plist: &std::path::Path,
7873 ) -> Result<(), ServiceError> {
7874 use std::os::unix::process::CommandExt;
7875
7876 const SCRIPT: &str = r#"(
7877 sleep 1
7878 /bin/launchctl bootout "$1"
7879 tries=0
7880 until /bin/launchctl bootstrap "$2" "$3"; do
7881 tries=$((tries + 1))
7882 if [ "$tries" -ge 60 ]; then
7883 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
7884 exit 1
7885 fi
7886 sleep 1
7887 done
7888) </dev/null &"#;
7889
7890 let control = LaunchdControl { mode };
7891 let reinstall = match mode {
7894 StartMode::Boot => "sudo runner-manager service install --start-at boot",
7895 StartMode::Login => "runner-manager service install --start-at login",
7896 };
7897 let mut command = std::process::Command::new("/bin/sh");
7898 command
7899 .args(["-c", SCRIPT, "runner-manager-reload"])
7900 .arg(control.service_target(identity))
7901 .arg(control.domain())
7902 .arg(plist)
7903 .arg(reinstall)
7904 .stdin(std::process::Stdio::null());
7905 unsafe {
7908 command.pre_exec(|| {
7909 if libc::setsid() == -1 {
7910 return Err(std::io::Error::last_os_error());
7911 }
7912 Ok(())
7913 });
7914 }
7915 let status = command.status().map_err(|error| {
7916 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
7917 })?;
7918 if status.success() {
7919 Ok(())
7920 } else {
7921 Err(control.failed(
7922 "reload",
7923 identity.name(),
7924 format!("the reload helper exited with {status}"),
7925 ))
7926 }
7927 }
7928
7929 fn program_arguments(document: &str) -> String {
7931 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
7932 return String::new();
7933 };
7934 let Some(end) = rest.find("</array>") else {
7935 return String::new();
7936 };
7937 let mut out = Vec::new();
7938 let mut cursor = &rest[..end];
7939 while let Some(open) = cursor.find("<string>") {
7940 let after = &cursor[open + "<string>".len()..];
7941 let Some(close) = after.find("</string>") else {
7942 break;
7943 };
7944 out.push(quote_argument(&super::xml_unescape(&after[..close])));
7945 cursor = &after[close..];
7946 }
7947 out.join(" ")
7948 }
7949}
7950
7951#[cfg(all(unix, not(target_os = "macos")))]
7956mod sys {
7957 use std::ffi::OsStr;
7967 use std::path::PathBuf;
7968 use std::time::Duration;
7969
7970 use runner_manager_domain::model::StartMode;
7971
7972 use super::{
7973 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
7974 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
7975 home_directory, ini_directives, run, write_definition,
7976 };
7977
7978 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7979 Ok(Box::new(SystemdControl { mode }))
7980 }
7981
7982 #[derive(Debug)]
7983 struct SystemdControl {
7984 mode: StartMode,
7985 }
7986
7987 impl SystemdControl {
7988 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
7989 let file = identity.systemd_unit();
7990 match self.mode {
7991 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
7992 StartMode::Login => {
7993 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
7994 }
7995 }
7996 }
7997
7998 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8000 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8001 if self.mode == StartMode::Login {
8002 all.push(OsStr::new("--user"));
8003 }
8004 all.extend(arguments.iter().map(OsStr::new));
8005 match run("systemctl", &all) {
8006 Ok((ok, stdout, stderr)) => (
8007 ok,
8008 if stdout.trim().is_empty() {
8009 stderr
8010 } else {
8011 stdout
8012 },
8013 ),
8014 Err(error) => (false, error.to_string()),
8015 }
8016 }
8017
8018 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8019 ServiceError::Control {
8020 operation,
8021 name: name.to_string(),
8022 manager: "systemd",
8023 detail,
8024 }
8025 }
8026 }
8027
8028 impl ServiceControl for SystemdControl {
8029 fn manager(&self) -> DefinitionKind {
8030 DefinitionKind::SystemdUnit
8031 }
8032
8033 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8034 let name = plan.identity().name().to_string();
8035 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8036 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8037 return Err(self.failed(
8038 "install",
8039 &name,
8040 "this account has no home directory, so there is nowhere to put a systemd \
8041 user unit. Use --start-at boot, which installs a system unit under \
8042 /etc/systemd/system."
8043 .to_string(),
8044 ));
8045 };
8046 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8047 let unit = plan.identity().systemd_unit();
8048 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8049 if !reloaded {
8050 let _ = std::fs::remove_file(&path);
8051 return Err(self.failed("install", &name, message));
8052 }
8053 let (enabled, message) = self.systemctl(&["enable", &unit]);
8054 if !enabled {
8055 let _ = std::fs::remove_file(&path);
8056 let _ = self.systemctl(&["daemon-reload"]);
8057 return Err(self.failed("install", &name, message));
8058 }
8059 Ok(definition)
8060 }
8061
8062 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8063 let Some(path) = self.unit_path(identity) else {
8064 return Ok(false);
8065 };
8066 if !path.exists() {
8067 return Ok(false);
8068 }
8069 let unit = identity.systemd_unit();
8070 let _ = self.systemctl(&["disable", "--now", &unit]);
8075 std::fs::remove_file(&path).map_err(|error| {
8076 super::definition_error(
8077 "uninstall",
8078 identity.name(),
8079 error,
8080 SUDO_REMEDY,
8081 path.as_path(),
8082 )
8083 })?;
8084 let _ = self.systemctl(&["daemon-reload"]);
8085 Ok(true)
8086 }
8087
8088 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8089 let Some(path) = self.unit_path(identity) else {
8090 return Ok(None);
8091 };
8092 let Ok(document) = std::fs::read_to_string(&path) else {
8093 return Ok(None);
8094 };
8095 let unit = identity.systemd_unit();
8096 let directives = ini_directives(&document, "Service");
8097 let (_, active) = self.systemctl(&["is-active", &unit]);
8098 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
8099 Ok(Some(Registration {
8100 manager: DefinitionKind::SystemdUnit,
8101 start_mode: self.mode,
8102 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
8103 account: directives.get("User").cloned().or_else(|| {
8104 Some(match self.mode {
8105 StartMode::Boot => "root".to_string(),
8106 StartMode::Login => "the invoking user".to_string(),
8107 })
8108 }),
8109 running: active.trim() == "active",
8110 starts_automatically: enabled.trim() == "enabled",
8111 restart_delay: directives
8112 .get("RestartSec")
8113 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
8114 .map(Duration::from_secs),
8115 }))
8116 }
8117
8118 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8119 let unit = identity.systemd_unit();
8120 let (ok, message) = self.systemctl(&["start", &unit]);
8121 if ok {
8122 Ok(())
8123 } else {
8124 Err(self.failed("start", identity.name(), message))
8125 }
8126 }
8127
8128 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8129 let running = self
8130 .query(identity)?
8131 .is_some_and(|registration| registration.running);
8132 if !running {
8133 return Ok(false);
8134 }
8135 let unit = identity.systemd_unit();
8136 let (ok, message) = self.systemctl(&["stop", &unit]);
8137 if ok {
8138 Ok(true)
8139 } else {
8140 Err(self.failed("stop", identity.name(), message))
8141 }
8142 }
8143 }
8144}
8145
8146#[cfg(all(test, target_os = "macos"))]
8148pub(crate) mod launchd_fixture {
8149 use std::path::{Path, PathBuf};
8150
8151 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8155
8156 pub(crate) fn enabled() -> bool {
8159 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8160 if !enabled {
8161 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8162 }
8163 enabled
8164 }
8165
8166 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8174 let binary = directory.join("launch-agent-job");
8175 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8176 .expect("test binary copied");
8177 binary
8178 }
8179
8180 pub(crate) struct LoadedAgent(String);
8182
8183 impl LoadedAgent {
8184 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8187 let domain = format!("gui/{}", unsafe { libc::getuid() });
8190 let loaded = Self(format!("{domain}/{label}"));
8192 let bootstrap = std::process::Command::new("launchctl")
8193 .arg("bootstrap")
8194 .arg(&domain)
8195 .arg(plist)
8196 .output()
8197 .expect("launchctl runs");
8198 assert!(
8199 bootstrap.status.success(),
8200 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8201 String::from_utf8_lossy(&bootstrap.stderr)
8202 );
8203 loaded
8204 }
8205
8206 pub(crate) fn target(&self) -> &str {
8208 &self.0
8209 }
8210 }
8211
8212 impl Drop for LoadedAgent {
8213 fn drop(&mut self) {
8214 let _ = std::process::Command::new("launchctl")
8215 .args(["bootout", &self.0])
8216 .output();
8217 }
8218 }
8219}
8220
8221#[cfg(test)]
8222mod tests {
8223 use super::*;
8224
8225 use std::collections::BTreeMap;
8226
8227 #[test]
8228 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8229 assert!(windows_login_task_starts_automatically(
8230 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8231 ));
8232 assert!(windows_login_task_starts_automatically(
8233 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8234 ));
8235 assert!(!windows_login_task_starts_automatically(
8236 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8237 ));
8238 assert!(!windows_login_task_starts_automatically(
8239 "<Task><BootTrigger></BootTrigger></Task>"
8240 ));
8241 }
8242
8243 fn linux_plan(mode: StartMode) -> InstallPlan {
8250 InstallPlan::unchecked(
8251 ServiceIdentity::product(),
8252 mode,
8253 "/opt/runner-manager/bin/runner-manager",
8254 ServiceDirectories {
8255 config: PathBuf::from("/var/lib/runner-manager/config"),
8256 state: PathBuf::from("/var/lib/runner-manager/state"),
8257 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8258 logs: PathBuf::from("/var/log/runner-manager"),
8259 },
8260 )
8261 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8262 }
8263
8264 fn windows_plan(mode: StartMode) -> InstallPlan {
8265 InstallPlan::unchecked(
8266 ServiceIdentity::product(),
8267 mode,
8268 "C:\\Program Files\\runner-manager\\runner-manager.exe",
8269 ServiceDirectories {
8270 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8271 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8272 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8273 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8274 },
8275 )
8276 }
8277
8278 fn edited(text: &str, from: &str, to: &str) -> String {
8286 assert!(
8287 text.contains(from),
8288 "the rendered definition does not contain `{from}`, so this test would assert \
8289 nothing about a widened one"
8290 );
8291 text.replace(from, to)
8292 }
8293
8294 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8296 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8297 let Ok(entries) = std::fs::read_dir(directory) else {
8298 return;
8299 };
8300 for entry in entries.flatten() {
8301 let path = entry.path();
8302 if path.is_dir() {
8303 walk(&path, out);
8304 } else if let Ok(bytes) = std::fs::read(&path) {
8305 out.insert(path, bytes);
8306 }
8307 }
8308 }
8309 let mut out = BTreeMap::new();
8310 for root in roots {
8311 walk(root, &mut out);
8312 }
8313 out
8314 }
8315
8316 struct Host {
8317 _root: tempfile::TempDir,
8318 paths: AppPaths,
8319 binary: PathBuf,
8320 runner_root: LocalAbsolutePath,
8329 controls: RecordingControls,
8330 }
8331
8332 impl Host {
8333 fn new() -> Self {
8334 let root = tempfile::tempdir().expect("a temporary directory");
8335 let paths = AppPaths::rooted_at(root.path());
8336 paths.create_all().expect("the four directories");
8337 let binary = root.path().join(if cfg!(windows) {
8338 "runner-manager.exe"
8339 } else {
8340 "runner-manager"
8341 });
8342 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
8343 let runner_root = LocalAbsolutePath::new(
8344 root.path()
8345 .join("runner-root")
8346 .to_str()
8347 .expect("a unicode temporary path"),
8348 )
8349 .expect("a local absolute path");
8350 Self {
8351 _root: root,
8352 paths,
8353 binary,
8354 runner_root,
8355 controls: RecordingControls::new(),
8356 }
8357 }
8358
8359 fn operations(&self) -> ServiceOperations {
8360 ServiceOperations::with_controls(
8361 self.paths.clone(),
8362 ServiceIdentity::product(),
8363 std::sync::Arc::new(self.controls.clone()),
8364 )
8365 .with_runner_root(self.runner_root.clone())
8366 }
8367
8368 fn request(&self, mode: StartMode) -> InstallRequest {
8369 InstallRequest::new(mode).for_binary(&self.binary)
8370 }
8371 }
8372
8373 #[cfg(windows)]
8374 #[test]
8375 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
8376 let probes = std::cell::Cell::new(0);
8377 let absent =
8378 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
8379 let next = probes.get() + 1;
8380 probes.set(next);
8381 Ok(next == 3)
8382 })
8383 .expect("the simulated SCM probe succeeds");
8384
8385 assert!(absent);
8386 assert_eq!(
8387 probes.get(),
8388 3,
8389 "uninstall must recheck after transient presence instead of treating it as a leak"
8390 );
8391 }
8392
8393 #[cfg(windows)]
8394 #[test]
8395 fn windows_recognises_an_already_pending_service_deletion() {
8396 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
8397
8398 assert!(super::sys::is_marked_for_delete(&error));
8399 }
8400
8401 #[test]
8402 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
8403 let root = tempfile::tempdir().expect("a temporary directory");
8404 let plist = root.path().join("fixture.plist");
8405 std::fs::write(&plist, b"fixture").expect("a plist fixture");
8406 let calls = std::cell::RefCell::new(Vec::new());
8407
8408 let error = enable_launchd_registration(
8409 |arguments| {
8410 let call = arguments
8411 .iter()
8412 .map(|argument| argument.to_string_lossy().into_owned())
8413 .collect::<Vec<_>>();
8414 let operation = call[0].clone();
8415 calls.borrow_mut().push(call);
8416 if operation == "enable" {
8417 (false, "label remains disabled".to_string())
8418 } else {
8419 (true, String::new())
8420 }
8421 },
8422 "system",
8423 "system/com.openai.runner-manager-selftest",
8424 &plist,
8425 "runner-manager-selftest",
8426 "rerun with administrative rights",
8427 )
8428 .expect_err("enable failure must fail the install");
8429
8430 assert!(
8431 matches!(
8432 error,
8433 ServiceError::Control {
8434 operation: "enable",
8435 ..
8436 }
8437 ),
8438 "{error}"
8439 );
8440 assert_eq!(calls.borrow().len(), 2);
8441 assert_eq!(calls.borrow()[0][0], "enable");
8442 assert_eq!(calls.borrow()[1][0], "bootout");
8443 assert!(!plist.exists(), "rollback must remove the plist");
8444 }
8445
8446 #[test]
8451 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
8452 let plan = windows_plan(StartMode::Boot);
8453 let command_line = plan.command_line();
8454 assert!(
8455 command_line.starts_with('"'),
8456 "a path with a space must be quoted, got {command_line}"
8457 );
8458 assert_eq!(
8459 executable_from_command_line(&command_line).as_deref(),
8460 Some(plan.binary())
8461 );
8462 }
8463
8464 #[test]
8465 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
8466 let plan = linux_plan(StartMode::Boot);
8467 let command_line = plan.command_line();
8468 assert!(!command_line.starts_with('"'), "got {command_line}");
8469 assert_eq!(
8470 executable_from_command_line(&command_line).as_deref(),
8471 Some(plan.binary())
8472 );
8473 }
8474
8475 #[test]
8476 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
8477 let awkward = r#"C:\odd "name"\rm.exe"#;
8480 let quoted = quote_argument(awkward);
8481 assert_eq!(
8482 executable_from_command_line(&format!("{quoted} daemon run"))
8483 .as_deref()
8484 .map(Path::to_string_lossy)
8485 .as_deref(),
8486 Some(awkward)
8487 );
8488 }
8489
8490 #[test]
8491 fn an_empty_command_line_has_no_executable() {
8492 assert_eq!(executable_from_command_line(" "), None);
8493 assert_eq!(executable_from_command_line(""), None);
8494 }
8495
8496 #[test]
8497 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
8498 let awkward = r#"DOMAIN\R&D <team> "ops""#;
8499 assert_eq!(
8500 xml_escape(awkward),
8501 "DOMAIN\\R&D <team> "ops""
8502 );
8503 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
8504 }
8505
8506 #[test]
8511 fn a_restart_delay_under_the_floor_is_refused() {
8512 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
8513 .expect_err("half a second is under the one-second floor");
8514 assert!(
8515 matches!(error, ServiceError::RestartDelay { .. }),
8516 "{error}"
8517 );
8518 }
8519
8520 #[test]
8521 fn a_restart_delay_over_the_ceiling_is_refused() {
8522 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
8523 .expect_err("an hour is over the five-minute ceiling");
8524 assert!(
8525 matches!(error, ServiceError::RestartDelay { .. }),
8526 "{error}"
8527 );
8528 }
8529
8530 #[test]
8531 fn a_reset_window_no_longer_than_the_delay_is_refused() {
8532 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
8533 .expect_err("a window equal to the delay can never elapse between restarts");
8534 assert!(
8535 matches!(error, ServiceError::RestartResetWindow { .. }),
8536 "{error}"
8537 );
8538 }
8539
8540 #[test]
8541 fn a_delay_inside_the_bound_is_accepted() {
8542 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
8543 .expect("twenty seconds is inside the bound");
8544 assert_eq!(policy.delay(), Duration::from_secs(20));
8545 assert_eq!(policy.reset_after(), Duration::from_secs(300));
8546 }
8547
8548 #[test]
8553 fn a_fixture_identity_can_never_be_the_product_identity() {
8554 let fixture = ServiceIdentity::fixture("abc123");
8555 assert!(fixture.is_fixture());
8556 assert!(!ServiceIdentity::product().is_fixture());
8557 assert_ne!(fixture.name(), ServiceIdentity::product().name());
8558 assert_ne!(
8559 fixture.launchd_label(),
8560 ServiceIdentity::product().launchd_label()
8561 );
8562 assert_ne!(
8563 fixture.systemd_unit(),
8564 ServiceIdentity::product().systemd_unit()
8565 );
8566 }
8567
8568 #[test]
8569 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
8570 let fixture = ServiceIdentity::fixture("A b/c:\\d");
8571 assert!(
8572 fixture
8573 .name()
8574 .chars()
8575 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
8576 "got {}",
8577 fixture.name()
8578 );
8579 }
8580
8581 #[test]
8586 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
8587 let unit = systemd_unit(&linux_plan(StartMode::Boot));
8588 assert!(unit.contains("KillMode=process\n"), "{unit}");
8589 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
8590 assert!(unit.contains("RestartSec=15\n"), "{unit}");
8591 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
8592 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
8593 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
8594 }
8595
8596 #[test]
8597 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
8598 let unit = systemd_unit(&linux_plan(StartMode::Boot));
8599 assert!(
8600 !unit.contains("LoadCredential="),
8601 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
8602 );
8603 }
8604
8605 #[test]
8606 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
8607 let unit = systemd_unit(&linux_plan(StartMode::Login));
8608 assert!(
8609 !unit.contains("LoadCredential="),
8610 "a user unit must not name a root-owned credential file, got:\n{unit}"
8611 );
8612 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
8613 }
8614
8615 #[test]
8616 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
8617 let plan = linux_plan(StartMode::Boot);
8618 let unit = systemd_unit(&plan);
8619 let directives = ini_directives(&unit, "Service");
8620 let listed = split_quoted(
8621 directives
8622 .get("ReadWritePaths")
8623 .expect("the unit names its writable paths"),
8624 );
8625 assert_eq!(listed.len(), 5, "{listed:?}");
8626 for path in plan.directories().all() {
8627 assert!(
8628 listed.iter().any(|entry| entry == &path.to_string_lossy()),
8629 "{} is missing from {listed:?}",
8630 path.display()
8631 );
8632 }
8633 assert!(
8634 listed
8635 .iter()
8636 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
8637 "atomic credential replacement needs its parent directory in {listed:?}"
8638 );
8639 }
8640
8641 #[test]
8642 fn the_unit_records_the_absolute_binary_path() {
8643 let plan = linux_plan(StartMode::Boot);
8644 let unit = systemd_unit(&plan);
8645 assert!(
8646 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
8647 "{unit}"
8648 );
8649 }
8650
8651 #[test]
8656 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
8657 let plist = launchd_plist(&linux_plan(StartMode::Boot));
8658 assert!(
8661 plist.contains(
8662 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
8663 ),
8664 "{plist}"
8665 );
8666 assert!(
8667 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
8668 "{plist}"
8669 );
8670 }
8671
8672 #[test]
8673 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
8674 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
8675 assert_eq!(
8676 plist_string_value(&daemon, "UserName").as_deref(),
8677 Some("root")
8678 );
8679 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
8680
8681 let agent = launchd_plist(&linux_plan(StartMode::Login));
8682 assert_eq!(
8683 plist_string_value(&agent, "UserName"),
8684 None,
8685 "a LaunchAgent already runs as the operator:\n{agent}"
8686 );
8687 }
8688
8689 #[test]
8690 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
8691 for mode in [StartMode::Boot, StartMode::Login] {
8692 let plan = linux_plan(mode);
8693 let plist = launchd_plist(&plan);
8694 assert_eq!(
8695 plist_string_value(&plist, "ProcessType").as_deref(),
8696 Some("Interactive"),
8697 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
8698 );
8699 let review = review_least_privilege(
8700 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
8701 &plan,
8702 );
8703 assert!(
8704 review
8705 .controls()
8706 .iter()
8707 .any(|control| control == "ProcessType=Interactive"),
8708 "{review}"
8709 );
8710 assert!(
8711 !review
8712 .findings()
8713 .iter()
8714 .any(|finding| finding.subject == "ProcessType"),
8715 "{review}"
8716 );
8717 }
8718 }
8719
8720 #[test]
8724 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
8725 let plan = linux_plan(StartMode::Login);
8726 let rendered = launchd_plist(&plan);
8727 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
8728 for (label, throttled) in [
8729 (
8730 "Background",
8731 edited(
8732 &rendered,
8733 interactive,
8734 "<key>ProcessType</key>\n <string>Background</string>\n",
8735 ),
8736 ),
8737 ("absent", edited(&rendered, interactive, "")),
8738 ] {
8739 let review = review_least_privilege(
8740 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
8741 &plan,
8742 );
8743 assert!(review.is_least_privilege(), "{label}: {review}");
8744 assert!(
8745 review.findings().iter().any(|finding| {
8746 finding.kind == FindingKind::Shortfall
8747 && finding.subject == "ProcessType"
8748 && finding.detail.contains(label)
8749 }),
8750 "{label}: {review}"
8751 );
8752 }
8753 }
8754
8755 #[test]
8756 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
8757 let host = Host::new();
8758 let identity = ServiceIdentity::product();
8759 let plan = InstallPlan::resolve(
8760 identity.clone(),
8761 &host.request(StartMode::Login),
8762 ServiceDirectories::of(&host.paths),
8763 )
8764 .expect("a resolvable plan");
8765 let installed = launchd_plist(&plan);
8766 let definition =
8767 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
8768 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8769 record.installed_by_version = "0.4.31".into();
8770 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
8771
8772 assert_eq!(
8773 launchd_definition_drift(&identity, &record, path.clone(), &installed),
8774 None
8775 );
8776 let edited_by_operator = edited(
8777 &installed,
8778 "</dict>\n</plist>\n",
8779 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
8780 );
8781 assert_eq!(
8782 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
8783 None,
8784 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
8785 );
8786
8787 let written_by_0_4_31 = edited(
8788 &installed,
8789 "<string>Interactive</string>",
8790 "<string>Background</string>",
8791 );
8792 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
8793 .expect("a Background plist is drift");
8794 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
8795 assert_eq!(drift.start_mode, StartMode::Login);
8796 assert_eq!(
8797 drift.rendered, installed,
8798 "the repair must write exactly what `service install` writes"
8799 );
8800
8801 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8802 .with_definition_drift(Some(drift));
8803 let problem = status
8804 .problems()
8805 .iter()
8806 .find(|problem| problem.subject == "definition")
8807 .expect("the drift is reported");
8808 assert!(
8811 problem.detail.contains("0.4.31")
8812 && problem.detail.contains(
8813 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
8814 )
8815 && !problem.detail.contains("service install")
8816 && !problem.detail.contains("systemctl")
8817 && !problem.detail.contains("sudo"),
8818 "{}",
8819 problem.detail
8820 );
8821 }
8822
8823 #[cfg(unix)]
8824 #[test]
8825 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
8826 use std::os::unix::fs::PermissionsExt as _;
8827
8828 let root = tempfile::tempdir().expect("a temporary directory");
8829 let path = root
8830 .path()
8831 .join("io.github.IvanMurzak.runner-manager.plist");
8832 std::fs::write(&path, "<string>Background</string>").expect("old plist");
8833 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
8834 let drift = DefinitionDrift {
8835 path: path.clone(),
8836 installed_by_version: "0.4.31".into(),
8837 rendered: launchd_plist(&linux_plan(StartMode::Login)),
8838 start_mode: StartMode::Login,
8839 manager: DefinitionKind::LaunchdPlist,
8840 };
8841
8842 replace_definition("runner-manager", &drift).expect("replaced");
8843
8844 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
8845 assert_eq!(
8846 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
8847 0o644
8848 );
8849 assert_eq!(
8850 std::fs::read_dir(root.path()).unwrap().count(),
8851 1,
8852 "no staging file is left beside the plist"
8853 );
8854 }
8855
8856 #[cfg(target_os = "macos")]
8865 #[test]
8866 #[ignore = "run only by launchd, as the job of the detached-reload test"]
8867 fn detached_reload_job() {
8868 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
8869 let tag = label
8870 .split_once("-selftest-")
8871 .map(|(_, tag)| tag.to_string())
8872 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
8873 let identity = ServiceIdentity::fixture(&tag);
8874 assert_eq!(identity.launchd_label(), label);
8875 let path = std::env::current_dir()
8876 .expect("working directory")
8877 .join(format!("{label}.plist"));
8878 let installed = std::fs::read_to_string(&path).expect("own plist");
8879 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
8880 let drift = DefinitionDrift {
8881 path: path.clone(),
8882 installed_by_version: "0.4.31".into(),
8883 rendered: installed.replace(
8884 "<string>Background</string>",
8885 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
8886 ),
8887 start_mode: StartMode::Login,
8888 manager: DefinitionKind::LaunchdPlist,
8889 };
8890 replace_definition(identity.name(), &drift).expect("plist replaced");
8891 super::sys::reload_detached(&identity, StartMode::Login, &path)
8892 .expect("helper started");
8893 }
8894 std::thread::sleep(Duration::from_secs(120));
8895 }
8896
8897 #[cfg(target_os = "macos")]
8907 #[test]
8908 fn a_job_reloads_itself_from_its_rewritten_plist() {
8909 if !super::launchd_fixture::enabled() {
8910 return;
8911 }
8912 let root = tempfile::tempdir().expect("a temporary directory");
8913 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
8914 let label = identity.launchd_label();
8915 let path = root.path().join(format!("{label}.plist"));
8916 let binary = super::launchd_fixture::job_binary(root.path());
8917 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
8918 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
8919 plist.push_str(&plist_string("Label", &label));
8920 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
8921 for argument in [
8922 binary.to_string_lossy().as_ref(),
8923 "--ignored",
8924 "--exact",
8925 "service::tests::detached_reload_job",
8926 "--test-threads=1",
8927 ] {
8928 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
8929 }
8930 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
8931 plist.push_str(&plist_string(
8932 "WorkingDirectory",
8933 &root.path().to_string_lossy(),
8934 ));
8935 plist.push_str(&plist_string("ProcessType", "Background"));
8936 plist.push_str("</dict>\n</plist>\n");
8937 std::fs::write(&path, plist).expect("plist written");
8938
8939 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
8940 let spawn_type = || {
8941 let printed = std::process::Command::new("launchctl")
8942 .args(["print", loaded.target()])
8943 .output()
8944 .expect("launchctl runs");
8945 String::from_utf8_lossy(&printed.stdout)
8946 .lines()
8947 .find_map(|line| {
8948 line.trim()
8949 .strip_prefix("spawn type = ")
8950 .map(str::to_string)
8951 })
8952 };
8953 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
8954
8955 let deadline = std::time::Instant::now() + Duration::from_secs(90);
8956 while spawn_type().as_deref() != Some("interactive (4)") {
8957 assert!(
8958 std::time::Instant::now() < deadline,
8959 "the job did not come back under its rewritten plist; launchd reports {:?}",
8960 spawn_type()
8961 );
8962 std::thread::sleep(Duration::from_millis(250));
8963 }
8964 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
8965 assert_eq!(
8966 plist_string_value(&rewritten, "ProcessType").as_deref(),
8967 Some(LAUNCHD_PROCESS_TYPE)
8968 );
8969 }
8970
8971 #[test]
8972 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
8973 let plist = launchd_plist(&linux_plan(StartMode::Boot));
8974 assert!(
8975 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
8976 "{plist}"
8977 );
8978 assert!(plist.contains("<string>daemon</string>"), "{plist}");
8979 assert!(plist.contains("<string>run</string>"), "{plist}");
8980 }
8981
8982 #[test]
8983 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
8984 assert_eq!(
8985 ServiceIdentity::product().launchd_label(),
8986 "io.github.IvanMurzak.runner-manager"
8987 );
8988 }
8989
8990 #[test]
8995 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
8996 let xml = windows_scheduled_task_xml(
8997 &windows_plan(StartMode::Login),
8998 &TaskPrincipal::named("HOST\\operator"),
8999 );
9000 assert!(xml.contains("<LogonTrigger>"), "{xml}");
9001 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9002 assert!(
9003 xml.contains("<LogonType>InteractiveToken</LogonType>"),
9004 "{xml}"
9005 );
9006 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9007 assert!(
9008 xml.contains("<Interval>PT1M</Interval>"),
9009 "Task Scheduler takes whole minutes only, and rejects the registration outright \
9010 for anything finer:\n{xml}"
9011 );
9012 }
9013
9014 #[test]
9015 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9016 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9021 let policy =
9022 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9023 .expect("inside the supported range");
9024 assert_eq!(
9025 policy
9026 .effective_delay(DefinitionKind::WindowsScheduledTask)
9027 .as_secs(),
9028 enforced,
9029 "a {asked}s delay must be enforced as {enforced}s"
9030 );
9031 assert!(
9032 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9033 "rounding must never shorten the bound"
9034 );
9035 }
9036 }
9037
9038 #[test]
9039 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9040 let policy = RestartPolicy::default();
9041 for kind in [
9042 DefinitionKind::WindowsService,
9043 DefinitionKind::LaunchdPlist,
9044 DefinitionKind::SystemdUnit,
9045 ] {
9046 assert_eq!(
9047 policy.effective_delay(kind),
9048 policy.delay(),
9049 "{kind:?} takes seconds and enforces exactly what it is given"
9050 );
9051 }
9052 }
9053
9054 #[test]
9055 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9056 let host = Host::new();
9057 let operations = host.operations();
9058 operations
9059 .install(&host.request(StartMode::Login))
9060 .expect("an install at login");
9061 host.controls.edit("runner-manager", |registration| {
9063 registration.manager = DefinitionKind::WindowsScheduledTask;
9064 registration.restart_delay = Some(Duration::from_secs(60));
9065 });
9066
9067 let status = operations.status().expect("a status");
9068 assert!(
9069 status.is_healthy(),
9070 "minute granularity is the manager's, not a mis-registration: {status}"
9071 );
9072 assert!(
9073 status
9074 .notes()
9075 .iter()
9076 .any(|note| note.contains("whole minutes")),
9077 "but the operator must be told why 15 became 60: {status}"
9078 );
9079
9080 host.controls.edit("runner-manager", |registration| {
9083 registration.restart_delay = Some(Duration::from_secs(1));
9084 });
9085 assert!(
9086 !operations.status().expect("a status").is_healthy(),
9087 "a one-second delay is not what any manager was asked for"
9088 );
9089 }
9090
9091 #[test]
9092 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9093 let plan = windows_plan(StartMode::Login);
9094 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9095 assert_eq!(
9096 xml_value(&xml, "Command").as_deref(),
9097 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9098 "{xml}"
9099 );
9100 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9101 }
9102
9103 #[test]
9104 fn an_account_name_holding_xml_punctuation_is_escaped() {
9105 let xml = windows_scheduled_task_xml(
9106 &windows_plan(StartMode::Login),
9107 &TaskPrincipal::named("R&D\\ops"),
9108 );
9109 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
9110 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9111 }
9112
9113 #[test]
9118 fn the_service_starts_automatically_under_the_account_the_store_admits() {
9119 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9120 let directives = ini_directives(&text, "windows-service");
9121 assert_eq!(
9122 directives.get("StartType").map(String::as_str),
9123 Some("AutoStart")
9124 );
9125 assert_eq!(
9126 directives.get("Account").map(String::as_str),
9127 Some("NT AUTHORITY\\SYSTEM")
9128 );
9129 assert_eq!(
9130 directives.get("ServiceType").map(String::as_str),
9131 Some("OWN_PROCESS")
9132 );
9133 assert_eq!(
9134 directives
9135 .get("FailureActionRestartDelaySecs")
9136 .map(String::as_str),
9137 Some("15")
9138 );
9139 assert_eq!(
9140 directives
9141 .get("FailureActionsOnNonCrashFailures")
9142 .map(String::as_str),
9143 Some("true"),
9144 "without this flag a non-zero exit is not a failure the manager restarts"
9145 );
9146 }
9147
9148 #[test]
9149 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9150 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9151 assert_eq!(spec.account, None);
9152 assert!(spec.automatic_start);
9153 assert!(
9154 spec.command_line.contains("daemon run"),
9155 "{}",
9156 spec.command_line
9157 );
9158 }
9159
9160 #[test]
9165 fn each_definition_goes_where_its_platform_expects_it() {
9166 let home = PathBuf::from("/home/op");
9167 assert_eq!(
9168 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9169 Some(Path::new(
9170 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9171 ))
9172 );
9173 assert_eq!(
9174 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9175 Some(Path::new(
9176 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9177 ))
9178 );
9179 assert_eq!(
9180 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9181 Some(Path::new("/etc/systemd/system/runner-manager.service"))
9182 );
9183 assert_eq!(
9184 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9185 Some(Path::new(
9186 "/home/op/.config/systemd/user/runner-manager.service"
9187 ))
9188 );
9189 assert_eq!(
9190 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9191 None,
9192 "the Service Control Manager has no file"
9193 );
9194 }
9195
9196 #[test]
9197 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9198 assert_eq!(
9199 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9200 None
9201 );
9202 assert_eq!(
9203 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9204 None
9205 );
9206 }
9207
9208 #[test]
9213 fn the_rendered_definitions_are_all_least_privilege() {
9214 let linux = linux_plan(StartMode::Boot);
9215 let windows = windows_plan(StartMode::Boot);
9216 for (definition, plan) in [
9217 (ServiceDefinition::systemd(&linux, None), &linux),
9218 (ServiceDefinition::launchd(&linux, None), &linux),
9219 (ServiceDefinition::windows_service(&windows), &windows),
9220 ] {
9221 let review = review_least_privilege(&definition, plan);
9222 assert!(
9223 review.is_least_privilege(),
9224 "{:?} should be least privilege, got:\n{review}",
9225 definition.kind()
9226 );
9227 assert!(
9228 !review.controls().is_empty(),
9229 "a review that confirms nothing proves nothing: {:?}",
9230 definition.kind()
9231 );
9232 }
9233 }
9234
9235 #[test]
9236 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9237 let plan = windows_plan(StartMode::Login);
9238 let definition =
9239 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9240 let review = review_least_privilege(&definition, &plan);
9241 assert!(review.is_least_privilege(), "{review}");
9242 assert!(
9243 review
9244 .controls()
9245 .iter()
9246 .any(|control| control.contains("LeastPrivilege")),
9247 "{review}"
9248 );
9249 }
9250
9251 #[test]
9252 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9253 let plan = linux_plan(StartMode::Boot);
9254 let rendered = systemd_unit(&plan);
9255 let widened = edited(
9256 &rendered,
9257 "ReadWritePaths=/var/lib/runner-manager/config",
9258 "ReadWritePaths=/etc /var/lib/runner-manager/config",
9259 );
9260 let review = review_least_privilege(
9261 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9262 &plan,
9263 );
9264 assert!(!review.is_least_privilege(), "{review}");
9265 assert!(
9266 review
9267 .excesses()
9268 .iter()
9269 .any(|finding| finding.detail.contains("/etc")),
9270 "the review must name the directory it objects to: {review}"
9271 );
9272 }
9273
9274 #[test]
9275 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9276 let plan = linux_plan(StartMode::Boot);
9277 let rendered = systemd_unit(&plan);
9278 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9279 let review = review_least_privilege(
9280 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9281 &plan,
9282 );
9283 assert!(!review.is_least_privilege(), "{review}");
9284 assert!(
9285 review
9286 .excesses()
9287 .iter()
9288 .any(|finding| finding.subject == "NoNewPrivileges"),
9289 "{review}"
9290 );
9291 }
9292
9293 #[test]
9294 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
9295 let plan = linux_plan(StartMode::Boot);
9296 let rendered = systemd_unit(&plan);
9297 let widened = edited(
9298 &rendered,
9299 "CapabilityBoundingSet=\n",
9300 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
9301 );
9302 let review = review_least_privilege(
9303 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9304 &plan,
9305 );
9306 assert!(!review.is_least_privilege(), "{review}");
9307 assert!(
9308 review
9309 .excesses()
9310 .iter()
9311 .any(|finding| finding.subject == "CapabilityBoundingSet"),
9312 "{review}"
9313 );
9314 }
9315
9316 #[test]
9317 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
9318 let plan = linux_plan(StartMode::Boot);
9319 let rendered = systemd_unit(&plan);
9320 let widened = edited(
9321 &rendered,
9322 "[Install]",
9323 "ListenStream=127.0.0.1:9000\n\n[Install]",
9324 );
9325 let review = review_least_privilege(
9326 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9327 &plan,
9328 );
9329 assert!(
9330 !review.is_least_privilege(),
9331 "07-security.md rule 2 forbids any inbound surface: {review}"
9332 );
9333 }
9334
9335 #[test]
9336 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
9337 let plan = linux_plan(StartMode::Boot);
9338 let rendered = systemd_unit(&plan);
9339 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
9340 let review = review_least_privilege(
9341 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
9342 &plan,
9343 );
9344 assert!(
9345 review.is_least_privilege(),
9346 "too little authority is not an excess: {review}"
9347 );
9348 assert!(
9349 review
9350 .findings()
9351 .iter()
9352 .any(|finding| finding.kind == FindingKind::Shortfall
9353 && finding.detail.contains("runtime")),
9354 "{review}"
9355 );
9356 }
9357
9358 #[test]
9359 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
9360 let plan = linux_plan(StartMode::Login);
9361 let rendered = launchd_plist(&plan);
9362 let widened = edited(
9363 &rendered,
9364 "<key>ProcessType</key>",
9365 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
9366 );
9367 let review = review_least_privilege(
9368 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
9369 &plan,
9370 );
9371 assert!(!review.is_least_privilege(), "{review}");
9372 assert!(
9373 review
9374 .excesses()
9375 .iter()
9376 .any(|finding| finding.subject == "UserName"),
9377 "{review}"
9378 );
9379 }
9380
9381 #[test]
9382 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
9383 let plan = linux_plan(StartMode::Boot);
9384 let rendered = launchd_plist(&plan);
9385 let widened = edited(
9386 &rendered,
9387 "<key>SessionCreate</key>\n <false/>",
9388 "<key>SessionCreate</key>\n <true/>",
9389 );
9390 let review = review_least_privilege(
9391 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
9392 &plan,
9393 );
9394 assert!(!review.is_least_privilege(), "{review}");
9395 assert!(
9396 review
9397 .excesses()
9398 .iter()
9399 .any(|finding| finding.subject == "SessionCreate"),
9400 "{review}"
9401 );
9402 }
9403
9404 #[test]
9405 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
9406 let plan = linux_plan(StartMode::Boot);
9407 let rendered = launchd_plist(&plan);
9408 let widened = edited(
9409 &rendered,
9410 "<key>ProcessType</key>",
9411 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
9412 );
9413 let review = review_least_privilege(
9414 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
9415 &plan,
9416 );
9417 assert!(!review.is_least_privilege(), "{review}");
9418 }
9419
9420 #[test]
9421 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
9422 let plan = windows_plan(StartMode::Login);
9423 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
9424 let widened = edited(
9425 &rendered,
9426 "<RunLevel>LeastPrivilege</RunLevel>",
9427 "<RunLevel>HighestAvailable</RunLevel>",
9428 );
9429 let review = review_least_privilege(
9430 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
9431 &plan,
9432 );
9433 assert!(!review.is_least_privilege(), "{review}");
9434 assert!(
9435 review
9436 .excesses()
9437 .iter()
9438 .any(|finding| finding.subject == "RunLevel"),
9439 "{review}"
9440 );
9441 }
9442
9443 #[test]
9444 fn a_task_that_would_store_a_password_is_not_least_privilege() {
9445 let plan = windows_plan(StartMode::Login);
9446 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
9447 let widened = edited(
9448 &rendered,
9449 "<LogonType>InteractiveToken</LogonType>",
9450 "<LogonType>Password</LogonType>",
9451 );
9452 let review = review_least_privilege(
9453 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
9454 &plan,
9455 );
9456 assert!(!review.is_least_privilege(), "{review}");
9457 }
9458
9459 #[test]
9460 fn an_interactive_windows_service_is_not_least_privilege() {
9461 let plan = windows_plan(StartMode::Boot);
9462 let rendered = windows_service_descriptor(&plan);
9463 let widened = edited(
9464 &rendered,
9465 "ServiceType=OWN_PROCESS",
9466 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
9467 );
9468 let review = review_least_privilege(
9469 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
9470 &plan,
9471 );
9472 assert!(!review.is_least_privilege(), "{review}");
9473 assert!(
9474 review
9475 .excesses()
9476 .iter()
9477 .any(|finding| finding.subject == "ServiceType"),
9478 "{review}"
9479 );
9480 }
9481
9482 #[test]
9483 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
9484 let plan = windows_plan(StartMode::Boot);
9485 let rendered = windows_service_descriptor(&plan);
9486 let changed = edited(
9487 &rendered,
9488 "Account=NT AUTHORITY\\SYSTEM",
9489 "Account=NT AUTHORITY\\LocalService",
9490 );
9491 let review = review_least_privilege(
9492 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
9493 &plan,
9494 );
9495 assert!(review.is_least_privilege(), "{review}");
9501 assert!(
9502 review.findings().iter().any(|finding| {
9503 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
9504 }),
9505 "{review}"
9506 );
9507 }
9508
9509 #[test]
9514 fn a_recorded_path_that_is_still_there_is_current() {
9515 let host = Host::new();
9516 let state = inspect_binary(&host.binary, Some(&host.binary));
9517 assert!(!state.is_error(), "{state}");
9518 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
9519 }
9520
9521 #[test]
9522 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
9523 let host = Host::new();
9524 let recorded = host.binary.clone();
9528 let healthy = inspect_binary(&recorded, Some(&recorded));
9529 assert!(
9530 !healthy.is_error(),
9531 "the discriminator: before the binary moves, this must be healthy"
9532 );
9533
9534 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
9535
9536 let state = inspect_binary(&recorded, Some(&recorded));
9537 assert!(state.is_error(), "{state}");
9538 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
9539 assert!(
9540 state.to_string().contains("npm"),
9541 "the message must name the cause an operator will not otherwise connect: {state}"
9542 );
9543 }
9544
9545 #[test]
9546 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
9547 let root = tempfile::tempdir().expect("a temporary directory");
9548 let state = inspect_binary(root.path(), None);
9549 assert!(state.is_error(), "{state}");
9550 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
9551 }
9552
9553 #[test]
9554 fn a_registration_naming_a_different_binary_is_a_divergence() {
9555 let host = Host::new();
9556 let other = host.binary.with_file_name("something-else");
9557 let state = inspect_binary(&host.binary, Some(&other));
9558 assert!(state.is_error(), "{state}");
9559 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
9560 }
9561
9562 #[test]
9563 fn absence_is_reported_before_divergence() {
9564 let host = Host::new();
9565 let recorded = host.binary.clone();
9566 std::fs::remove_file(&recorded).expect("removable");
9567 let other = recorded.with_file_name("something-else");
9568 assert!(matches!(
9572 inspect_binary(&recorded, Some(&other)),
9573 BinaryPath::Missing { .. }
9574 ));
9575 }
9576
9577 #[test]
9582 fn the_record_round_trips_through_toml() {
9583 let host = Host::new();
9584 let plan = InstallPlan::resolve(
9585 ServiceIdentity::product(),
9586 &host.request(StartMode::Boot),
9587 ServiceDirectories::of(&host.paths),
9588 )
9589 .expect("a resolvable plan");
9590 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
9591 let record = InstallRecord::of(&plan, &definition, Utc::now());
9592 record.write(&host.paths).expect("a writable record");
9593 let read = InstallRecord::read(&host.paths)
9594 .expect("a readable record")
9595 .expect("a record is there");
9596 assert_eq!(read, record);
9597 assert_eq!(read.binary, host.binary);
9598 assert!(read.binary.is_absolute());
9599 }
9600
9601 #[test]
9602 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
9603 let host = Host::new();
9604 let identity = ServiceIdentity::product();
9605 let plan = InstallPlan::resolve(
9606 identity.clone(),
9607 &host.request(StartMode::Boot),
9608 ServiceDirectories::of(&host.paths),
9609 )
9610 .expect("a resolvable plan");
9611 let installed = systemd_unit(&plan);
9612 let definition =
9613 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
9614 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9615 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
9616
9617 assert_eq!(
9618 systemd_definition_drift(&identity, &record, path.clone(), &installed),
9619 None,
9620 "the record must rebuild exactly the unit `service install` wrote"
9621 );
9622
9623 record.installed_by_version = "0.4.7".into();
9624 let frozen = installed.replace(
9625 "[Service]\n",
9626 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
9627 );
9628 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
9629 .expect("an outdated unit is drift");
9630 assert_eq!(drift.installed_by_version, "0.4.7");
9631 assert_eq!(drift.rendered, installed);
9632
9633 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9634 .with_definition_drift(Some(drift));
9635 assert!(!status.is_healthy());
9636 assert!(
9637 status
9638 .problems()
9639 .iter()
9640 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
9641 "{status}"
9642 );
9643 }
9644
9645 #[test]
9646 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
9647 let host = Host::new();
9648 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
9649 let first = Utc::now() - chrono::Duration::hours(9);
9650 record_github_credential_rejected(&host.paths, first).unwrap();
9651 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
9652 let since = github_credential_rejected_since(&host.paths).unwrap();
9653 assert_eq!(since, Some(first));
9654
9655 let status = ServiceStatus::compose(
9656 ServiceIdentity::product(),
9657 None,
9658 None,
9659 None,
9660 None,
9661 &host.paths,
9662 )
9663 .with_credential_rejection(since);
9664 assert!(!status.is_healthy());
9665 assert_eq!(status.credential_rejected_since(), Some(first));
9666 assert!(status.to_string().contains("auth login"), "{status}");
9667
9668 clear_github_credential_rejection(&host.paths).unwrap();
9669 clear_github_credential_rejection(&host.paths).unwrap();
9670 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
9671 }
9672
9673 #[test]
9675 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
9676 use crate::launch_health::{
9677 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
9678 record_launches_blocked,
9679 };
9680
9681 let host = Host::new();
9682 let compose = || {
9683 ServiceStatus::compose(
9684 ServiceIdentity::product(),
9685 None,
9686 None,
9687 None,
9688 None,
9689 &host.paths,
9690 )
9691 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
9692 };
9693 assert!(
9694 !compose()
9695 .problems()
9696 .iter()
9697 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
9698 );
9699 let blocked = BlockCause::FenceHeld {
9700 directory: PathBuf::from("launch-fence"),
9701 owner: None,
9702 }
9703 .blocked_since(Utc::now(), Viewpoint::Guest);
9704 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
9705
9706 let status = compose();
9707 assert!(!status.is_healthy());
9708 assert_eq!(status.launches_blocked(), Some(&blocked));
9709 let problem = status
9710 .problems()
9711 .iter()
9712 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
9713 .expect("reported under its own subject");
9714 assert!(problem.detail.contains("systemctl restart"), "{status}");
9715
9716 clear_launches_blocked(&host.paths).unwrap();
9717 assert_eq!(compose().launches_blocked(), None);
9718 }
9719
9720 #[test]
9723 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
9724 let host = Host::new();
9725 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
9726 let first = Utc::now() - chrono::Duration::minutes(40);
9727 record_credential_unreadable(&host.paths, first).unwrap();
9728 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
9729 let since = credential_unreadable_since(&host.paths).unwrap();
9730 assert_eq!(since, Some(first), "a restart loop must not move the start");
9731
9732 let status = ServiceStatus::compose(
9733 ServiceIdentity::product(),
9734 None,
9735 None,
9736 None,
9737 None,
9738 &host.paths,
9739 )
9740 .with_credential_unreadable(since);
9741 assert!(!status.is_healthy());
9742 let problem = status
9743 .problems()
9744 .iter()
9745 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
9746 .expect("reported under its own subject");
9747 assert!(
9749 problem
9750 .detail
9751 .contains(&sign_in_instruction(StartMode::default())),
9752 "{status}"
9753 );
9754
9755 clear_credential_unreadable(&host.paths).unwrap();
9756 clear_credential_unreadable(&host.paths).unwrap();
9757 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
9758 let healthy = ServiceStatus::compose(
9759 ServiceIdentity::product(),
9760 None,
9761 None,
9762 None,
9763 None,
9764 &host.paths,
9765 )
9766 .with_credential_unreadable(None);
9767 assert!(
9768 !healthy
9769 .problems()
9770 .iter()
9771 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
9772 "{healthy}"
9773 );
9774 }
9775
9776 #[test]
9777 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
9778 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
9779 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
9780 assert_eq!(
9781 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
9782 cfg!(unix),
9783 "a machine-scoped store needs root on Unix"
9784 );
9785 assert_eq!(
9786 sign_in_instruction(StartMode::Login).contains("SSH"),
9787 cfg!(target_os = "macos"),
9788 "only the macOS login keychain is out of an SSH session's reach"
9789 );
9790 }
9791
9792 #[cfg(unix)]
9801 #[test]
9802 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
9803 use std::os::unix::fs::PermissionsExt as _;
9804
9805 let host = Host::new();
9806 let plan = InstallPlan::resolve(
9807 ServiceIdentity::product(),
9808 &host.request(StartMode::Boot),
9809 ServiceDirectories::of(&host.paths),
9810 )
9811 .expect("a resolvable plan");
9812 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
9813 InstallRecord::of(&plan, &definition, Utc::now())
9814 .write(&host.paths)
9815 .expect("a writable record");
9816
9817 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
9818 .expect("the record is there")
9819 .permissions()
9820 .mode()
9821 & 0o777;
9822 assert_eq!(
9823 mode, 0o644,
9824 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
9825 service install` wrote, and `service status` fails on their own host. It holds no \
9826 credential and sits in a 0700 directory, so 0644 discloses nothing"
9827 );
9828 }
9829
9830 #[test]
9834 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
9835 let host = Host::new();
9836 let path = InstallRecord::path(&host.paths);
9837 std::fs::write(
9838 &path,
9839 format!(
9840 "schema_version = {RECORD_SCHEMA_VERSION}
9841service_name = \"runner-manager\"
9842 manager = \"systemd\"
9843start_mode = \"boot\"
9844account = \"root\"
9845 binary = \"/x\"
9846arguments = []
9847restart_delay_secs = 15
9848 restart_reset_secs = 600
9849log_file = \"/x\"
9850 installed_at = \"2026-01-01T00:00:00Z\"
9851installed_by_version = \"0.1.0\"
9852 [directories]
9853config = \"/a\"
9854state = \"/b\"
9855runtime = \"/c\"
9856logs = \"/d\"
9857"
9858 ),
9859 )
9860 .expect("a writable record");
9861 let read = InstallRecord::read(&host.paths)
9862 .expect("a record missing an optional field is still readable")
9863 .expect("a record is there");
9864 assert_eq!(
9865 read.source_binary, None,
9866 "the legacy layout has no source, and must not invent one"
9867 );
9868 }
9869
9870 #[test]
9872 fn a_registration_remembers_the_file_it_was_copied_from() {
9873 let host = Host::new();
9874 let source = host.binary.with_file_name("npm-installed-runner-manager");
9875 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
9876 let plan = InstallPlan::resolve(
9877 ServiceIdentity::product(),
9878 &host.request(StartMode::Boot).copied_from(&source),
9879 ServiceDirectories::of(&host.paths),
9880 )
9881 .expect("a resolvable plan");
9882 let definition = ServiceDefinition::from_text(
9883 DefinitionKind::SystemdUnit,
9884 "[Service]
9885",
9886 );
9887 let record = InstallRecord::of(&plan, &definition, Utc::now());
9888 record.write(&host.paths).expect("a writable record");
9889
9890 let read = InstallRecord::read(&host.paths)
9891 .expect("a readable record")
9892 .expect("a record is there");
9893 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
9894 assert_ne!(
9895 read.source_binary.as_deref(),
9896 Some(read.binary.as_path()),
9897 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
9898 );
9899 }
9900
9901 #[test]
9902 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
9903 let host = Host::new();
9904 let path = InstallRecord::path(&host.paths);
9905 std::fs::write(
9906 &path,
9907 format!(
9908 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
9909 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
9910 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
9911 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
9912 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
9913 RECORD_SCHEMA_VERSION + 1
9914 ),
9915 )
9916 .expect("a writable record");
9917 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
9918 assert!(
9919 matches!(error, ServiceError::RecordUnreadable { .. }),
9920 "{error}"
9921 );
9922 assert!(
9923 error.to_string().contains("service uninstall"),
9924 "the message must say how to recover: {error}"
9925 );
9926 }
9927
9928 #[test]
9929 fn no_record_is_not_an_error() {
9930 let host = Host::new();
9931 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
9932 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
9933 }
9934
9935 #[test]
9940 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
9941 let host = Host::new();
9942 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
9943 }
9944
9945 #[test]
9946 fn the_heartbeat_round_trips_to_the_second() {
9947 let host = Host::new();
9948 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
9949 .expect("a valid timestamp")
9950 .with_timezone(&Utc);
9951 record_github_contact(&host.paths, at).expect("a writable heartbeat");
9952 assert_eq!(
9953 last_github_contact(&host.paths).expect("readable"),
9954 Some(at)
9955 );
9956 }
9957
9958 #[test]
9959 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
9960 let host = Host::new();
9961 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
9962 let error = last_github_contact(&host.paths)
9963 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
9964 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9965 }
9966
9967 #[test]
9973 fn a_runner_root_refusal_round_trips_for_service_status() {
9974 let host = Host::new();
9975 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
9976
9977 assert!(
9978 runner_root_refusals(&host.paths)
9979 .expect("readable")
9980 .is_empty(),
9981 "no record means every policy is placing runners"
9982 );
9983
9984 record_runner_root_refusal(
9985 &host.paths,
9986 "policy-a",
9987 at,
9988 "denied_by_privacy_policy",
9989 "/Volumes/NVME/runners",
9990 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
9991 )
9992 .expect("a writable record");
9993
9994 let refusals = runner_root_refusals(&host.paths).expect("readable");
9995 assert_eq!(refusals.len(), 1);
9996 assert_eq!(refusals[0].policy, "policy-a");
9997 assert_eq!(refusals[0].at, at);
9998 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
9999 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
10000 assert!(
10001 refusals[0].detail.contains("/Volumes/NVME/runners")
10002 && refusals[0].detail.contains("Full Disk Access"),
10003 "the path and the remediation are the whole point of this file: {refusals:?}"
10004 );
10005 }
10006
10007 #[test]
10015 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
10016 let host = Host::new();
10017 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
10018 record_runner_root_refusal(
10019 &host.paths,
10020 "broken",
10021 at,
10022 "denied_by_privacy_policy",
10023 "/Volumes/NVME/runners",
10024 "detail",
10025 )
10026 .expect("a writable record");
10027 record_runner_root_refusal(
10028 &host.paths,
10029 "also-broken",
10030 at,
10031 "not_writable",
10032 "/srv/other",
10033 "detail",
10034 )
10035 .expect("a writable record");
10036
10037 clear_runner_root_refusal(&host.paths, "healthy")
10039 .expect("clearing an absent policy is fine");
10040 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
10041
10042 let refusals = runner_root_refusals(&host.paths).expect("readable");
10043 assert_eq!(
10044 refusals
10045 .iter()
10046 .map(|r| r.policy.as_str())
10047 .collect::<Vec<_>>(),
10048 vec!["broken"],
10049 "the policy that is still refused must keep its record"
10050 );
10051 }
10052
10053 #[test]
10056 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
10057 let host = Host::new();
10058 record_runner_root_refusal(
10059 &host.paths,
10060 "p",
10061 Utc::now(),
10062 "not_writable",
10063 "/srv/x",
10064 "detail",
10065 )
10066 .expect("a writable record");
10067
10068 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
10069 assert!(
10070 !root_refusal_path(&host.paths).exists(),
10071 "a host with nothing refused leaves nothing behind"
10072 );
10073 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
10074 }
10075
10076 #[test]
10079 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
10080 let host = Host::new();
10081 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
10082 let error = runner_root_refusals(&host.paths)
10083 .expect_err("an unparseable record is not the same as no record");
10084 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10085 }
10086
10087 #[test]
10097 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
10098 let host = Host::new();
10099 record_runner_root_refusal(
10100 &host.paths,
10101 "policy-a",
10102 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
10103 "denied_by_privacy_policy",
10104 "/Volumes/NVME/runners",
10105 "Grant Full Disk Access to the program that runs the service",
10106 )
10107 .expect("a writable record");
10108
10109 let status = host.operations().status().expect("a readable status");
10110 let notes = status.notes().join("\n");
10111
10112 assert!(
10113 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
10114 "the directory and the remediation the log had to scrub must appear here: {notes}"
10115 );
10116 assert!(
10117 notes.contains("policy-a"),
10118 "the operator has to know which target placed no runner: {notes}"
10119 );
10120 assert!(
10121 !status
10122 .problems()
10123 .iter()
10124 .any(|problem| problem.subject == "runner root"),
10125 "a record nothing an operator types can clear must not drive the exit code"
10126 );
10127 }
10128
10129 #[test]
10135 fn install_records_the_absolute_binary_path_and_the_four_directories() {
10136 let host = Host::new();
10137 let installed = host
10138 .operations()
10139 .install(&host.request(StartMode::Boot))
10140 .expect("an install against the recording controls");
10141
10142 assert_eq!(installed.record.binary, host.binary);
10143 assert!(installed.record.binary.is_absolute());
10144 assert_eq!(installed.record.start_mode, StartMode::Boot);
10145 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
10146 assert_eq!(
10147 installed.record.directories,
10148 ServiceDirectories::of(&host.paths)
10149 );
10150 assert_eq!(
10151 installed.record.log_file,
10152 host.paths.logs_dir().join(LOG_FILE_STEM)
10153 );
10154 assert_eq!(installed.record.restart_delay_secs, 15);
10155
10156 let registrations = host.controls.registrations();
10157 assert_eq!(registrations.len(), 1);
10158 assert_eq!(registrations[0].0, StartMode::Boot);
10159 assert_eq!(registrations[0].1, "runner-manager");
10160 }
10161
10162 #[test]
10163 fn install_is_refused_while_the_single_instance_lock_is_held() {
10164 let host = Host::new();
10165 {
10167 let operations = host.operations();
10168 operations
10169 .install(&host.request(StartMode::Boot))
10170 .expect("an install with the lock free");
10171 operations.uninstall().expect("a clean slate");
10172 }
10173
10174 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
10175 .expect("this process takes the lock first");
10176
10177 let error = host
10178 .operations()
10179 .install(&host.request(StartMode::Boot))
10180 .expect_err("a second agent must not be registered while one is running");
10181 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
10182 assert!(
10183 error.to_string().contains("already running"),
10184 "the message must be actionable: {error}"
10185 );
10186 assert!(
10187 host.controls.registrations().is_empty(),
10188 "a refused install must register nothing"
10189 );
10190 assert_eq!(
10191 InstallRecord::read(&host.paths).expect("readable"),
10192 None,
10193 "a refused install must write no record"
10194 );
10195 }
10196
10197 #[test]
10205 fn installing_over_the_same_start_mode_replaces_the_registration() {
10206 let host = Host::new();
10207 let operations = host.operations();
10208 operations
10209 .install(&host.request(StartMode::Boot))
10210 .expect("the first install");
10211
10212 let again = operations
10213 .install(&host.request(StartMode::Boot))
10214 .expect("an install over the same mode replaces rather than refusing");
10215
10216 assert!(
10217 again.replaced_existing,
10218 "the operator is told this replaced something rather than made it"
10219 );
10220 assert_eq!(
10221 host.controls.registrations().len(),
10222 1,
10223 "replacing must not leave two registrations behind"
10224 );
10225 assert_eq!(
10226 InstallRecord::read(&host.paths)
10227 .expect("readable")
10228 .expect("a record")
10229 .start_mode,
10230 StartMode::Boot
10231 );
10232 }
10233
10234 #[test]
10238 fn installing_over_the_other_start_mode_is_refused() {
10239 let host = Host::new();
10240 let operations = host.operations();
10241 operations
10242 .install(&host.request(StartMode::Boot))
10243 .expect("the first install");
10244
10245 let error = operations
10246 .install(&host.request(StartMode::Login))
10247 .expect_err("a mode change is not an install");
10248 assert!(
10249 matches!(
10250 error,
10251 ServiceError::AlreadyInstalled {
10252 existing: StartMode::Boot,
10253 requested: StartMode::Login,
10254 ..
10255 }
10256 ),
10257 "{error}"
10258 );
10259 assert!(
10260 !error
10261 .to_string()
10262 .contains("switch the start mode in place,"),
10263 "the old remedy named a capability no command offers; the terminal UI is where \
10264 the start mode moves: {error}"
10265 );
10266 assert_eq!(host.controls.registrations().len(), 1);
10267 }
10268
10269 #[test]
10270 fn install_rolls_back_the_registration_when_record_persistence_fails() {
10271 let host = Host::new();
10272 let record_path = InstallRecord::path(&host.paths);
10273 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
10274
10275 let error = host
10276 .operations()
10277 .install(&host.request(StartMode::Boot))
10278 .expect_err("record persistence must fail");
10279
10280 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10281 assert!(
10282 host.controls.registrations().is_empty(),
10283 "a failed install must not leave a live unrecorded registration"
10284 );
10285 assert!(
10286 host.controls
10287 .calls()
10288 .iter()
10289 .any(|call| call == "uninstall runner-manager (boot)"),
10290 "the registration must be explicitly rolled back: {:?}",
10291 host.controls.calls()
10292 );
10293 assert!(
10294 !host.runner_root.as_path().exists(),
10295 "the rollback must take the runner root this install created with it; a directory \
10296 prepared for a registration that does not exist is litter, and on Windows it is \
10297 litter with a security descriptor"
10298 );
10299 }
10300
10301 #[test]
10306 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
10307 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
10308
10309 assert_eq!(
10313 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
10314 ServiceAccount::LocalSystem
10315 );
10316 assert_eq!(
10317 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
10318 ServiceAccount::InvokingUser
10319 );
10320
10321 let boot = default_root_sddl(&RootAdmission::LocalSystem);
10322 assert!(!grants_broad_write(&boot), "{boot}");
10323 assert!(
10324 !boot.contains("S-1-5-21"),
10325 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
10326 );
10327
10328 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
10333 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
10334 assert!(!grants_broad_write(&login), "{login}");
10335 }
10336
10337 #[test]
10338 fn an_install_reports_the_runner_root_it_prepared() {
10339 let host = Host::new();
10340 let installed = host
10341 .operations()
10342 .install(&host.request(StartMode::Boot))
10343 .expect("an install");
10344 let rendered = installed.runner_root.to_string();
10345 assert!(
10346 !rendered.contains("S-1-5-21"),
10347 "the report must add no identity to the output: {rendered}"
10348 );
10349 if cfg!(windows) {
10350 assert_eq!(
10351 installed.runner_root.path(),
10352 Some(host.runner_root.as_path())
10353 );
10354 assert!(
10355 host.runner_root.as_path().is_dir(),
10356 "the directory jobs would run in has to exist once the service is registered"
10357 );
10358 } else {
10359 assert_eq!(
10360 installed.runner_root,
10361 crate::runner_root_access::RootAccessSummary::NotApplicable,
10362 "macOS and Linux keep the runtime directory they have always used"
10363 );
10364 }
10365 }
10366
10367 #[test]
10368 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
10369 let host = Host::new();
10370 let operations = host.operations();
10371 operations
10372 .install(&host.request(StartMode::Boot))
10373 .expect("an install at boot");
10374
10375 let change = operations
10376 .set_start_mode(StartMode::Login)
10377 .expect("a switch to login");
10378
10379 assert!(change.changed);
10380 if cfg!(windows) {
10381 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
10382 assert!(
10383 host.runner_root.as_path().is_dir(),
10384 "the switch must not remove the directory it reconciled"
10385 );
10386 }
10387 assert!(
10390 change.to_string().contains("runner root"),
10391 "{}",
10392 change.to_string()
10393 );
10394 }
10395
10396 #[test]
10397 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
10398 let host = Host::new();
10399 let operations = host.operations();
10400 operations
10401 .install(&host.request(StartMode::Boot))
10402 .expect("an install at boot");
10403
10404 let change = operations
10405 .set_start_mode(StartMode::Boot)
10406 .expect("a switch to the mode already in force");
10407
10408 assert!(!change.changed);
10409 assert_eq!(
10410 change.runner_root,
10411 crate::runner_root_access::RootAccessSummary::NotApplicable,
10412 "nothing moves, so nothing about the root's access control has to; reconciling here \
10413 would turn a no-op command into one that can fail on a permission it does not need"
10414 );
10415 }
10416
10417 #[cfg(windows)]
10418 #[test]
10419 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
10420 let host = Host::new();
10421 host.controls
10422 .fail_next_install(StartMode::Boot, "injected registration failure");
10423
10424 let error = host
10425 .operations()
10426 .install(&host.request(StartMode::Boot))
10427 .expect_err("the manager refuses the registration");
10428
10429 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
10430 assert!(
10431 !host.runner_root.as_path().exists(),
10432 "the directory was created for a registration that does not exist"
10433 );
10434 }
10435
10436 #[cfg(windows)]
10437 #[test]
10438 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
10439 let host = Host::new();
10440 crate::runner_root_access::create_with_descriptor_for_tests(
10444 host.runner_root.as_path(),
10445 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
10446 )
10447 .expect("a deliberately open runner root");
10448 let before = crate::runner_root_access::report(host.runner_root.as_path());
10449
10450 let error = host
10451 .operations()
10452 .install(&host.request(StartMode::Boot))
10453 .expect_err("an open runner root is refused");
10454
10455 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
10456 assert!(
10457 error.to_string().contains("nothing was registered"),
10458 "{error}"
10459 );
10460 assert!(
10461 host.controls.registrations().is_empty(),
10462 "the refusal has to come before the platform is asked to register anything: {:?}",
10463 host.controls.calls()
10464 );
10465 assert_eq!(
10466 crate::runner_root_access::report(host.runner_root.as_path()),
10467 before,
10468 "an open directory is refused rather than tightened: its contents cannot be trusted, \
10469 so adopting it would be worse than declining it"
10470 );
10471 }
10472
10473 #[cfg(windows)]
10474 #[test]
10475 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
10476 let host = Host::new();
10477 let operations = host.operations();
10478 operations
10479 .install(&host.request(StartMode::Boot))
10480 .expect("an install");
10481 assert!(host.runner_root.as_path().is_dir());
10482
10483 operations.uninstall().expect("an uninstall");
10484
10485 assert!(
10486 host.runner_root.as_path().is_dir(),
10487 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
10488 runner root may hold an operator's retained workspaces."
10489 );
10490 }
10491
10492 #[test]
10493 fn install_reviews_what_it_registered() {
10494 let host = Host::new();
10495 let installed = host
10496 .operations()
10497 .install(&host.request(StartMode::Boot))
10498 .expect("an install");
10499 assert!(
10500 installed.review.is_least_privilege(),
10501 "{}",
10502 installed.review
10503 );
10504 assert!(
10505 !installed.review.controls().is_empty(),
10506 "a review that confirms nothing proves nothing: {}",
10507 installed.review
10508 );
10509 assert_eq!(
10510 installed.review.kind(),
10511 host_definition_kind(StartMode::Boot),
10512 "the review must be of the definition this host's manager was given"
10513 );
10514 assert!(
10515 !installed.review.account().justification().is_empty(),
10516 "a privileged account with no stated reason is an unreviewed one"
10517 );
10518 }
10519
10520 #[test]
10525 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
10526 let host = Host::new();
10527 let operations = host.operations();
10528 operations
10529 .install(&host.request(StartMode::Boot))
10530 .expect("an install");
10531
10532 let config = host.paths.config_dir();
10536 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
10537 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
10538 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
10539 std::fs::write(
10540 host.paths
10541 .state_dir()
10542 .join("packages/2.330.0/runner.tar.gz"),
10543 b"cached package",
10544 )
10545 .expect("writable");
10546 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
10547 std::fs::write(
10548 host.paths.state_dir().join("secrets/user-access-token"),
10549 b"a stand-in for the stored credential",
10550 )
10551 .expect("writable");
10552 std::fs::write(
10553 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
10554 b"diagnostics",
10555 )
10556 .expect("writable");
10557
10558 let roots: Vec<PathBuf> = host
10559 .paths
10560 .all()
10561 .iter()
10562 .map(|(_, path)| (*path).to_path_buf())
10563 .collect();
10564 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
10565 let before = snapshot(&roots);
10566
10567 assert!(
10570 before.len() >= 6,
10571 "the fixture must actually contain the files this test is about, got {before:#?}"
10572 );
10573 let record_path = InstallRecord::path(&host.paths);
10574 assert!(
10575 before.contains_key(&record_path),
10576 "the install record must be present before uninstall"
10577 );
10578
10579 let uninstalled = operations.uninstall().expect("an uninstall");
10580 assert!(uninstalled.removed_registration);
10581 assert!(uninstalled.removed_record);
10582
10583 let after = snapshot(&roots);
10584
10585 let mut expected = before.clone();
10587 expected.remove(&record_path);
10588 assert_eq!(
10589 after, expected,
10590 "uninstall must remove its own record and nothing else"
10591 );
10592 assert!(
10593 !record_path.exists(),
10594 "the record itself must go, or `uninstall` did nothing at all"
10595 );
10596 assert!(
10597 uninstalled
10598 .preserved
10599 .iter()
10600 .all(|path| roots.contains(&path.as_path())),
10601 "the preserved list must name the four directories: {uninstalled}"
10602 );
10603 }
10604
10605 #[test]
10606 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
10607 let host = Host::new();
10608 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
10609 assert!(!uninstalled.removed_registration);
10610 assert!(!uninstalled.removed_record);
10611 }
10612
10613 #[test]
10614 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
10615 let host = Host::new();
10616 let operations = host.operations();
10617 operations
10618 .install(&host.request(StartMode::Boot))
10619 .expect("an install");
10620 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
10621
10622 let uninstalled = operations.uninstall().expect("an uninstall");
10623 assert!(
10624 uninstalled.removed_registration,
10625 "a lost record must not strand a registration"
10626 );
10627 assert!(host.controls.registrations().is_empty());
10628 }
10629
10630 #[test]
10635 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
10636 let host = Host::new();
10637 let operations = host.operations();
10638 operations
10639 .install(&host.request(StartMode::Boot))
10640 .expect("an install at boot");
10641
10642 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
10646
10647 let change = operations
10648 .set_start_mode(StartMode::Login)
10649 .expect("a switch that does not reinstall the product");
10650 assert!(change.changed);
10651 assert_eq!(change.from, StartMode::Boot);
10652 assert_eq!(change.to, StartMode::Login);
10653 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
10654
10655 let record = InstallRecord::read(&host.paths)
10656 .expect("readable")
10657 .expect("a record");
10658 assert_eq!(record.start_mode, StartMode::Login);
10659 assert_eq!(
10660 record.binary, host.binary,
10661 "the recorded path must survive the switch untouched"
10662 );
10663
10664 let registrations = host.controls.registrations();
10665 assert_eq!(registrations.len(), 1, "{registrations:?}");
10666 assert_eq!(registrations[0].0, StartMode::Login);
10667 assert!(
10668 registrations[0]
10669 .2
10670 .command_line
10671 .contains(&host.binary.to_string_lossy().into_owned()),
10672 "{:?}",
10673 registrations[0].2
10674 );
10675 }
10676
10677 #[test]
10678 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
10679 let host = Host::new();
10680 let operations = host.operations();
10681 operations
10682 .install(&host.request(StartMode::Boot))
10683 .expect("an install at boot");
10684 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
10685 host.controls
10686 .fail_next_install(StartMode::Login, "injected target failure");
10687
10688 let error = operations
10689 .set_start_mode(StartMode::Login)
10690 .expect_err("the target manager refuses the install");
10691
10692 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
10693 assert_eq!(
10694 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
10695 record_before
10696 );
10697 let registrations = host.controls.registrations();
10698 assert_eq!(registrations.len(), 1, "{registrations:?}");
10699 assert_eq!(registrations[0].0, StartMode::Boot);
10700 }
10701
10702 #[test]
10703 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
10704 let host = Host::new();
10705 let operations = host.operations();
10706 operations
10707 .install(&host.request(StartMode::Boot))
10708 .expect("an install at boot");
10709 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
10710 let config = host.paths.config_dir().to_path_buf();
10711 let hidden = config.with_file_name("config-hidden-by-fault");
10712 host.controls.hide_directory_after_install(
10713 StartMode::Login,
10714 config.clone(),
10715 hidden.clone(),
10716 );
10717
10718 let error = operations
10719 .set_start_mode(StartMode::Login)
10720 .expect_err("the injected filesystem fault prevents persistence");
10721
10722 std::fs::remove_file(&config).expect("remove the injected blocker");
10723 std::fs::rename(&hidden, &config).expect("restore the record directory");
10724 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10725 assert_eq!(
10726 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
10727 record_before
10728 );
10729 let registrations = host.controls.registrations();
10730 assert_eq!(registrations.len(), 1, "{registrations:?}");
10731 assert_eq!(registrations[0].0, StartMode::Boot);
10732 assert!(
10733 host.controls
10734 .calls()
10735 .iter()
10736 .any(|call| call == "uninstall runner-manager (login)"),
10737 "the target must be rolled back: {:?}",
10738 host.controls.calls()
10739 );
10740 }
10741
10742 #[test]
10743 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
10744 let host = Host::new();
10745 let operations = host.operations();
10746 operations
10747 .install(&host.request(StartMode::Boot))
10748 .expect("an install");
10749 let before = host.controls.calls().len();
10750
10751 let change = operations
10752 .set_start_mode(StartMode::Boot)
10753 .expect("a no-op switch");
10754 assert!(!change.changed);
10755 assert_eq!(
10756 host.controls.calls().len(),
10757 before,
10758 "a no-op switch must not touch the service manager"
10759 );
10760 }
10761
10762 #[test]
10763 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
10764 let host = Host::new();
10765 let error = host
10766 .operations()
10767 .set_start_mode(StartMode::Login)
10768 .expect_err("there is nothing to switch");
10769 assert!(
10770 matches!(error, ServiceError::NotInstalled { .. }),
10771 "{error}"
10772 );
10773 }
10774
10775 #[test]
10780 fn status_reports_the_four_facts_journey_five_asks_for() {
10781 let host = Host::new();
10782 let operations = host.operations();
10783 operations
10784 .install(&host.request(StartMode::Boot))
10785 .expect("an install");
10786 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
10787 .expect("a valid timestamp")
10788 .with_timezone(&Utc);
10789 record_github_contact(&host.paths, at).expect("a heartbeat");
10790
10791 let status = operations.status().expect("a status");
10792 assert_eq!(status.start_mode(), Some(StartMode::Boot));
10793 assert_eq!(
10794 status.binary().map(BinaryPath::recorded),
10795 Some(host.binary.as_path())
10796 );
10797 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
10798 assert_eq!(status.last_github_contact(), Some(at));
10799 assert!(status.is_installed());
10800 assert!(status.is_healthy(), "{status}");
10801
10802 let printed = status.to_string();
10803 for fragment in [
10804 "start mode",
10805 "diagnostic log",
10806 "last GitHub contact",
10807 "binary",
10808 ] {
10809 assert!(printed.contains(fragment), "{printed}");
10810 }
10811 }
10812
10813 #[cfg(unix)]
10825 #[test]
10826 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
10827 use std::os::unix::fs::PermissionsExt as _;
10828
10829 if unsafe { libc::geteuid() } == 0 {
10833 return;
10834 }
10835
10836 let host = Host::new();
10837 let operations = host.operations();
10838 operations
10839 .install(&host.request(StartMode::Boot))
10840 .expect("an install");
10841 assert!(
10842 operations.status().expect("a status").is_healthy(),
10843 "the discriminator: healthy before the record is made unreadable"
10844 );
10845
10846 let record = InstallRecord::path(&host.paths);
10847 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
10848 .expect("the mode is applied");
10849
10850 let status = operations
10851 .status()
10852 .expect("a record this account may not read is reported, not thrown");
10853 assert!(status.is_installed(), "{status}");
10854 assert!(!status.is_healthy(), "{status}");
10855
10856 let printed = status.to_string();
10857 assert!(
10858 printed.contains("this account may not read it"),
10859 "the operator is told which of the two states this is: {printed}"
10860 );
10861 assert!(
10862 !printed.contains("there is no install record"),
10863 "a record that is there and unreadable is not a record that is missing, and the \
10864 missing one's remedy starts with `service uninstall`: {printed}"
10865 );
10866
10867 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
10870 .expect("the mode is restored");
10871 }
10872
10873 #[test]
10874 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
10875 let host = Host::new();
10876 let operations = host.operations();
10877 operations
10878 .install(&host.request(StartMode::Boot))
10879 .expect("an install");
10880
10881 assert!(
10884 operations.status().expect("a status").is_healthy(),
10885 "the freshly installed host must be healthy"
10886 );
10887
10888 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
10889
10890 let status = operations.status().expect("a status");
10891 assert!(!status.is_healthy(), "{status}");
10892 assert!(
10893 status
10894 .problems()
10895 .iter()
10896 .any(|problem| problem.subject == "binary"),
10897 "{status}"
10898 );
10899 assert!(status.to_string().contains("STALE"), "{status}");
10900 }
10901
10902 #[test]
10903 fn status_reports_a_registration_that_would_not_start_at_boot() {
10904 let host = Host::new();
10905 let operations = host.operations();
10906 operations
10907 .install(&host.request(StartMode::Boot))
10908 .expect("an install");
10909 assert!(operations.status().expect("a status").is_healthy());
10910
10911 host.controls.edit("runner-manager", |registration| {
10912 registration.starts_automatically = false;
10913 });
10914
10915 let status = operations.status().expect("a status");
10916 assert!(!status.is_healthy(), "{status}");
10917 assert!(
10918 status
10919 .problems()
10920 .iter()
10921 .any(|problem| problem.detail.contains("after a reboot")),
10922 "{status}"
10923 );
10924 }
10925
10926 #[test]
10927 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
10928 let host = Host::new();
10929 let operations = host.operations();
10930 operations
10931 .install(&host.request(StartMode::Login))
10932 .expect("install and immediate start");
10933
10934 assert!(
10935 operations.status().expect("running status").is_running(),
10936 "install must not wait for the next login trigger"
10937 );
10938 operations.stop().expect("stop the registration");
10939 let status = operations.status().expect("stopped status");
10940 assert!(!status.is_healthy(), "{status}");
10941 assert!(
10942 status
10943 .problems()
10944 .iter()
10945 .any(|problem| problem.subject == "runtime"),
10946 "{status}"
10947 );
10948 let rendered = status.to_string();
10949 assert!(
10950 rendered.contains("runner-manager service install --start-at login"),
10951 "{rendered}"
10952 );
10953 assert!(
10954 !rendered.contains("runner-manager service start"),
10955 "{rendered}"
10956 );
10957 }
10958
10959 #[test]
10960 fn status_reports_a_restart_policy_something_else_edited() {
10961 let host = Host::new();
10962 let operations = host.operations();
10963 operations
10964 .install(&host.request(StartMode::Boot))
10965 .expect("an install");
10966 assert!(operations.status().expect("a status").is_healthy());
10967
10968 host.controls.edit("runner-manager", |registration| {
10969 registration.restart_delay = Some(Duration::from_secs(1));
10970 });
10971
10972 let status = operations.status().expect("a status");
10973 assert!(!status.is_healthy(), "{status}");
10974 assert!(
10975 status
10976 .problems()
10977 .iter()
10978 .any(|problem| problem.subject == "restart policy"),
10979 "{status}"
10980 );
10981 }
10982
10983 #[test]
10984 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
10985 let host = Host::new();
10986 let operations = host.operations();
10987 operations
10988 .install(&host.request(StartMode::Boot))
10989 .expect("an install");
10990 let other = host.binary.with_file_name("someone-elses.exe");
10991 std::fs::write(&other, b"x").expect("writable");
10992
10993 host.controls.edit("runner-manager", |registration| {
10994 registration.command_line = quote_argument(&other.to_string_lossy());
10995 });
10996
10997 let status = operations.status().expect("a status");
10998 assert!(!status.is_healthy(), "{status}");
10999 assert!(
11000 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11001 "{status}"
11002 );
11003 }
11004
11005 #[test]
11006 fn status_reports_a_record_no_service_manager_knows_about() {
11007 let host = Host::new();
11008 let operations = host.operations();
11009 operations
11010 .install(&host.request(StartMode::Boot))
11011 .expect("an install");
11012 for mode in [StartMode::Boot, StartMode::Login] {
11014 host.controls
11015 .control(mode)
11016 .expect("a control")
11017 .uninstall(&ServiceIdentity::product())
11018 .expect("removed");
11019 }
11020
11021 let status = operations.status().expect("a status");
11022 assert!(!status.is_healthy(), "{status}");
11023 assert!(
11024 status
11025 .problems()
11026 .iter()
11027 .any(|problem| problem.subject == "registration"),
11028 "{status}"
11029 );
11030 }
11031
11032 #[test]
11033 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
11034 let host = Host::new();
11035 let status = host.operations().status().expect("a status");
11036 assert!(!status.is_installed());
11037 assert!(
11038 status.is_healthy(),
11039 "a host that never installed the service has no fault to report: {status}"
11040 );
11041 assert!(status.to_string().contains("installed"), "{status}");
11042 }
11043
11044 #[test]
11045 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
11046 let host = Host::new();
11047 let operations = host.operations();
11048 operations
11049 .install(&host.request(StartMode::Login))
11050 .expect("an install at login");
11051 let status = operations.status().expect("a status");
11052 assert!(
11053 status
11054 .notes()
11055 .iter()
11056 .any(|note| note.contains("does not run until the operator signs in")),
11057 "05-infrastructure.md requires `service status` to say so: {status}"
11058 );
11059 }
11060
11061 #[test]
11064 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
11065 use crate::unattended_login::{AutoLogin, UnattendedLogin};
11066 let automatic = UnattendedLogin {
11067 auto_login: AutoLogin::As("ivan".into()),
11068 filevault_on: Some(false),
11069 };
11070 assert_eq!(
11071 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
11072 None
11073 );
11074
11075 let off = UnattendedLogin {
11076 auto_login: AutoLogin::Off,
11077 filevault_on: Some(false),
11078 };
11079 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
11080 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
11081 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
11082
11083 let locked = UnattendedLogin {
11084 auto_login: AutoLogin::As("ivan".into()),
11085 filevault_on: Some(true),
11086 };
11087 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
11088 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
11089
11090 let unread = UnattendedLogin {
11093 auto_login: AutoLogin::As("ivan".into()),
11094 filevault_on: None,
11095 };
11096 assert_eq!(
11097 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
11098 None
11099 );
11100
11101 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
11103 assert!(
11104 gap.reason
11105 .contains("does not run until the operator signs in")
11106 );
11107 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
11108 }
11109
11110 #[test]
11113 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
11114 let host = Host::new();
11115 let operations = host.operations();
11116 operations
11117 .install(&host.request(StartMode::Login))
11118 .expect("an install at login");
11119 let stalled_problem = |operations: &ServiceOperations| {
11120 operations
11121 .status()
11122 .expect("a status")
11123 .problems()
11124 .iter()
11125 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
11126 .map(|problem| problem.detail.clone())
11127 };
11128 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
11129 assert_eq!(stalled_problem(&operations), None, "beating");
11130
11131 crate::daemon_heartbeat::beat(
11132 &operations.paths,
11133 Utc::now() - chrono::Duration::minutes(20),
11134 )
11135 .unwrap();
11136 let detail = stalled_problem(&operations).expect("a stall is a problem");
11137 assert!(detail.contains("has made no progress since"), "{detail}");
11138 let restart = ServiceIdentity::product().restart_command(StartMode::Login);
11141 assert!(detail.contains(&restart), "{detail}");
11142 assert!(!detail.contains("runner-manager service stop"), "{detail}");
11143 assert!(!detail.contains("runner-manager service start"), "{detail}");
11144 }
11145
11146 #[test]
11150 fn the_restart_command_is_the_service_managers_own() {
11151 let identity = ServiceIdentity::product();
11152 let boot = identity.restart_command(StartMode::Boot);
11153 let login = identity.restart_command(StartMode::Login);
11154 for command in [&boot, &login] {
11155 assert!(!command.contains("runner-manager service"), "{command}");
11156 assert!(command.contains(identity.name()), "{command}");
11157 }
11158 assert_ne!(boot, login);
11159 if cfg!(windows) {
11160 assert!(
11161 boot.starts_with("Restart-Service -Name runner-manager"),
11162 "{boot}"
11163 );
11164 assert!(
11165 login.contains("schtasks /Run /TN runner-manager"),
11166 "{login}"
11167 );
11168 } else if cfg!(target_os = "macos") {
11169 assert!(
11170 boot.starts_with("sudo launchctl kickstart -k system/"),
11171 "{boot}"
11172 );
11173 assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
11174 } else {
11175 assert_eq!(boot, "sudo systemctl restart runner-manager.service");
11176 assert_eq!(login, "systemctl --user restart runner-manager.service");
11177 }
11178 }
11179
11180 #[test]
11182 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
11183 let host = Host::new();
11184 let operations = host.operations();
11185 operations
11186 .install(&host.request(StartMode::Login))
11187 .expect("an install at login");
11188 let paths = operations.paths.clone();
11189 crate::host_fitness::record_host_unfit(
11190 &paths,
11191 &[crate::host_fitness::UnfitFinding {
11192 id: "host.runner_root_responsive".into(),
11193 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
11194 remedy: Some("click Allow".into()),
11195 }],
11196 Utc::now(),
11197 )
11198 .unwrap();
11199 let status = operations.status().expect("a status");
11200 assert!(!status.is_healthy(), "{status}");
11201 let problem = status
11202 .problems()
11203 .iter()
11204 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
11205 .expect("the refusal is a problem");
11206 assert!(
11207 problem.detail.contains("/Volumes/NVME/runners"),
11208 "{problem:?}"
11209 );
11210 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
11211
11212 crate::host_fitness::clear_host_unfit(&paths).unwrap();
11215 crate::host_fitness::record_host_unfit(
11216 &paths,
11217 &[],
11218 Utc::now() - chrono::Duration::hours(2),
11219 )
11220 .unwrap();
11221 let status = operations.status().expect("a status");
11222 assert!(
11223 !status
11224 .problems()
11225 .iter()
11226 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
11227 "{status}"
11228 );
11229 }
11230
11231 #[test]
11236 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
11237 let host = Host::new();
11238 let operations = host.operations();
11239 operations
11240 .install(&host.request(StartMode::Login))
11241 .expect("an install at login");
11242 operations.start().expect("a start");
11243 assert!(operations.status().expect("a status").is_running());
11244 assert!(operations.stop().expect("a stop"));
11245 assert!(!operations.status().expect("a status").is_running());
11246 }
11247
11248 #[test]
11249 fn starting_a_host_with_no_registration_is_refused() {
11250 let host = Host::new();
11251 let error = host.operations().start().expect_err("nothing to start");
11252 assert!(
11253 matches!(error, ServiceError::NotInstalled { .. }),
11254 "{error}"
11255 );
11256 }
11257
11258 #[cfg(windows)]
11274 #[test]
11275 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
11276 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
11277
11278 let root = tempfile::tempdir().expect("a temporary directory");
11279 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
11280 .expect("a rooted machine-scoped store");
11281 store
11282 .store(&secrecy::SecretString::from("a stand-in for the token"))
11283 .expect("the store accepts a value");
11284 let protection = store.protection().expect("the DACL can be read back");
11285
11286 assert!(
11287 protection.description().contains(";;;SY)"),
11288 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
11289 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
11290 protection.description()
11291 );
11292 assert_eq!(
11293 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11294 ServiceAccount::LocalSystem,
11295 "and that is the account this installer registers, which is why SY is what matters"
11296 );
11297 assert!(
11298 !protection.readable_by_other_local_users(),
11299 "the same DACL must still exclude ordinary local users: {}",
11300 protection.description()
11301 );
11302
11303 for rejected in [";;;LS)", ";;;NS)"] {
11307 assert!(
11308 !protection.description().contains(rejected),
11309 "if the store ever admitted {rejected}, the least-privilege analysis in \
11310 docs/service-account.md would need redoing: {}",
11311 protection.description()
11312 );
11313 }
11314 }
11315
11316 fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
11321 let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
11322 arguments.extend(ServiceDirectories::of(&host.paths).arguments());
11323 host.request(mode).with_arguments(arguments)
11324 }
11325
11326 fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
11329 ServiceOperations::with_controls(
11330 account.paths.clone(),
11331 ServiceIdentity::product(),
11332 std::sync::Arc::new(machine.controls.clone()),
11333 )
11334 .with_runner_root(account.runner_root.clone())
11335 }
11336
11337 fn a_machine_with_a_stale_second_account() -> (Host, Host) {
11341 let owner = Host::new();
11342 owner
11343 .operations()
11344 .install(&request_with_directories(&owner, StartMode::Boot))
11345 .expect("the owner's boot install");
11346 let earlier = Host::new();
11347 earlier
11348 .operations()
11349 .install(&request_with_directories(&earlier, StartMode::Login))
11350 .expect("the second account's earlier login install");
11351 assert!(
11352 InstallRecord::read(&earlier.paths).unwrap().is_some(),
11353 "the earlier install left its record behind"
11354 );
11355 (owner, earlier)
11356 }
11357
11358 const DESTRUCTIVE_ADVICE: [&str; 4] = [
11361 "service uninstall",
11362 "service install`",
11363 "service install --",
11364 "auth login",
11365 ];
11366
11367 #[test]
11368 fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
11369 let (owner, earlier) = a_machine_with_a_stale_second_account();
11370
11371 let status = second_account(&earlier, &owner).status().expect("a status");
11372
11373 let foreign = status.foreign().expect("the service is the owner's");
11374 assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
11375 assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
11376 assert!(foreign.unreadable.is_none(), "{status}");
11377 assert!(
11378 status.is_healthy(),
11379 "the owner's service is healthy, and a stale record elsewhere is not its fault: \
11380 {status}"
11381 );
11382 for subject in [
11383 "binary",
11384 "start mode",
11385 "secret store",
11386 "record",
11387 "registration",
11388 ] {
11389 assert!(
11390 !status
11391 .problems()
11392 .iter()
11393 .any(|problem| problem.subject == subject),
11394 "{subject} compared the second account's record against the owner's \
11395 registration: {status}"
11396 );
11397 }
11398 let rendered = status.to_string();
11399 for advice in DESTRUCTIVE_ADVICE {
11400 assert!(!rendered.contains(advice), "{advice}: {rendered}");
11401 }
11402 assert!(rendered.contains("another installation's"), "{rendered}");
11403 assert!(
11404 status
11405 .notes()
11406 .iter()
11407 .any(|note| note.contains("unused local configuration")
11408 && note.contains("host forget-local")),
11409 "{status}"
11410 );
11411 assert_eq!(status.start_mode(), Some(StartMode::Boot));
11413 }
11414
11415 #[test]
11416 fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
11417 let (owner, earlier) = a_machine_with_a_stale_second_account();
11418 InstallRecord::remove(&owner.paths).expect("the owner's record removed");
11421
11422 let operations = second_account(&earlier, &owner);
11423 let status = operations.status().expect("a status");
11424 let foreign = status.foreign().expect("the service is the owner's");
11425 assert!(foreign.unreadable.is_some(), "{status}");
11426 assert!(
11427 status.is_healthy(),
11428 "running, and nothing it could judge: {status}"
11429 );
11430 let rendered = status.to_string();
11431 for advice in DESTRUCTIVE_ADVICE {
11432 assert!(!rendered.contains(advice), "{advice}: {rendered}");
11433 }
11434 assert!(rendered.contains("running; installed from"), "{rendered}");
11435 assert!(!rendered.contains("last GitHub contact"), "{rendered}");
11436
11437 owner.operations().stop().expect("stopped");
11439 let stopped = operations.status().expect("a status");
11440 assert!(!stopped.is_healthy(), "{stopped}");
11441 let rendered = stopped.to_string();
11442 for advice in DESTRUCTIVE_ADVICE {
11443 assert!(!rendered.contains(advice), "{advice}: {rendered}");
11444 }
11445 }
11446
11447 #[test]
11448 fn this_accounts_own_registration_still_reports_every_mismatch() {
11449 let host = Host::new();
11450 let operations = host.operations();
11451 operations
11452 .install(&request_with_directories(&host, StartMode::Boot))
11453 .expect("an install");
11454 let other = host.binary.with_file_name("someone-elses");
11455 std::fs::write(&other, b"x").expect("writable");
11456 host.controls.edit("runner-manager", |registration| {
11458 let mut arguments = split_command_line(®istration.command_line);
11459 arguments[0] = other.to_string_lossy().into_owned();
11460 registration.command_line = arguments
11461 .iter()
11462 .map(|argument| quote_argument(argument))
11463 .collect::<Vec<_>>()
11464 .join(" ");
11465 registration.start_mode = StartMode::Login;
11466 });
11467
11468 let status = operations.status().expect("a status");
11469 assert!(status.foreign().is_none(), "{status}");
11470 assert!(
11471 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11472 "{status}"
11473 );
11474 assert!(
11475 status
11476 .problems()
11477 .iter()
11478 .any(|problem| problem.subject == "start mode"),
11479 "{status}"
11480 );
11481 assert!(status.to_string().contains("service uninstall"), "{status}");
11482 }
11483
11484 #[test]
11485 fn directories_read_back_from_a_command_line_with_spaces() {
11486 let root = Path::new("/Users/someone/Library/Application Support/runner manager");
11487 let directories = ServiceDirectories {
11488 config: root.join("config"),
11489 state: root.join("data").join("state"),
11490 runtime: root.join("data").join("runtime"),
11491 logs: root.join("data").join("logs"),
11492 };
11493 let mut command_line = quote_argument("/usr/local/bin/runner-manager");
11494 for argument in ["daemon", "run"]
11495 .map(OsString::from)
11496 .into_iter()
11497 .chain(directories.arguments())
11498 {
11499 command_line.push(' ');
11500 command_line.push_str("e_argument(&argument.to_string_lossy()));
11501 }
11502 assert_eq!(
11503 ServiceDirectories::from_command_line(&command_line),
11504 Some(directories.clone())
11505 );
11506 assert_eq!(directories.root(), root);
11507 assert_eq!(
11508 ServiceDirectories::from_command_line("runner-manager daemon run"),
11509 None,
11510 "a registration that names no directories cannot be judged foreign"
11511 );
11512 }
11513
11514 #[test]
11515 fn the_owner_of_a_data_root_is_read_from_its_path() {
11516 let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
11517 let home = users.join("ivand");
11518 assert_eq!(
11519 data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
11520 Some("yuriv")
11521 );
11522 assert_eq!(
11523 data_root_owner(
11524 Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
11525 Some(&home)
11526 ),
11527 None
11528 );
11529 if cfg!(windows) {
11530 assert_eq!(
11531 data_root_owner(
11532 Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
11533 Some(&home)
11534 )
11535 .as_deref(),
11536 Some("LocalSystem")
11537 );
11538 } else {
11539 assert_eq!(
11540 data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
11541 Some("root")
11542 );
11543 }
11544 }
11545
11546 #[test]
11547 fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
11548 let (owner, earlier) = a_machine_with_a_stale_second_account();
11549 let owner_before = snapshot(&[owner._root.path()]);
11550 let config = earlier.paths.config_dir().to_path_buf();
11551 let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
11552
11553 let forgotten = second_account(&earlier, &owner)
11554 .forget_local(Utc::now())
11555 .expect("archived");
11556
11557 assert_eq!(
11558 snapshot(&[owner._root.path()]),
11559 owner_before,
11560 "the owner's directories must not change by one byte"
11561 );
11562 let archive = forgotten.archive.expect("something was archived");
11563 assert!(archive.starts_with(&config), "{}", archive.display());
11564 assert_eq!(
11565 std::fs::read(archive.join(RECORD_FILE)).unwrap(),
11566 record,
11567 "moved, not rewritten or deleted"
11568 );
11569 assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
11570 assert_eq!(
11571 forgotten.service_root,
11572 Some(ServiceDirectories::of(&owner.paths).root())
11573 );
11574 let status = second_account(&earlier, &owner).status().expect("status");
11576 assert!(
11577 !status
11578 .notes()
11579 .iter()
11580 .any(|note| note.contains("unused local configuration")),
11581 "{status}"
11582 );
11583 }
11584
11585 #[test]
11586 fn forget_local_refuses_a_configuration_a_service_runs_against() {
11587 let (owner, earlier) = a_machine_with_a_stale_second_account();
11588 let before = snapshot(&[owner._root.path()]);
11589 let refused = owner.operations().forget_local(Utc::now());
11590 assert!(
11591 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
11592 "{refused:?}"
11593 );
11594 assert_eq!(snapshot(&[owner._root.path()]), before);
11595
11596 let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
11598 .expect("the agent's lock");
11599 let refused = second_account(&earlier, &owner).forget_local(Utc::now());
11600 assert!(
11601 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
11602 "{refused:?}"
11603 );
11604 assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
11605 }
11606
11607 #[test]
11608 fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
11609 let (owner, earlier) = a_machine_with_a_stale_second_account();
11610 owner.operations().stop().expect("stopped");
11613 record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
11614 record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
11615
11616 let status = second_account(&earlier, &owner).status().expect("a status");
11617 assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
11618 let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
11619 for subject in [
11620 "runtime",
11621 "github credential",
11622 CREDENTIAL_UNREADABLE_SUBJECT,
11623 ] {
11624 assert!(subjects.contains(&subject), "{subject}: {status}");
11625 }
11626 let rendered = status.to_string();
11627 for advice in DESTRUCTIVE_ADVICE {
11628 assert!(!rendered.contains(advice), "{advice}: {rendered}");
11629 }
11630 for problem in status.problems() {
11631 assert!(problem.detail.contains("Act on it"), "{problem}");
11632 }
11633 }
11634
11635 #[test]
11636 fn no_command_of_this_account_changes_another_installations_service() {
11637 let (owner, earlier) = a_machine_with_a_stale_second_account();
11638 let operations = second_account(&earlier, &owner);
11639 let foreign = |result: Result<(), ServiceError>| {
11640 assert!(
11641 matches!(result, Err(ServiceError::ForeignInstallation { .. })),
11642 "{result:?}"
11643 );
11644 };
11645 foreign(
11646 operations
11647 .install(&request_with_directories(&earlier, StartMode::Boot))
11648 .map(drop),
11649 );
11650 foreign(operations.uninstall().map(drop));
11651 foreign(operations.set_start_mode(StartMode::Boot).map(drop));
11652 foreign(operations.start());
11653 foreign(operations.stop().map(drop));
11654
11655 let registration = owner
11656 .controls
11657 .control(StartMode::Boot)
11658 .unwrap()
11659 .query(&ServiceIdentity::product())
11660 .unwrap()
11661 .expect("the owner's registration is still there");
11662 assert_eq!(
11663 ServiceDirectories::from_command_line(®istration.command_line),
11664 Some(ServiceDirectories::of(&owner.paths)),
11665 "and still runs against the owner's directories"
11666 );
11667 assert!(registration.running, "and was never stopped");
11668 assert!(
11669 InstallRecord::read(&earlier.paths).unwrap().is_some(),
11670 "the refused uninstall kept this account's record"
11671 );
11672 }
11673
11674 #[test]
11675 fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
11676 let root = tempfile::tempdir().unwrap();
11679 let config = root.path().join("Application Support");
11680 let paths = AppPaths::from_directories(
11681 &config,
11682 config.join("state"),
11683 config.join("runtime"),
11684 config.join("logs"),
11685 );
11686 paths.create_all().unwrap();
11687 std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
11688 std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
11689 let operations = ServiceOperations::with_controls(
11690 paths.clone(),
11691 ServiceIdentity::product(),
11692 std::sync::Arc::new(RecordingControls::new()),
11693 );
11694
11695 let forgotten = operations.forget_local(Utc::now()).expect("archived");
11696
11697 assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
11698 for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
11699 assert!(kept.is_dir(), "{} was moved", kept.display());
11700 }
11701 assert!(paths.logs_dir().join("runner-manager.log").is_file());
11702 }
11703
11704 #[test]
11705 fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
11706 for (detail, diagnosis) in [
11707 (
11708 "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
11709 service install --start-at boot`; if the service manager denies replacement",
11710 "x holds an automatic registration, but the daemon is stopped",
11711 ),
11712 (
11713 "store -- MISMATCH. Run `auth login` again so the token is stored",
11714 "store -- MISMATCH",
11715 ),
11716 (
11717 "unit differs, so the daemon runs under outdated settings. Re-register it with \
11718 `sudo runner-manager service install --start-at boot`",
11719 "unit differs, so the daemon runs under outdated settings",
11720 ),
11721 (
11722 "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
11723 "it starts no runner; the log has it",
11724 ),
11725 (
11726 "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
11727 install`",
11728 "C:\\a\\runner-manager.exe -- STALE: moved",
11729 ),
11730 (
11731 "a new builder: run `runner-manager service install` here, or not",
11732 "the installation's own records report this",
11733 ),
11734 ] {
11735 assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
11736 }
11737 }
11738}