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
303impl fmt::Display for ServiceIdentity {
304 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305 f.write_str(&self.name)
306 }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct RestartPolicy {
324 delay: Duration,
325 reset_after: Duration,
326}
327
328impl RestartPolicy {
329 pub const MIN_DELAY: Duration = Duration::from_secs(1);
336
337 pub const MAX_DELAY: Duration = Duration::from_secs(300);
340
341 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
343
344 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
346
347 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
355 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
356 return Err(ServiceError::RestartDelay {
357 requested_secs: delay.as_secs(),
358 min_secs: Self::MIN_DELAY.as_secs(),
359 max_secs: Self::MAX_DELAY.as_secs(),
360 });
361 }
362 if reset_after <= delay {
363 return Err(ServiceError::RestartResetWindow {
364 reset_secs: reset_after.as_secs(),
365 delay_secs: delay.as_secs(),
366 });
367 }
368 Ok(Self { delay, reset_after })
369 }
370
371 #[must_use]
373 pub const fn delay(&self) -> Duration {
374 self.delay
375 }
376
377 #[must_use]
379 pub const fn reset_after(&self) -> Duration {
380 self.reset_after
381 }
382
383 #[must_use]
400 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
401 match kind {
402 DefinitionKind::WindowsScheduledTask => {
403 let seconds = self.delay.as_secs();
404 let minutes = seconds.div_ceil(60);
405 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
406 }
407 _ => self.delay,
408 }
409 }
410}
411
412impl Default for RestartPolicy {
413 fn default() -> Self {
414 Self {
415 delay: Self::DEFAULT_DELAY,
416 reset_after: Self::DEFAULT_RESET_AFTER,
417 }
418 }
419}
420
421impl fmt::Display for RestartPolicy {
422 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423 write!(
424 f,
425 "restart after {}s, failure count resets after {}s",
426 self.delay.as_secs(),
427 self.reset_after.as_secs()
428 )
429 }
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "snake_case")]
444pub enum ServiceAccount {
445 LocalSystem,
448 Root,
451 InvokingUser,
453}
454
455impl ServiceAccount {
456 #[must_use]
465 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
466 match (kind, mode) {
467 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
468 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
469 (_, StartMode::Boot) => Self::Root,
470 }
471 }
472
473 #[must_use]
476 pub const fn for_start_mode(mode: StartMode) -> Self {
477 Self::for_definition(host_definition_kind(mode), mode)
478 }
479
480 #[must_use]
482 pub const fn as_str(&self) -> &'static str {
483 match self {
484 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
485 Self::Root => "root",
486 Self::InvokingUser => "the invoking user",
487 }
488 }
489
490 #[must_use]
497 pub const fn justification(&self) -> &'static str {
498 match self {
499 Self::LocalSystem => {
500 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
501 NetworkService cannot read it, and widening the DACL to reach them would grant \
502 every service on this host read access to the one credential this product holds"
503 }
504 Self::Root => {
505 "a boot-time registration runs outside every login session: on macOS the System \
506 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
507 machine-scoped store is a 0600 file under /var/lib that only root can open \
508 before a session exists"
509 }
510 Self::InvokingUser => {
511 "a login-mode registration reads the user-scoped store, which is deliberately \
512 readable by exactly one account and needs no elevation at all"
513 }
514 }
515 }
516
517 #[must_use]
519 pub const fn needs_elevation(&self) -> bool {
520 matches!(self, Self::LocalSystem | Self::Root)
521 }
522}
523
524impl fmt::Display for ServiceAccount {
525 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526 f.write_str(self.as_str())
527 }
528}
529
530#[derive(Debug, thiserror::Error)]
541pub enum ServiceError {
542 #[error("cannot install the service while an agent is already running on this host: {source}")]
548 LockHeld {
549 #[source]
551 source: Box<LockError>,
552 },
553
554 #[error(
557 "cannot tell whether an agent is already running on this host, so the service was not \
558 installed: {source}. Fix the reported problem with the state directory and try again."
559 )]
560 LockUnreadable {
561 #[source]
563 source: Box<LockError>,
564 },
565
566 #[error(
574 "this registration would run jobs under the default runner root, and that root could \
575 not be prepared, so nothing was registered: {source}"
576 )]
577 RunnerRoot {
578 #[source]
580 source: Box<RootAccessError>,
581 },
582
583 #[error(
585 "cannot resolve the absolute path of this executable, so there is nothing to register: \
586 {detail}. Run the installer from the installed binary rather than through a shell \
587 function or a wrapper that replaces argv[0]."
588 )]
589 BinaryPath {
590 detail: String,
592 },
593
594 #[error(
596 "{} is not a file, so registering it would create a service that cannot start. Install \
597 the product first, then run `service install` from the installed binary.",
598 path.display()
599 )]
600 BinaryMissing {
601 path: PathBuf,
603 },
604
605 #[error(
607 "a restart delay of {requested_secs}s is outside the supported range \
608 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
609 value, so `service status` would report a delay that is not the one in force."
610 )]
611 RestartDelay {
612 requested_secs: u64,
614 min_secs: u64,
616 max_secs: u64,
618 },
619
620 #[error(
622 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
623 restart delay, so the count would reset between every pair of restarts and no \
624 start limit could ever apply."
625 )]
626 RestartResetWindow {
627 reset_secs: u64,
629 delay_secs: u64,
631 },
632
633 #[error(
639 "{name} is already registered to start at {existing}, and this asks for {requested}. \
640 Changing the start mode moves the registration between two different service \
641 managers, so it is not something an install does by itself: run `service uninstall` \
642 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
643 switches it in place and keeps the registration running throughout."
644 )]
645 AlreadyInstalled {
646 name: String,
648 existing: StartMode,
650 requested: StartMode,
652 },
653
654 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
656 NotInstalled {
657 name: String,
659 operation: &'static str,
661 },
662
663 #[error("cannot {operation} the service record {}: {detail}", path.display())]
665 Record {
666 operation: &'static str,
668 path: PathBuf,
670 detail: String,
672 },
673
674 #[error(
676 "the service record {} was not written by this product, or was written by a version \
677 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
678 neither touches configuration, secrets, or the cache.",
679 path.display()
680 )]
681 RecordUnreadable {
682 path: PathBuf,
684 detail: String,
686 },
687
688 #[error(
702 "the service record {} is there and this account may not read it: {detail}. It was \
703 written by whichever account installed the service -- on a boot-mode host, `sudo \
704 service install`. Running `service install` again rewrites it readable; until then \
705 only what the service manager itself reports is available.",
706 path.display()
707 )]
708 RecordNotPermitted {
709 path: PathBuf,
711 detail: String,
713 },
714
715 #[error("cannot prepare this host's application-data directories: {source}")]
717 Paths {
718 #[source]
720 source: Box<crate::paths::PathsError>,
721 },
722
723 #[error("cannot {operation} {name} through {manager}: {detail}")]
725 Control {
726 operation: &'static str,
728 name: String,
730 manager: &'static str,
732 detail: String,
734 },
735
736 #[error(
738 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
739 Inspect `service status` before retrying."
740 )]
741 Rollback {
742 operation: &'static str,
744 name: String,
746 cause: String,
748 rollback: String,
750 },
751
752 #[error(
756 "this machine's {name} service was installed from {installed_by}, not from this \
757 account's data root, so this account does not {operation} it: doing so would \
758 re-register or remove that installation's service with this account's directories. \
759 Do it {act_as}. If that account no longer exists, remove the registration with the \
760 service manager itself first."
761 )]
762 ForeignInstallation {
763 name: String,
765 operation: &'static str,
767 installed_by: String,
769 act_as: String,
771 },
772
773 #[error("this account's local configuration is in use, so nothing was archived: {detail}.")]
776 LocalConfigurationInUse {
777 detail: String,
779 },
780
781 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
783 NeedsElevation {
784 operation: &'static str,
786 name: String,
788 detail: String,
790 remedy: &'static str,
792 },
793}
794
795#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
807pub struct ServiceDirectories {
808 pub config: PathBuf,
810 pub state: PathBuf,
812 pub runtime: PathBuf,
814 pub logs: PathBuf,
816}
817
818impl ServiceDirectories {
819 #[must_use]
821 pub fn of(paths: &AppPaths) -> Self {
822 Self {
823 config: paths.config_dir().to_path_buf(),
824 state: paths.state_dir().to_path_buf(),
825 runtime: paths.runtime_dir().to_path_buf(),
826 logs: paths.logs_dir().to_path_buf(),
827 }
828 }
829
830 #[must_use]
832 pub fn all(&self) -> [&Path; 4] {
833 [
834 self.config.as_path(),
835 self.state.as_path(),
836 self.runtime.as_path(),
837 self.logs.as_path(),
838 ]
839 }
840
841 #[must_use]
844 pub fn log_file(&self) -> PathBuf {
845 self.logs.join(LOG_FILE_STEM)
846 }
847
848 #[must_use]
851 pub fn arguments(&self) -> [OsString; 8] {
852 let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS.map(OsString::from);
853 [
854 config,
855 self.config.clone().into_os_string(),
856 state,
857 self.state.clone().into_os_string(),
858 runtime,
859 self.runtime.clone().into_os_string(),
860 logs,
861 self.logs.clone().into_os_string(),
862 ]
863 }
864
865 #[must_use]
872 pub fn from_command_line(command_line: &str) -> Option<Self> {
873 let arguments = split_command_line(command_line);
874 let value = |option: &str| {
875 let at = arguments.iter().position(|argument| argument == option)?;
876 arguments.get(at + 1).map(PathBuf::from)
877 };
878 let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS;
879 Some(Self {
880 config: value(config)?,
881 state: value(state)?,
882 runtime: value(runtime)?,
883 logs: value(logs)?,
884 })
885 }
886
887 #[must_use]
891 pub fn root(&self) -> PathBuf {
892 let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
893 for other in [&self.state, &self.runtime, &self.logs] {
894 let common = shared
895 .iter()
896 .zip(other.components())
897 .take_while(|(left, right)| **left == *right)
898 .count();
899 shared.truncate(common);
900 }
901 if shared.is_empty() {
902 self.config.clone()
903 } else {
904 shared.iter().collect()
905 }
906 }
907
908 #[must_use]
911 pub fn paths(&self) -> AppPaths {
912 AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
913 }
914}
915
916pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
922 "--service-config-dir",
923 "--service-state-dir",
924 "--service-runtime-dir",
925 "--service-logs-dir",
926];
927
928#[derive(Debug, Clone, PartialEq, Eq)]
940pub struct InstallRequest {
941 start_mode: StartMode,
942 binary: Option<PathBuf>,
943 source_binary: Option<PathBuf>,
944 arguments: Vec<OsString>,
945 restart: RestartPolicy,
946 on_demand: bool,
947}
948
949impl InstallRequest {
950 #[must_use]
952 pub fn new(start_mode: StartMode) -> Self {
953 Self {
954 start_mode,
955 binary: None,
956 source_binary: None,
957 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
958 restart: RestartPolicy::default(),
959 on_demand: false,
960 }
961 }
962
963 #[must_use]
973 pub const fn started_on_demand(mut self) -> Self {
974 self.on_demand = true;
975 self
976 }
977
978 #[must_use]
985 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
986 self.binary = Some(binary.into());
987 self
988 }
989
990 #[must_use]
1007 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1008 self.source_binary = Some(source.into());
1009 self
1010 }
1011
1012 #[must_use]
1014 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1015 where
1016 I: IntoIterator<Item = S>,
1017 S: Into<OsString>,
1018 {
1019 self.arguments = arguments.into_iter().map(Into::into).collect();
1020 self
1021 }
1022
1023 #[must_use]
1026 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1027 self.restart = restart;
1028 self
1029 }
1030
1031 #[must_use]
1033 pub const fn start_mode(&self) -> StartMode {
1034 self.start_mode
1035 }
1036}
1037
1038#[derive(Debug, Clone, PartialEq, Eq)]
1045pub struct InstallPlan {
1046 identity: ServiceIdentity,
1047 start_mode: StartMode,
1048 binary: PathBuf,
1049 source_binary: Option<PathBuf>,
1050 arguments: Vec<OsString>,
1051 account: ServiceAccount,
1052 restart: RestartPolicy,
1053 directories: ServiceDirectories,
1054 secret_guard: Option<PathBuf>,
1055 on_demand: bool,
1056}
1057
1058impl InstallPlan {
1059 pub fn resolve(
1067 identity: ServiceIdentity,
1068 request: &InstallRequest,
1069 directories: ServiceDirectories,
1070 ) -> Result<Self, ServiceError> {
1071 let binary = match &request.binary {
1072 Some(named) => absolute(named)?,
1073 None => running_executable()?,
1074 };
1075 if !binary.is_file() {
1076 return Err(ServiceError::BinaryMissing { path: binary });
1077 }
1078 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1083 .ok()
1084 .map(|store| store.guard());
1085 Ok(Self {
1086 identity,
1087 start_mode: request.start_mode,
1088 binary,
1089 source_binary: request.source_binary.clone(),
1090 arguments: request.arguments.clone(),
1091 account: ServiceAccount::for_start_mode(request.start_mode),
1092 restart: request.restart,
1093 directories,
1094 secret_guard,
1095 on_demand: request.on_demand,
1096 })
1097 }
1098
1099 #[must_use]
1106 pub fn unchecked(
1107 identity: ServiceIdentity,
1108 start_mode: StartMode,
1109 binary: impl Into<PathBuf>,
1110 directories: ServiceDirectories,
1111 ) -> Self {
1112 Self {
1113 identity,
1114 start_mode,
1115 binary: binary.into(),
1116 source_binary: None,
1117 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1118 account: ServiceAccount::for_start_mode(start_mode),
1119 restart: RestartPolicy::default(),
1120 directories,
1121 secret_guard: None,
1122 on_demand: false,
1123 }
1124 }
1125
1126 #[must_use]
1129 pub const fn started_on_demand(mut self) -> Self {
1130 self.on_demand = true;
1131 self
1132 }
1133
1134 #[must_use]
1136 pub const fn is_on_demand(&self) -> bool {
1137 self.on_demand
1138 }
1139
1140 #[must_use]
1147 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1148 self.secret_guard = Some(guard.into());
1149 self
1150 }
1151
1152 #[must_use]
1154 pub fn secret_guard(&self) -> Option<&Path> {
1155 self.secret_guard.as_deref()
1156 }
1157
1158 #[must_use]
1160 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1161 self.restart = restart;
1162 self
1163 }
1164
1165 #[must_use]
1167 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1168 where
1169 I: IntoIterator<Item = S>,
1170 S: Into<OsString>,
1171 {
1172 self.arguments = arguments.into_iter().map(Into::into).collect();
1173 self
1174 }
1175
1176 #[must_use]
1178 pub const fn identity(&self) -> &ServiceIdentity {
1179 &self.identity
1180 }
1181
1182 #[must_use]
1184 pub const fn start_mode(&self) -> StartMode {
1185 self.start_mode
1186 }
1187
1188 #[must_use]
1190 pub fn binary(&self) -> &Path {
1191 &self.binary
1192 }
1193
1194 #[must_use]
1200 pub fn source_binary(&self) -> Option<&Path> {
1201 self.source_binary.as_deref()
1202 }
1203
1204 #[must_use]
1206 pub fn arguments(&self) -> &[OsString] {
1207 &self.arguments
1208 }
1209
1210 #[must_use]
1212 pub const fn account(&self) -> &ServiceAccount {
1213 &self.account
1214 }
1215
1216 #[must_use]
1218 pub const fn restart(&self) -> RestartPolicy {
1219 self.restart
1220 }
1221
1222 #[must_use]
1224 pub const fn directories(&self) -> &ServiceDirectories {
1225 &self.directories
1226 }
1227
1228 #[must_use]
1230 pub fn command_line(&self) -> String {
1231 let mut out = quote_argument(&self.binary.to_string_lossy());
1232 for argument in &self.arguments {
1233 out.push(' ');
1234 out.push_str("e_argument(&argument.to_string_lossy()));
1235 }
1236 out
1237 }
1238}
1239
1240fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1248 let reversal = change.revert();
1249 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1250}
1251
1252fn rolled_back<T>(
1265 retained: Option<String>,
1266 rollback: Result<T, ServiceError>,
1267 operation: &'static str,
1268 identity: &ServiceIdentity,
1269 cause: ServiceError,
1270) -> ServiceError {
1271 let left_behind = match (rollback.err(), retained) {
1272 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1273 (Some(rollback), None) => Some(rollback.to_string()),
1274 (None, retained) => retained,
1275 };
1276 match left_behind {
1277 Some(rollback) => ServiceError::Rollback {
1278 operation,
1279 name: identity.name().to_string(),
1280 cause: cause.to_string(),
1281 rollback,
1282 },
1283 None => cause,
1284 }
1285}
1286
1287fn undo_runner_root(
1290 change: &RootAccessChange,
1291 operation: &'static str,
1292 identity: &ServiceIdentity,
1293 cause: ServiceError,
1294) -> ServiceError {
1295 rolled_back(
1296 retained_runner_root(change),
1297 Ok(()),
1298 operation,
1299 identity,
1300 cause,
1301 )
1302}
1303
1304fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1318 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1319 detail: format!("{} could not be made absolute: {error}", path.display()),
1320 })
1321}
1322
1323fn running_executable() -> Result<PathBuf, ServiceError> {
1325 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1326 detail: error.to_string(),
1327 })?;
1328 absolute(&raw)
1329}
1330
1331pub(crate) fn quote_argument(argument: &str) -> String {
1340 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1341 return argument.to_string();
1342 }
1343 let mut out = String::with_capacity(argument.len() + 2);
1344 out.push('"');
1345 let mut backslashes = 0usize;
1346 for c in argument.chars() {
1347 match c {
1348 '\\' => {
1349 backslashes += 1;
1350 out.push('\\');
1351 }
1352 '"' => {
1353 for _ in 0..=backslashes {
1356 out.push('\\');
1357 }
1358 out.push('"');
1359 backslashes = 0;
1360 }
1361 other => {
1362 backslashes = 0;
1363 out.push(other);
1364 }
1365 }
1366 }
1367 for _ in 0..backslashes {
1369 out.push('\\');
1370 }
1371 out.push('"');
1372 out
1373}
1374
1375#[must_use]
1386pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1387 split_command_line(command_line)
1388 .into_iter()
1389 .next()
1390 .filter(|executable| !executable.is_empty())
1391 .map(PathBuf::from)
1392}
1393
1394#[must_use]
1400pub fn split_command_line(command_line: &str) -> Vec<String> {
1401 let mut arguments = Vec::new();
1402 let mut current = String::new();
1403 let mut started = false;
1404 let mut quoted = false;
1405 let mut backslashes = 0usize;
1406 for c in command_line.chars() {
1407 match c {
1408 '\\' => {
1409 backslashes += 1;
1410 started = true;
1411 }
1412 '"' => {
1413 current.extend(std::iter::repeat_n('\\', backslashes / 2));
1414 if backslashes.is_multiple_of(2) {
1415 quoted = !quoted;
1416 } else {
1417 current.push('"');
1418 }
1419 backslashes = 0;
1420 started = true;
1421 }
1422 ' ' | '\t' if !quoted => {
1423 current.extend(std::iter::repeat_n('\\', backslashes));
1424 backslashes = 0;
1425 if started {
1426 arguments.push(std::mem::take(&mut current));
1427 started = false;
1428 }
1429 }
1430 other => {
1431 current.extend(std::iter::repeat_n('\\', backslashes));
1432 backslashes = 0;
1433 current.push(other);
1434 started = true;
1435 }
1436 }
1437 }
1438 current.extend(std::iter::repeat_n('\\', backslashes));
1439 if started {
1440 arguments.push(current);
1441 }
1442 arguments
1443}
1444
1445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1451pub enum DefinitionKind {
1452 WindowsService,
1456 WindowsScheduledTask,
1458 LaunchdPlist,
1460 SystemdUnit,
1462}
1463
1464impl DefinitionKind {
1465 #[must_use]
1467 pub const fn manager(self) -> &'static str {
1468 match self {
1469 Self::WindowsService => "the Windows Service Control Manager",
1470 Self::WindowsScheduledTask => "Windows Task Scheduler",
1471 Self::LaunchdPlist => "launchd",
1472 Self::SystemdUnit => "systemd",
1473 }
1474 }
1475}
1476
1477impl fmt::Display for DefinitionKind {
1478 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1479 f.write_str(self.manager())
1480 }
1481}
1482
1483#[derive(Debug, Clone, PartialEq, Eq)]
1490pub struct ServiceDefinition {
1491 kind: DefinitionKind,
1492 text: String,
1493 install_path: Option<PathBuf>,
1494}
1495
1496impl ServiceDefinition {
1497 #[must_use]
1499 pub const fn kind(&self) -> DefinitionKind {
1500 self.kind
1501 }
1502
1503 #[must_use]
1505 pub fn text(&self) -> &str {
1506 &self.text
1507 }
1508
1509 #[must_use]
1516 pub fn install_path(&self) -> Option<&Path> {
1517 self.install_path.as_deref()
1518 }
1519
1520 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1533 Ok(match host_definition_kind(plan.start_mode()) {
1534 DefinitionKind::WindowsService => Self::windows_service(plan),
1535 DefinitionKind::WindowsScheduledTask => {
1536 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1537 }
1538 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1539 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1540 })
1541 }
1542
1543 #[must_use]
1545 pub fn windows_service(plan: &InstallPlan) -> Self {
1546 Self {
1547 kind: DefinitionKind::WindowsService,
1548 text: windows_service_descriptor(plan),
1549 install_path: None,
1550 }
1551 }
1552
1553 #[must_use]
1555 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1556 Self {
1557 kind: DefinitionKind::WindowsScheduledTask,
1558 text: windows_scheduled_task_xml(plan, principal),
1559 install_path: None,
1560 }
1561 }
1562
1563 #[must_use]
1568 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1569 let file = format!("{}.plist", plan.identity().launchd_label());
1570 let install_path = match plan.start_mode() {
1571 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1572 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1573 };
1574 Self {
1575 kind: DefinitionKind::LaunchdPlist,
1576 text: launchd_plist(plan),
1577 install_path,
1578 }
1579 }
1580
1581 #[must_use]
1589 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1590 Self {
1591 kind,
1592 text: text.into(),
1593 install_path: None,
1594 }
1595 }
1596
1597 #[must_use]
1602 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1603 let file = plan.identity().systemd_unit();
1604 let install_path = match plan.start_mode() {
1605 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1606 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1607 };
1608 Self {
1609 kind: DefinitionKind::SystemdUnit,
1610 text: systemd_unit(plan),
1611 install_path,
1612 }
1613 }
1614}
1615
1616pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1618pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1620pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1622pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1624
1625const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1628
1629pub const START_LIMIT_BURST: u32 = 5;
1637
1638#[must_use]
1659pub fn systemd_unit(plan: &InstallPlan) -> String {
1660 let identity = plan.identity();
1661 let restart = plan.restart();
1662 let directories = plan.directories();
1663
1664 let mut out = String::new();
1665 out.push_str("[Unit]\n");
1666 out.push_str(&format!("Description={}\n", identity.display_name()));
1667 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1668 out.push_str("After=network-online.target\n");
1669 out.push_str("Wants=network-online.target\n");
1670 out.push_str(&format!(
1673 "StartLimitIntervalSec={}\n",
1674 restart.reset_after().as_secs()
1675 ));
1676 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1677
1678 out.push_str("\n[Service]\n");
1679 out.push_str("Type=simple\n");
1680 out.push_str("KillMode=process\n");
1684 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1685 out.push_str(&format!(
1686 "WorkingDirectory={}\n",
1687 directories.state.display()
1688 ));
1689 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1690 out.push_str("Restart=on-failure\n");
1691 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1692
1693 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1694 for directive in SYSTEMD_HARDENING {
1695 out.push_str(directive);
1696 out.push('\n');
1697 }
1698 let mut writable = directories.all().to_vec();
1699 if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1700 writable.push(secret_directory);
1701 }
1702 out.push_str(&format!(
1703 "ReadWritePaths={}\n",
1704 writable
1705 .iter()
1706 .map(|path| quote_argument(&path.to_string_lossy()))
1707 .collect::<Vec<_>>()
1708 .join(" ")
1709 ));
1710
1711 out.push_str("\n[Install]\n");
1712 out.push_str(match plan.start_mode() {
1713 StartMode::Boot => "WantedBy=multi-user.target\n",
1714 StartMode::Login => "WantedBy=default.target\n",
1715 });
1716 out
1717}
1718
1719pub const SYSTEMD_HARDENING: [&str; 13] = [
1726 "NoNewPrivileges=yes",
1727 "CapabilityBoundingSet=",
1728 "AmbientCapabilities=",
1729 "PrivateTmp=yes",
1730 "PrivateDevices=yes",
1731 "ProtectSystem=strict",
1732 "ProtectKernelTunables=yes",
1733 "ProtectKernelModules=yes",
1734 "ProtectControlGroups=yes",
1735 "RestrictNamespaces=yes",
1736 "RestrictRealtime=yes",
1737 "RestrictSUIDSGID=yes",
1738 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1739];
1740
1741pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1765
1766#[must_use]
1773pub fn launchd_plist(plan: &InstallPlan) -> String {
1774 let identity = plan.identity();
1775 let directories = plan.directories();
1776 let mut out = String::new();
1777 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1778 out.push_str(
1779 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1780 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1781 );
1782 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1783 out.push_str(&plist_string("Label", &identity.launchd_label()));
1784
1785 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1786 out.push_str(&format!(
1787 " <string>{}</string>\n",
1788 xml_escape(&plan.binary().to_string_lossy())
1789 ));
1790 for argument in plan.arguments() {
1791 out.push_str(&format!(
1792 " <string>{}</string>\n",
1793 xml_escape(&argument.to_string_lossy())
1794 ));
1795 }
1796 out.push_str(" </array>\n");
1797
1798 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1799 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1800 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1801 out.push_str(" </dict>\n");
1802 out.push_str(&format!(
1803 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1804 plan.restart().delay().as_secs()
1805 ));
1806 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1807 out.push_str(&plist_string(
1808 "WorkingDirectory",
1809 &directories.state.to_string_lossy(),
1810 ));
1811 out.push_str(&plist_string(
1812 "StandardOutPath",
1813 &directories
1814 .logs
1815 .join("runner-manager.launchd.out.log")
1816 .to_string_lossy(),
1817 ));
1818 out.push_str(&plist_string(
1819 "StandardErrorPath",
1820 &directories
1821 .logs
1822 .join("runner-manager.launchd.err.log")
1823 .to_string_lossy(),
1824 ));
1825
1826 match plan.start_mode() {
1827 StartMode::Boot => {
1828 out.push_str(&plist_string(
1831 "UserName",
1832 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1833 .as_str(),
1834 ));
1835 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1837 }
1838 StartMode::Login => {
1839 }
1843 }
1844
1845 out.push_str("</dict>\n</plist>\n");
1846 out
1847}
1848
1849fn plist_string(key: &str, value: &str) -> String {
1851 format!(
1852 " <key>{}</key>\n <string>{}</string>\n",
1853 xml_escape(key),
1854 xml_escape(value)
1855 )
1856}
1857
1858#[derive(Debug, Clone, PartialEq, Eq)]
1866pub struct TaskPrincipal {
1867 user_id: String,
1868}
1869
1870impl TaskPrincipal {
1871 pub fn current() -> Result<Self, ServiceError> {
1879 let user = std::env::var("USERNAME")
1880 .ok()
1881 .filter(|value| !value.trim().is_empty());
1882 let Some(user) = user else {
1883 return Err(ServiceError::Control {
1884 operation: "identify the account for",
1885 name: SERVICE_NAME.to_string(),
1886 manager: "Windows Task Scheduler",
1887 detail: "this session reports no %USERNAME%, so there is no principal to \
1888 register a logon-triggered task for"
1889 .to_string(),
1890 });
1891 };
1892 let domain = std::env::var("USERDOMAIN")
1893 .ok()
1894 .filter(|value| !value.trim().is_empty());
1895 Ok(Self {
1896 user_id: match domain {
1897 Some(domain) => format!("{domain}\\{user}"),
1898 None => user,
1899 },
1900 })
1901 }
1902
1903 #[must_use]
1905 pub fn named(user_id: impl Into<String>) -> Self {
1906 Self {
1907 user_id: user_id.into(),
1908 }
1909 }
1910
1911 #[must_use]
1913 pub fn user_id(&self) -> &str {
1914 &self.user_id
1915 }
1916}
1917
1918#[must_use]
1925pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1926 let identity = plan.identity();
1927 let user = xml_escape(principal.user_id());
1928 let arguments = plan
1929 .arguments()
1930 .iter()
1931 .map(|argument| quote_argument(&argument.to_string_lossy()))
1932 .collect::<Vec<_>>()
1933 .join(" ");
1934 let mut out = String::new();
1935 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1936 out.push_str(
1937 "<Task version=\"1.4\" \
1938 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1939 );
1940 out.push_str(" <RegistrationInfo>\n");
1941 out.push_str(&format!(
1942 " <Description>{}</Description>\n",
1943 xml_escape(identity.description())
1944 ));
1945 out.push_str(&format!(
1946 " <URI>\\{}</URI>\n",
1947 xml_escape(identity.name())
1948 ));
1949 out.push_str(" </RegistrationInfo>\n");
1950
1951 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1952 out.push_str(" <Enabled>true</Enabled>\n");
1953 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1954 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1955
1956 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1957 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1958 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1959 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1960 out.push_str(" </Principal>\n </Principals>\n");
1961
1962 out.push_str(" <Settings>\n");
1963 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1967 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1968 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1969 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1970 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1971 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1972 out.push_str(" <IdleSettings>\n");
1973 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1974 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1975 out.push_str(" </IdleSettings>\n");
1976 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1977 out.push_str(" <Enabled>true</Enabled>\n");
1978 out.push_str(" <Hidden>false</Hidden>\n");
1979 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1980 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1981 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1983 out.push_str(" <Priority>7</Priority>\n");
1984 out.push_str(" <RestartOnFailure>\n");
1985 out.push_str(&format!(
1986 " <Interval>{}</Interval>\n",
1987 iso8601_minutes(
1988 plan.restart()
1989 .effective_delay(DefinitionKind::WindowsScheduledTask)
1990 )
1991 ));
1992 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1993 out.push_str(" </RestartOnFailure>\n");
1994 out.push_str(" </Settings>\n");
1995
1996 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1997 out.push_str(&format!(
1998 " <Command>{}</Command>\n",
1999 xml_escape(&plan.binary().to_string_lossy())
2000 ));
2001 if !arguments.is_empty() {
2002 out.push_str(&format!(
2003 " <Arguments>{}</Arguments>\n",
2004 xml_escape(&arguments)
2005 ));
2006 }
2007 out.push_str(&format!(
2008 " <WorkingDirectory>{}</WorkingDirectory>\n",
2009 xml_escape(&plan.directories().state.to_string_lossy())
2010 ));
2011 out.push_str(" </Exec>\n </Actions>\n");
2012 out.push_str("</Task>\n");
2013 out
2014}
2015
2016fn iso8601_minutes(duration: Duration) -> String {
2019 format!("PT{}M", duration.as_secs() / 60)
2020}
2021
2022pub(crate) fn xml_escape(value: &str) -> String {
2030 let mut out = String::with_capacity(value.len());
2031 for c in value.chars() {
2032 match c {
2033 '&' => out.push_str("&"),
2034 '<' => out.push_str("<"),
2035 '>' => out.push_str(">"),
2036 '"' => out.push_str("""),
2037 '\'' => out.push_str("'"),
2038 other => out.push(other),
2039 }
2040 }
2041 out
2042}
2043
2044fn xml_unescape(value: &str) -> String {
2050 value
2051 .replace("<", "<")
2052 .replace(">", ">")
2053 .replace(""", "\"")
2054 .replace("'", "'")
2055 .replace("&", "&")
2056}
2057
2058#[derive(Debug, Clone, PartialEq, Eq)]
2067pub struct WindowsServiceSpec {
2068 pub name: String,
2070 pub display_name: String,
2072 pub description: String,
2074 pub automatic_start: bool,
2076 pub account: Option<String>,
2079 pub command_line: String,
2081 pub restart: RestartPolicy,
2083}
2084
2085#[must_use]
2087pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2088 WindowsServiceSpec {
2089 name: plan.identity().name().to_string(),
2090 display_name: plan.identity().display_name().to_string(),
2091 description: plan.identity().description().to_string(),
2092 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2097 account: match ServiceAccount::for_definition(
2098 DefinitionKind::WindowsService,
2099 plan.start_mode(),
2100 ) {
2101 ServiceAccount::LocalSystem => None,
2104 other => Some(other.as_str().to_string()),
2105 },
2106 command_line: plan.command_line(),
2107 restart: plan.restart(),
2108 }
2109}
2110
2111#[must_use]
2114fn windows_service_descriptor(plan: &InstallPlan) -> String {
2115 let spec = windows_service_spec(plan);
2116 let mut out = String::new();
2117 out.push_str("[windows-service]\n");
2118 out.push_str(&format!("Name={}\n", spec.name));
2119 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2120 out.push_str(&format!("Description={}\n", spec.description));
2121 out.push_str("ServiceType=OWN_PROCESS\n");
2125 out.push_str(&format!(
2126 "StartType={}\n",
2127 if spec.automatic_start {
2128 "AutoStart"
2129 } else {
2130 "OnDemand"
2131 }
2132 ));
2133 out.push_str("ErrorControl=Normal\n");
2134 out.push_str(&format!(
2135 "Account={}\n",
2136 spec.account
2137 .as_deref()
2138 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2139 ));
2140 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2141 out.push_str(&format!(
2142 "FailureActionRestartDelaySecs={}\n",
2143 spec.restart.delay().as_secs()
2144 ));
2145 out.push_str(&format!(
2146 "FailureActionsResetPeriodSecs={}\n",
2147 spec.restart.reset_after().as_secs()
2148 ));
2149 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2153 out.push_str(&format!(
2154 "ReadWritePaths={}\n",
2155 plan.directories()
2156 .all()
2157 .iter()
2158 .map(|path| quote_argument(&path.to_string_lossy()))
2159 .collect::<Vec<_>>()
2160 .join(" ")
2161 ));
2162 out
2163}
2164
2165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2171pub enum FindingKind {
2172 Excess,
2175 Shortfall,
2178}
2179
2180impl fmt::Display for FindingKind {
2181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2182 f.write_str(match self {
2183 Self::Excess => "excess",
2184 Self::Shortfall => "shortfall",
2185 })
2186 }
2187}
2188
2189#[derive(Debug, Clone, PartialEq, Eq)]
2191pub struct PrivilegeFinding {
2192 pub kind: FindingKind,
2194 pub subject: String,
2196 pub detail: String,
2198}
2199
2200impl fmt::Display for PrivilegeFinding {
2201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2202 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2203 }
2204}
2205
2206#[derive(Debug, Clone, PartialEq, Eq)]
2220pub struct PrivilegeReview {
2221 kind: DefinitionKind,
2222 account: ServiceAccount,
2223 controls: Vec<String>,
2224 findings: Vec<PrivilegeFinding>,
2225}
2226
2227impl PrivilegeReview {
2228 #[must_use]
2230 pub fn is_least_privilege(&self) -> bool {
2231 !self
2232 .findings
2233 .iter()
2234 .any(|finding| finding.kind == FindingKind::Excess)
2235 }
2236
2237 #[must_use]
2239 pub fn findings(&self) -> &[PrivilegeFinding] {
2240 &self.findings
2241 }
2242
2243 #[must_use]
2246 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2247 self.findings
2248 .iter()
2249 .filter(|finding| finding.kind == FindingKind::Excess)
2250 .collect()
2251 }
2252
2253 #[must_use]
2259 pub fn controls(&self) -> &[String] {
2260 &self.controls
2261 }
2262
2263 #[must_use]
2265 pub const fn account(&self) -> &ServiceAccount {
2266 &self.account
2267 }
2268
2269 #[must_use]
2271 pub const fn kind(&self) -> DefinitionKind {
2272 self.kind
2273 }
2274}
2275
2276impl fmt::Display for PrivilegeReview {
2277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2278 writeln!(
2279 f,
2280 "{} runs as {} ({})",
2281 self.kind,
2282 self.account,
2283 self.account.justification()
2284 )?;
2285 for control in &self.controls {
2286 writeln!(f, " confirmed {control}")?;
2287 }
2288 for finding in &self.findings {
2289 writeln!(f, " {finding}")?;
2290 }
2291 if self.is_least_privilege() {
2292 write!(f, " verdict least privilege")
2293 } else {
2294 write!(
2295 f,
2296 " verdict NOT least privilege: {} excess(es)",
2297 self.excesses().len()
2298 )
2299 }
2300 }
2301}
2302
2303#[must_use]
2309pub fn review_least_privilege(
2310 definition: &ServiceDefinition,
2311 plan: &InstallPlan,
2312) -> PrivilegeReview {
2313 let mut controls = Vec::new();
2314 let mut findings = Vec::new();
2315 match definition.kind() {
2316 DefinitionKind::SystemdUnit => {
2317 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2318 }
2319 DefinitionKind::LaunchdPlist => {
2320 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2321 }
2322 DefinitionKind::WindowsScheduledTask => {
2323 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2324 }
2325 DefinitionKind::WindowsService => {
2326 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2327 }
2328 }
2329 PrivilegeReview {
2330 kind: definition.kind(),
2331 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2336 controls,
2337 findings,
2338 }
2339}
2340
2341fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2343 let mut permitted = plan
2344 .directories()
2345 .all()
2346 .iter()
2347 .map(|path| path.to_string_lossy().into_owned())
2348 .collect::<Vec<_>>();
2349 if kind == DefinitionKind::SystemdUnit
2350 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2351 {
2352 permitted.push(secret_directory.to_string_lossy().into_owned());
2353 }
2354 permitted
2355}
2356
2357fn same_path_text(left: &str, right: &str) -> bool {
2364 if cfg!(windows) {
2365 left.eq_ignore_ascii_case(right)
2366 } else {
2367 left == right
2368 }
2369}
2370
2371fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2374 match kind {
2375 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2376 left.eq_ignore_ascii_case(right)
2377 }
2378 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2379 }
2380}
2381
2382fn review_writable_paths(
2384 kind: DefinitionKind,
2385 subject: &str,
2386 listed: &[String],
2387 plan: &InstallPlan,
2388 controls: &mut Vec<String>,
2389 findings: &mut Vec<PrivilegeFinding>,
2390) {
2391 let permitted = permitted_paths(kind, plan);
2392 for entry in listed {
2393 if !permitted
2394 .iter()
2395 .any(|allowed| same_path_for(kind, allowed, entry))
2396 {
2397 findings.push(PrivilegeFinding {
2398 kind: FindingKind::Excess,
2399 subject: subject.to_string(),
2400 detail: format!(
2401 "{entry} is writable but is not one of this registration's required paths"
2402 ),
2403 });
2404 }
2405 }
2406 for allowed in &permitted {
2407 if !listed
2408 .iter()
2409 .any(|entry| same_path_for(kind, allowed, entry))
2410 {
2411 findings.push(PrivilegeFinding {
2412 kind: FindingKind::Shortfall,
2413 subject: subject.to_string(),
2414 detail: format!(
2415 "{allowed} is one of this registration's directories but is not writable, \
2416 so the daemon cannot use it"
2417 ),
2418 });
2419 }
2420 }
2421 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2422 controls.push(format!(
2423 "{subject} names exactly the required application-data and credential paths"
2424 ));
2425 }
2426}
2427
2428fn review_inbound_surface(
2436 text: &str,
2437 markers: &[(&str, &str)],
2438 controls: &mut Vec<String>,
2439 findings: &mut Vec<PrivilegeFinding>,
2440) {
2441 let mut clean = true;
2442 for (marker, detail) in markers {
2443 if text.contains(marker) {
2444 clean = false;
2445 findings.push(PrivilegeFinding {
2446 kind: FindingKind::Excess,
2447 subject: (*marker).to_string(),
2448 detail: (*detail).to_string(),
2449 });
2450 }
2451 }
2452 if clean {
2453 controls.push(
2454 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2455 );
2456 }
2457}
2458
2459fn review_systemd(
2460 text: &str,
2461 plan: &InstallPlan,
2462 controls: &mut Vec<String>,
2463 findings: &mut Vec<PrivilegeFinding>,
2464) {
2465 let directives = ini_directives(text, "Service");
2466 for expected in SYSTEMD_HARDENING {
2467 let (key, value) = expected
2468 .split_once('=')
2469 .expect("every hardening directive is written as key=value");
2470 match directives.get(key) {
2471 Some(actual) if actual == value => controls.push((*expected).to_string()),
2472 Some(actual) => findings.push(PrivilegeFinding {
2473 kind: FindingKind::Excess,
2474 subject: key.to_string(),
2475 detail: format!(
2476 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2477 requirement does not ask for"
2478 ),
2479 }),
2480 None => findings.push(PrivilegeFinding {
2481 kind: FindingKind::Excess,
2482 subject: key.to_string(),
2483 detail: format!(
2484 "is absent, so the unit inherits systemd's default rather than `{value}`"
2485 ),
2486 }),
2487 }
2488 }
2489
2490 match directives.get("ReadWritePaths") {
2491 Some(value) => {
2492 let listed = split_quoted(value);
2493 review_writable_paths(
2494 DefinitionKind::SystemdUnit,
2495 "ReadWritePaths",
2496 &listed,
2497 plan,
2498 controls,
2499 findings,
2500 );
2501 }
2502 None => findings.push(PrivilegeFinding {
2503 kind: FindingKind::Shortfall,
2504 subject: "ReadWritePaths".to_string(),
2505 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2506 .to_string(),
2507 }),
2508 }
2509
2510 if directives.contains_key("PrivateUsers")
2513 && directives.get("PrivateUsers") == Some(&"no".to_string())
2514 {
2515 findings.push(PrivilegeFinding {
2516 kind: FindingKind::Excess,
2517 subject: "PrivateUsers".to_string(),
2518 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2519 .to_string(),
2520 });
2521 }
2522
2523 review_inbound_surface(
2524 text,
2525 &[
2526 (
2527 "ListenStream=",
2528 "asks systemd to open a listening socket for this service, which \
2529 07-security.md rule 2 forbids the product to have",
2530 ),
2531 (
2532 "ListenDatagram=",
2533 "asks systemd to open a listening socket for this service, which \
2534 07-security.md rule 2 forbids the product to have",
2535 ),
2536 ],
2537 controls,
2538 findings,
2539 );
2540}
2541
2542fn review_launchd(
2543 text: &str,
2544 plan: &InstallPlan,
2545 controls: &mut Vec<String>,
2546 findings: &mut Vec<PrivilegeFinding>,
2547) {
2548 match plist_string_value(text, "ProcessType") {
2552 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2553 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2554 }
2555 found => findings.push(PrivilegeFinding {
2556 kind: FindingKind::Shortfall,
2557 subject: "ProcessType".to_string(),
2558 detail: format!(
2559 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2560 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2561 when it next starts with no runner; `service status` says how to restart it",
2562 found.map_or_else(
2563 || "absent (launchd's `Standard`)".to_string(),
2564 |value| format!("`{value}`")
2565 )
2566 ),
2567 }),
2568 }
2569
2570 match plan.start_mode() {
2571 StartMode::Boot => {
2572 if plist_bool_value(text, "SessionCreate") == Some(true) {
2573 findings.push(PrivilegeFinding {
2574 kind: FindingKind::Excess,
2575 subject: "SessionCreate".to_string(),
2576 detail: "asks launchd to create a security session for a job that runs \
2577 outside every login session and has no use for one"
2578 .to_string(),
2579 });
2580 } else {
2581 controls.push("SessionCreate is not requested".to_string());
2582 }
2583 match plist_string_value(text, "UserName").as_deref() {
2584 Some("root") => {
2585 controls.push("UserName=root, stated rather than inherited".to_string())
2586 }
2587 Some(other) => findings.push(PrivilegeFinding {
2588 kind: FindingKind::Shortfall,
2589 subject: "UserName".to_string(),
2590 detail: format!(
2591 "is `{other}`, which cannot unlock the System Keychain: \
2592 /var/db/SystemKey is root-only, so the daemon would start and then \
2593 find no credential"
2594 ),
2595 }),
2596 None => findings.push(PrivilegeFinding {
2597 kind: FindingKind::Shortfall,
2598 subject: "UserName".to_string(),
2599 detail: "is absent, so the account is launchd's implicit default and this \
2600 review cannot confirm it"
2601 .to_string(),
2602 }),
2603 }
2604 }
2605 StartMode::Login => {
2606 if let Some(named) = plist_string_value(text, "UserName") {
2607 findings.push(PrivilegeFinding {
2608 kind: FindingKind::Excess,
2609 subject: "UserName".to_string(),
2610 detail: format!(
2611 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2612 naming an account here asks launchd for a switch a login-mode \
2613 registration has no reason to want"
2614 ),
2615 });
2616 } else {
2617 controls
2618 .push("no UserName: the agent runs as the operator and no other".to_string());
2619 }
2620 }
2621 }
2622
2623 review_inbound_surface(
2624 text,
2625 &[
2626 (
2627 "<key>Sockets</key>",
2628 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2629 forbids the product to have",
2630 ),
2631 (
2632 "<key>MachServices</key>",
2633 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2634 forbids the product to have",
2635 ),
2636 ],
2637 controls,
2638 findings,
2639 );
2640}
2641
2642fn review_scheduled_task(
2643 text: &str,
2644 controls: &mut Vec<String>,
2645 findings: &mut Vec<PrivilegeFinding>,
2646) {
2647 match xml_value(text, "RunLevel").as_deref() {
2648 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2649 Some(other) => findings.push(PrivilegeFinding {
2650 kind: FindingKind::Excess,
2651 subject: "RunLevel".to_string(),
2652 detail: format!(
2653 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2654 an administrator"
2655 ),
2656 }),
2657 None => findings.push(PrivilegeFinding {
2658 kind: FindingKind::Excess,
2659 subject: "RunLevel".to_string(),
2660 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2661 .to_string(),
2662 }),
2663 }
2664
2665 match xml_value(text, "LogonType").as_deref() {
2666 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2667 Some(other) => findings.push(PrivilegeFinding {
2668 kind: FindingKind::Excess,
2669 subject: "LogonType".to_string(),
2670 detail: format!(
2671 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2672 task; an interactive token needs neither"
2673 ),
2674 }),
2675 None => findings.push(PrivilegeFinding {
2676 kind: FindingKind::Shortfall,
2677 subject: "LogonType".to_string(),
2678 detail: "is absent, so this review cannot confirm that no credential is stored"
2679 .to_string(),
2680 }),
2681 }
2682}
2683
2684fn review_windows_service(
2685 text: &str,
2686 plan: &InstallPlan,
2687 controls: &mut Vec<String>,
2688 findings: &mut Vec<PrivilegeFinding>,
2689) {
2690 let directives = ini_directives(text, "windows-service");
2691
2692 match directives.get("ServiceType").map(String::as_str) {
2693 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2694 Some(other) => findings.push(PrivilegeFinding {
2695 kind: FindingKind::Excess,
2696 subject: "ServiceType".to_string(),
2697 detail: format!(
2698 "is `{other}`; an interactive or shared-process service reaches further than a \
2699 daemon that only talks to GitHub over HTTPS"
2700 ),
2701 }),
2702 None => findings.push(PrivilegeFinding {
2703 kind: FindingKind::Shortfall,
2704 subject: "ServiceType".to_string(),
2705 detail: "is absent, so this review cannot confirm the service is not interactive"
2706 .to_string(),
2707 }),
2708 }
2709
2710 match directives.get("Account").map(String::as_str) {
2714 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2715 controls.push(format!(
2716 "Account={account}: the only stock account the machine-scoped store's DACL \
2717 (SY, BA, OW) admits"
2718 ));
2719 }
2720 Some(other) => findings.push(PrivilegeFinding {
2721 kind: FindingKind::Shortfall,
2722 subject: "Account".to_string(),
2723 detail: format!(
2724 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2725 daemon would start and then find no credential. Widening that DACL is not this \
2726 registration's to do: an ACE reaching `{other}` would also reach every other \
2727 service running under it"
2728 ),
2729 }),
2730 None => findings.push(PrivilegeFinding {
2731 kind: FindingKind::Shortfall,
2732 subject: "Account".to_string(),
2733 detail: "is absent, so this review cannot confirm which account was registered"
2734 .to_string(),
2735 }),
2736 }
2737
2738 match directives.get("ReadWritePaths") {
2739 Some(value) => {
2740 let listed = split_quoted(value);
2741 review_writable_paths(
2742 DefinitionKind::WindowsService,
2743 "ReadWritePaths",
2744 &listed,
2745 plan,
2746 controls,
2747 findings,
2748 );
2749 }
2750 None => findings.push(PrivilegeFinding {
2751 kind: FindingKind::Shortfall,
2752 subject: "ReadWritePaths".to_string(),
2753 detail: "is absent, so the directories the service was installed against are not \
2754 recorded"
2755 .to_string(),
2756 }),
2757 }
2758}
2759
2760fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2768 let mut out = BTreeMap::new();
2769 let mut inside = false;
2770 for line in text.lines() {
2771 let line = line.trim();
2772 if line.starts_with('[') && line.ends_with(']') {
2773 inside = &line[1..line.len() - 1] == section;
2774 continue;
2775 }
2776 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2777 continue;
2778 }
2779 if let Some((key, value)) = line.split_once('=') {
2780 out.insert(key.trim().to_string(), value.trim().to_string());
2781 }
2782 }
2783 out
2784}
2785
2786fn split_quoted(value: &str) -> Vec<String> {
2789 let mut out = Vec::new();
2790 let mut rest = value.trim();
2791 while !rest.is_empty() {
2792 if rest.starts_with('"') {
2793 if let Some(parsed) = executable_from_command_line(rest) {
2796 out.push(parsed.to_string_lossy().into_owned());
2797 }
2798 let mut depth = 0usize;
2800 let mut end = rest.len();
2801 for (index, c) in rest.char_indices() {
2802 match c {
2803 '\\' => depth += 1,
2804 '"' => {
2805 if depth.is_multiple_of(2) && index > 0 {
2806 end = index + 1;
2807 break;
2808 }
2809 depth = 0;
2810 }
2811 _ => depth = 0,
2812 }
2813 }
2814 rest = rest[end..].trim_start();
2815 } else {
2816 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2817 out.push(rest[..end].to_string());
2818 rest = rest[end..].trim_start();
2819 }
2820 }
2821 out
2822}
2823
2824pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2836 let open = format!("<{tag}>");
2837 let close = format!("</{tag}>");
2838 let start = text.find(&open)? + open.len();
2839 let end = text[start..].find(&close)? + start;
2840 Some(xml_unescape(text[start..end].trim()))
2841}
2842
2843#[cfg(any(windows, test))]
2849fn windows_login_task_starts_automatically(document: &str) -> bool {
2850 document.contains("<LogonTrigger>")
2851 && xml_value(document, "Enabled").as_deref() != Some("false")
2852}
2853
2854fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2856 let marker = format!("<key>{key}</key>");
2857 let start = text.find(&marker)? + marker.len();
2858 Some(text[start..].trim_start())
2859}
2860
2861pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
2862 let rest = plist_value_after_key(text, key)?;
2863 if !rest.starts_with("<string>") {
2864 return None;
2865 }
2866 xml_value(rest, "string")
2867}
2868
2869fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2870 let rest = plist_value_after_key(text, key)?;
2871 if rest.starts_with("<true/>") {
2872 Some(true)
2873 } else if rest.starts_with("<false/>") {
2874 Some(false)
2875 } else {
2876 None
2877 }
2878}
2879
2880pub const RECORD_SCHEMA_VERSION: u32 = 1;
2891
2892#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2904pub struct InstallRecord {
2905 pub schema_version: u32,
2907 pub service_name: String,
2909 pub manager: String,
2911 pub start_mode: StartMode,
2913 pub account: ServiceAccount,
2915 pub binary: PathBuf,
2917 #[serde(default)]
2925 pub source_binary: Option<PathBuf>,
2926 pub arguments: Vec<String>,
2928 pub restart_delay_secs: u64,
2930 pub restart_reset_secs: u64,
2932 pub log_file: PathBuf,
2934 #[serde(default)]
2942 pub starts_on_demand: bool,
2943 pub definition_path: Option<PathBuf>,
2945 pub installed_at: DateTime<Utc>,
2947 pub installed_by_version: String,
2949 pub directories: ServiceDirectories,
2954}
2955
2956impl InstallRecord {
2957 #[must_use]
2959 pub fn path(paths: &AppPaths) -> PathBuf {
2960 paths.config_dir().join(RECORD_FILE)
2961 }
2962
2963 #[must_use]
2965 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2966 Self {
2967 schema_version: RECORD_SCHEMA_VERSION,
2968 service_name: plan.identity().name().to_string(),
2969 manager: definition.kind().manager().to_string(),
2970 start_mode: plan.start_mode(),
2971 account: plan.account().clone(),
2972 binary: plan.binary().to_path_buf(),
2973 arguments: plan
2974 .arguments()
2975 .iter()
2976 .map(|argument| argument.to_string_lossy().into_owned())
2977 .collect(),
2978 restart_delay_secs: plan.restart().delay().as_secs(),
2979 restart_reset_secs: plan.restart().reset_after().as_secs(),
2980 starts_on_demand: plan.is_on_demand(),
2981 source_binary: plan.source_binary().map(Path::to_path_buf),
2982 log_file: plan.directories().log_file(),
2983 definition_path: definition.install_path().map(Path::to_path_buf),
2984 installed_at: at,
2985 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2986 directories: plan.directories().clone(),
2987 }
2988 }
2989
2990 #[must_use]
2998 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2999 let plan = InstallPlan::unchecked(
3000 identity,
3001 self.start_mode,
3002 self.binary.clone(),
3003 self.directories.clone(),
3004 )
3005 .with_arguments(self.arguments.clone())
3006 .with_restart(self.restart());
3007 let plan = if self.starts_on_demand {
3008 plan.started_on_demand()
3009 } else {
3010 plan
3011 };
3012 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3013 Ok(store) => plan.with_secret_guard(store.guard()),
3014 Err(_) => plan,
3015 }
3016 }
3017
3018 #[must_use]
3022 pub fn restart(&self) -> RestartPolicy {
3023 RestartPolicy::new(
3024 Duration::from_secs(self.restart_delay_secs),
3025 Duration::from_secs(self.restart_reset_secs),
3026 )
3027 .unwrap_or_default()
3028 }
3029
3030 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3041 let path = Self::path(paths);
3042 let text = match std::fs::read_to_string(&path) {
3043 Ok(text) => text,
3044 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3045 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3048 return Err(ServiceError::RecordNotPermitted {
3049 path,
3050 detail: error.to_string(),
3051 });
3052 }
3053 Err(error) => {
3054 return Err(ServiceError::Record {
3055 operation: "read",
3056 path,
3057 detail: error.to_string(),
3058 });
3059 }
3060 };
3061 let record: Self =
3062 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3063 path: path.clone(),
3064 detail: error.to_string(),
3065 })?;
3066 if record.schema_version != RECORD_SCHEMA_VERSION {
3067 return Err(ServiceError::RecordUnreadable {
3068 path,
3069 detail: format!(
3070 "it declares schema version {} and this build reads version {}",
3071 record.schema_version, RECORD_SCHEMA_VERSION
3072 ),
3073 });
3074 }
3075 Ok(Some(record))
3076 }
3077
3078 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3084 use std::io::Write as _;
3085
3086 let path = Self::path(paths);
3087 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3088 operation: "encode",
3089 path: path.clone(),
3090 detail: error.to_string(),
3091 })?;
3092 if let Some(parent) = path.parent() {
3093 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3094 operation: "write",
3095 path: path.clone(),
3096 detail: error.to_string(),
3097 })?;
3098 }
3099 let parent = path.parent().ok_or_else(|| ServiceError::Record {
3100 operation: "write",
3101 path: path.clone(),
3102 detail: "the record path has no parent directory".to_string(),
3103 })?;
3104 let mut temporary =
3105 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3106 operation: "write",
3107 path: path.clone(),
3108 detail: error.to_string(),
3109 })?;
3110 temporary
3111 .write_all(text.as_bytes())
3112 .and_then(|()| temporary.as_file().sync_all())
3113 .map_err(|error| ServiceError::Record {
3114 operation: "write",
3115 path: path.clone(),
3116 detail: error.to_string(),
3117 })?;
3118 #[cfg(unix)]
3134 {
3135 use std::os::unix::fs::PermissionsExt as _;
3136
3137 temporary
3138 .as_file()
3139 .set_permissions(std::fs::Permissions::from_mode(0o644))
3140 .map_err(|error| ServiceError::Record {
3141 operation: "write",
3142 path: path.clone(),
3143 detail: error.to_string(),
3144 })?;
3145 }
3146 temporary
3147 .persist(&path)
3148 .map(|_| ())
3149 .map_err(|error| ServiceError::Record {
3150 operation: "write",
3151 path,
3152 detail: error.error.to_string(),
3153 })
3154 }
3155
3156 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3162 let path = Self::path(paths);
3163 match std::fs::remove_file(&path) {
3164 Ok(()) => Ok(true),
3165 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3166 Err(error) => Err(ServiceError::Record {
3167 operation: "remove",
3168 path,
3169 detail: error.to_string(),
3170 }),
3171 }
3172 }
3173}
3174
3175const CONTACT_SCHEMA_VERSION: u32 = 1;
3181
3182#[derive(Debug, Clone, Serialize, Deserialize)]
3183struct ContactRecord {
3184 schema_version: u32,
3185 last_success: DateTime<Utc>,
3186}
3187
3188pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3209 write_state_record(
3210 &contact_path(paths),
3211 &ContactRecord {
3212 schema_version: CONTACT_SCHEMA_VERSION,
3213 last_success: at,
3214 },
3215 )
3216}
3217
3218pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3230 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3231}
3232
3233#[must_use]
3235pub fn contact_path(paths: &AppPaths) -> PathBuf {
3236 paths.state_dir().join(CONTACT_FILE)
3237}
3238
3239pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3242 let failed = |detail: String| ServiceError::Record {
3243 operation: "write",
3244 path: path.to_path_buf(),
3245 detail,
3246 };
3247 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3248 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3249 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3250 let temporary = path.with_extension("toml.new");
3251 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3252 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3253}
3254
3255pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3258 path: &Path,
3259) -> Result<Option<T>, ServiceError> {
3260 let failed = |detail: String| ServiceError::Record {
3261 operation: "read",
3262 path: path.to_path_buf(),
3263 detail,
3264 };
3265 let text = match std::fs::read_to_string(path) {
3266 Ok(text) => text,
3267 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3268 Err(error) => return Err(failed(error.to_string())),
3269 };
3270 toml::from_str(&text)
3271 .map(Some)
3272 .map_err(|error| failed(error.to_string()))
3273}
3274
3275pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3277 match std::fs::remove_file(path) {
3278 Ok(()) => Ok(()),
3279 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3280 Err(error) => Err(ServiceError::Record {
3281 operation: "remove",
3282 path: path.to_path_buf(),
3283 detail: error.to_string(),
3284 }),
3285 }
3286}
3287
3288#[derive(Debug, Serialize, Deserialize)]
3293#[serde(deny_unknown_fields)]
3294struct CredentialRejectionRecord {
3295 schema_version: u32,
3296 rejected_since: DateTime<Utc>,
3297}
3298
3299pub fn record_github_credential_rejected(
3315 paths: &AppPaths,
3316 at: DateTime<Utc>,
3317) -> Result<(), ServiceError> {
3318 if github_credential_rejected_since(paths)
3319 .ok()
3320 .flatten()
3321 .is_some()
3322 {
3323 return Ok(());
3324 }
3325 write_state_record(
3326 &credential_rejection_path(paths),
3327 &CredentialRejectionRecord {
3328 schema_version: CONTACT_SCHEMA_VERSION,
3329 rejected_since: at,
3330 },
3331 )
3332}
3333
3334pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3340 remove_state_record(&credential_rejection_path(paths))
3341}
3342
3343pub fn github_credential_rejected_since(
3349 paths: &AppPaths,
3350) -> Result<Option<DateTime<Utc>>, ServiceError> {
3351 Ok(
3352 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3353 .map(|record| record.rejected_since),
3354 )
3355}
3356
3357#[must_use]
3359pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3360 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3361}
3362
3363#[derive(Debug, Serialize, Deserialize)]
3364#[serde(deny_unknown_fields)]
3365struct CredentialUnreadableRecord {
3366 schema_version: u32,
3367 unreadable_since: DateTime<Utc>,
3368}
3369
3370pub fn record_credential_unreadable(
3389 paths: &AppPaths,
3390 at: DateTime<Utc>,
3391) -> Result<(), ServiceError> {
3392 if credential_unreadable_since(paths).ok().flatten().is_some() {
3393 return Ok(());
3394 }
3395 write_state_record(
3396 &credential_unreadable_path(paths),
3397 &CredentialUnreadableRecord {
3398 schema_version: CONTACT_SCHEMA_VERSION,
3399 unreadable_since: at,
3400 },
3401 )
3402}
3403
3404pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3410 remove_state_record(&credential_unreadable_path(paths))
3411}
3412
3413pub fn credential_unreadable_since(
3420 paths: &AppPaths,
3421) -> Result<Option<DateTime<Utc>>, ServiceError> {
3422 Ok(
3423 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3424 .map(|record| record.unreadable_since),
3425 )
3426}
3427
3428#[must_use]
3430pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3431 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3432}
3433
3434#[must_use]
3442pub fn sign_in_instruction(mode: StartMode) -> String {
3443 let (command, place) = match mode {
3444 StartMode::Boot if cfg!(unix) => ("sudo runner-manager auth login --start-at boot", ""),
3445 StartMode::Boot => (
3446 "runner-manager auth login --start-at boot",
3447 " from an elevated terminal",
3448 ),
3449 StartMode::Login => (
3450 "runner-manager auth login --start-at login",
3451 MACOS_LOGIN_PLACE,
3452 ),
3453 };
3454 format!("`{command}`{place}")
3455}
3456
3457pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
3460 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
3461 keychain)"
3462} else {
3463 ""
3464};
3465
3466#[derive(Debug, Clone, PartialEq, Eq)]
3483pub struct DefinitionDrift {
3484 pub path: PathBuf,
3486 pub installed_by_version: String,
3488 pub rendered: String,
3490 pub start_mode: StartMode,
3492 pub manager: DefinitionKind,
3494}
3495
3496fn definition_drift(
3504 controls: &dyn ControlFactory,
3505 identity: &ServiceIdentity,
3506 record: &InstallRecord,
3507) -> Option<DefinitionDrift> {
3508 let compare = match host_definition_kind(record.start_mode) {
3509 DefinitionKind::SystemdUnit => systemd_definition_drift,
3510 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3511 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3512 };
3513 let path = record.definition_path.clone()?;
3514 let installed = controls.installed_definition(&path)?;
3515 compare(identity, record, path, &installed)
3516}
3517
3518fn launchd_definition_drift(
3527 identity: &ServiceIdentity,
3528 record: &InstallRecord,
3529 path: PathBuf,
3530 installed: &str,
3531) -> Option<DefinitionDrift> {
3532 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3533 || DefinitionDrift {
3534 path,
3535 installed_by_version: record.installed_by_version.clone(),
3536 rendered: launchd_plist(&record.plan(identity.clone())),
3537 start_mode: record.start_mode,
3538 manager: DefinitionKind::LaunchdPlist,
3539 },
3540 )
3541}
3542
3543fn systemd_definition_drift(
3544 identity: &ServiceIdentity,
3545 record: &InstallRecord,
3546 path: PathBuf,
3547 installed: &str,
3548) -> Option<DefinitionDrift> {
3549 let rendered = systemd_unit(&record.plan(identity.clone()));
3550 (installed != rendered).then(|| DefinitionDrift {
3551 path,
3552 installed_by_version: record.installed_by_version.clone(),
3553 rendered,
3554 start_mode: record.start_mode,
3555 manager: DefinitionKind::SystemdUnit,
3556 })
3557}
3558
3559const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3564
3565#[derive(Debug, Clone, Serialize, Deserialize)]
3566struct RootRefusalFile {
3567 schema_version: u32,
3568 #[serde(default)]
3570 refusals: BTreeMap<String, RootRefusalEntry>,
3571}
3572
3573#[derive(Debug, Clone, Serialize, Deserialize)]
3574struct RootRefusalEntry {
3575 at: DateTime<Utc>,
3576 kind: String,
3577 root: String,
3578 detail: String,
3579}
3580
3581#[derive(Debug, Clone, PartialEq, Eq)]
3583pub struct RunnerRootRefusal {
3584 pub policy: String,
3586 pub at: DateTime<Utc>,
3588 pub kind: String,
3590 pub root: String,
3592 pub detail: String,
3595}
3596
3597pub fn record_runner_root_refusal(
3631 paths: &AppPaths,
3632 policy: &str,
3633 at: DateTime<Utc>,
3634 kind: &str,
3635 root: &str,
3636 detail: &str,
3637) -> Result<(), ServiceError> {
3638 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3639 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3640 refusals: BTreeMap::new(),
3641 });
3642 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3643 file.refusals.insert(
3644 policy.to_owned(),
3645 RootRefusalEntry {
3646 at,
3647 kind: kind.to_owned(),
3648 root: root.to_owned(),
3649 detail: detail.to_owned(),
3650 },
3651 );
3652 write_refusal_file(paths, &file)
3653}
3654
3655pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3666 let Some(mut file) = read_refusal_file(paths)? else {
3667 return Ok(());
3668 };
3669 if file.refusals.remove(policy).is_none() {
3670 return Ok(());
3671 }
3672 if file.refusals.is_empty() {
3673 let path = root_refusal_path(paths);
3674 return match std::fs::remove_file(&path) {
3675 Ok(()) => Ok(()),
3676 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3677 Err(error) => Err(ServiceError::Record {
3678 operation: "remove",
3679 path,
3680 detail: error.to_string(),
3681 }),
3682 };
3683 }
3684 write_refusal_file(paths, &file)
3685}
3686
3687pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3699 Ok(read_refusal_file(paths)?
3700 .map(|file| {
3701 file.refusals
3702 .into_iter()
3703 .map(|(policy, entry)| RunnerRootRefusal {
3704 policy,
3705 at: entry.at,
3706 kind: entry.kind,
3707 root: entry.root,
3708 detail: entry.detail,
3709 })
3710 .collect()
3711 })
3712 .unwrap_or_default())
3713}
3714
3715fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3716 read_state_record(&root_refusal_path(paths))
3717}
3718
3719fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3720 write_state_record(&root_refusal_path(paths), file)
3721}
3722
3723#[must_use]
3725pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3726 paths.state_dir().join(ROOT_REFUSAL_FILE)
3727}
3728
3729#[derive(Debug, Clone, PartialEq, Eq)]
3740pub enum BinaryPath {
3741 Current {
3743 path: PathBuf,
3745 },
3746 Missing {
3753 recorded: PathBuf,
3755 },
3756 NotExecutable {
3759 recorded: PathBuf,
3761 detail: String,
3763 },
3764 Diverged {
3766 recorded: PathBuf,
3768 registered: PathBuf,
3770 },
3771}
3772
3773impl BinaryPath {
3774 #[must_use]
3776 pub const fn is_error(&self) -> bool {
3777 !matches!(self, Self::Current { .. })
3778 }
3779
3780 #[must_use]
3782 pub fn recorded(&self) -> &Path {
3783 match self {
3784 Self::Current { path } => path,
3785 Self::Missing { recorded }
3786 | Self::NotExecutable { recorded, .. }
3787 | Self::Diverged { recorded, .. } => recorded,
3788 }
3789 }
3790}
3791
3792impl fmt::Display for BinaryPath {
3793 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3794 match self {
3795 Self::Current { path } => write!(f, "{}", path.display()),
3796 Self::Missing { recorded } => write!(
3797 f,
3798 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3799 package manager that moved the binary is the usual cause; an `npm i -g` \
3800 installation moves with the active Node version. Run `service install` again \
3801 from the binary that is now installed.",
3802 recorded.display()
3803 ),
3804 Self::NotExecutable { recorded, detail } => write!(
3805 f,
3806 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3807 from the installed binary.",
3808 recorded.display()
3809 ),
3810 Self::Diverged {
3811 recorded,
3812 registered,
3813 } => write!(
3814 f,
3815 "{} -- STALE: the service manager is registered to start {} instead. Something \
3816 has edited the registration since it was installed. Run `service uninstall` and \
3817 `service install`; neither touches configuration, secrets, or the cache.",
3818 recorded.display(),
3819 registered.display()
3820 ),
3821 }
3822 }
3823}
3824
3825#[must_use]
3833pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3834 match std::fs::metadata(recorded) {
3835 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3836 return BinaryPath::Missing {
3837 recorded: recorded.to_path_buf(),
3838 };
3839 }
3840 Err(error) => {
3841 return BinaryPath::NotExecutable {
3842 recorded: recorded.to_path_buf(),
3843 detail: format!("it cannot be inspected ({error})"),
3844 };
3845 }
3846 Ok(metadata) if !metadata.is_file() => {
3847 return BinaryPath::NotExecutable {
3848 recorded: recorded.to_path_buf(),
3849 detail: "what is there is not a file".to_string(),
3850 };
3851 }
3852 Ok(_) => {}
3853 }
3854 if let Some(registered) = registered
3855 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3856 {
3857 return BinaryPath::Diverged {
3858 recorded: recorded.to_path_buf(),
3859 registered: registered.to_path_buf(),
3860 };
3861 }
3862 BinaryPath::Current {
3863 path: recorded.to_path_buf(),
3864 }
3865}
3866
3867#[derive(Debug, Clone, PartialEq, Eq)]
3873pub struct Registration {
3874 pub manager: DefinitionKind,
3879 pub start_mode: StartMode,
3881 pub command_line: String,
3883 pub account: Option<String>,
3885 pub running: bool,
3887 pub starts_automatically: bool,
3894 pub restart_delay: Option<Duration>,
3903}
3904
3905impl Registration {
3906 #[must_use]
3908 pub fn binary(&self) -> Option<PathBuf> {
3909 executable_from_command_line(&self.command_line)
3910 }
3911}
3912
3913pub trait ServiceControl: fmt::Debug {
3919 fn manager(&self) -> DefinitionKind;
3921
3922 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3928
3929 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3940
3941 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3947
3948 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3955
3956 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3962}
3963
3964pub trait ControlFactory: fmt::Debug + Send + Sync {
3970 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3976
3977 fn installed_definition(&self, _path: &Path) -> Option<String> {
3981 None
3982 }
3983
3984 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
3989 None
3990 }
3991}
3992
3993#[derive(Debug, Clone, Copy, Default)]
3995pub struct HostControls;
3996
3997#[derive(Debug, Clone)]
4003pub struct Installed {
4004 pub plan: InstallPlan,
4006 pub definition: ServiceDefinition,
4008 pub record: InstallRecord,
4010 pub review: PrivilegeReview,
4012 pub runner_root: RootAccessSummary,
4017 pub replaced_existing: bool,
4025}
4026
4027#[derive(Debug, Clone, PartialEq, Eq)]
4029pub struct Uninstalled {
4030 pub removed_registration: bool,
4032 pub removed_record: bool,
4034 pub removed_definition: Option<PathBuf>,
4036 pub preserved: Vec<PathBuf>,
4043}
4044
4045impl fmt::Display for Uninstalled {
4046 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4047 if self.removed_registration {
4048 writeln!(f, "The service registration was removed.")?;
4049 } else {
4050 writeln!(f, "There was no service registration to remove.")?;
4051 }
4052 writeln!(f, "Nothing else was deleted. These are untouched:")?;
4053 for path in &self.preserved {
4054 writeln!(f, " {}", path.display())?;
4055 }
4056 write!(
4057 f,
4058 "The stored GitHub token is untouched too; `auth logout` is what purges it."
4059 )
4060 }
4061}
4062
4063#[derive(Debug, Clone, PartialEq, Eq)]
4065pub struct StartModeChange {
4066 pub from: StartMode,
4068 pub to: StartMode,
4070 pub changed: bool,
4072 pub store_scope: crate::secrets::SecretScope,
4075 pub runner_root: RootAccessSummary,
4081}
4082
4083impl fmt::Display for StartModeChange {
4084 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4085 if !self.changed {
4086 return write!(f, "The service already starts at {}.", self.to);
4087 }
4088 write!(
4089 f,
4090 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4091 if the token was stored under the other scope, run `auth login` again. {}",
4092 self.to, self.from, self.store_scope, self.runner_root
4093 )
4094 }
4095}
4096
4097#[derive(Debug, Clone)]
4105pub struct ServiceOperations {
4106 paths: AppPaths,
4107 identity: ServiceIdentity,
4108 controls: std::sync::Arc<dyn ControlFactory>,
4109 runner_root: Option<LocalAbsolutePath>,
4110}
4111
4112impl ServiceOperations {
4113 #[must_use]
4115 pub fn on_this_host(paths: AppPaths) -> Self {
4116 Self::with_controls(
4117 paths,
4118 ServiceIdentity::product(),
4119 std::sync::Arc::new(HostControls),
4120 )
4121 }
4122
4123 #[must_use]
4131 pub fn with_controls(
4132 paths: AppPaths,
4133 identity: ServiceIdentity,
4134 controls: std::sync::Arc<dyn ControlFactory>,
4135 ) -> Self {
4136 Self {
4137 paths,
4138 identity,
4139 controls,
4140 runner_root: None,
4141 }
4142 }
4143
4144 #[must_use]
4163 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4164 self.runner_root = Some(root);
4165 self
4166 }
4167
4168 #[must_use]
4170 pub const fn paths(&self) -> &AppPaths {
4171 &self.paths
4172 }
4173
4174 #[must_use]
4176 pub const fn identity(&self) -> &ServiceIdentity {
4177 &self.identity
4178 }
4179
4180 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4200 self.paths
4201 .create_all()
4202 .map_err(|source| ServiceError::Paths {
4203 source: Box::new(source),
4204 })?;
4205
4206 let _guard = self.refuse_while_an_agent_runs()?;
4209 self.refuse_foreign("replace")?;
4213
4214 let (replacing, previous_was_running) = match self.find_registration()? {
4243 Some((existing, registration)) if existing == request.start_mode() => {
4244 (true, registration.running)
4245 }
4246 Some((existing, _)) => {
4247 return Err(ServiceError::AlreadyInstalled {
4248 name: self.identity.name().to_string(),
4249 existing,
4250 requested: request.start_mode(),
4251 });
4252 }
4253 None => (false, false),
4254 };
4255
4256 let plan = InstallPlan::resolve(
4257 self.identity.clone(),
4258 request,
4259 ServiceDirectories::of(&self.paths),
4260 )?;
4261
4262 let control = self.controls.control(plan.start_mode())?;
4267
4268 let root = self.prepare_runner_root(plan.start_mode())?;
4274
4275 let previous = if replacing {
4280 InstallRecord::read(&self.paths).ok().flatten()
4281 } else {
4282 None
4283 };
4284 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4285 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4286 }
4287
4288 let definition = match control.install(&plan) {
4289 Ok(definition) => definition,
4290 Err(cause) => {
4291 let restored =
4294 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4295 return Err(rolled_back(
4296 retained_runner_root(&root),
4297 restored,
4298 "install",
4299 &self.identity,
4300 cause,
4301 ));
4302 }
4303 };
4304 let review = review_least_privilege(&definition, &plan);
4305 let record = InstallRecord::of(&plan, &definition, Utc::now());
4306 if let Err(cause) = record.write(&self.paths) {
4307 let rollback = control
4308 .uninstall(&self.identity)
4309 .map(|_| ())
4310 .and_then(|()| {
4311 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4312 });
4313 return Err(rolled_back(
4314 retained_runner_root(&root),
4315 rollback,
4316 "install",
4317 &self.identity,
4318 cause,
4319 ));
4320 }
4321
4322 if let Err(cause) = control.start(&self.identity) {
4329 let remove_new = control.uninstall(&self.identity).map(|_| ());
4330 let restore_registration = remove_new.and_then(|()| {
4331 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4332 });
4333 let restore_record = restore_registration.and_then(|()| match &previous {
4334 Some(previous) => previous.write(&self.paths),
4335 None => InstallRecord::remove(&self.paths).map(|_| ()),
4336 });
4337 return Err(rolled_back(
4338 retained_runner_root(&root),
4339 restore_record,
4340 "install and start",
4341 &self.identity,
4342 cause,
4343 ));
4344 }
4345 Ok(Installed {
4346 plan,
4347 definition,
4348 record,
4349 review,
4350 runner_root: root.summary().clone(),
4351 replaced_existing: replacing,
4352 })
4353 }
4354
4355 fn reinstate(
4366 &self,
4367 control: &dyn ServiceControl,
4368 previous: Option<&InstallRecord>,
4369 was_running: bool,
4370 ) -> Result<(), ServiceError> {
4371 let Some(record) = previous else {
4372 return Ok(());
4373 };
4374 control.install(&record.plan(self.identity.clone()))?;
4375 if was_running {
4376 control.start(&self.identity)?;
4377 }
4378 Ok(())
4379 }
4380
4381 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4390 let record = InstallRecord::read(&self.paths).ok().flatten();
4391 let removed_definition = record
4392 .as_ref()
4393 .and_then(|record| record.definition_path.clone());
4394
4395 let mut removed_registration = false;
4396 let mut foreign = None;
4403 for mode in [StartMode::Boot, StartMode::Login] {
4404 let control = self.controls.control(mode)?;
4405 if let Some(directories) = control
4406 .query(&self.identity)
4407 .ok()
4408 .flatten()
4409 .and_then(|registration| self.foreign_directories(®istration))
4410 {
4411 foreign = Some(directories);
4412 continue;
4413 }
4414 if control.uninstall(&self.identity)? {
4415 removed_registration = true;
4416 }
4417 }
4418 if !removed_registration && let Some(directories) = foreign {
4419 return Err(self.foreign_refusal("uninstall", directories));
4420 }
4421 let removed_record = InstallRecord::remove(&self.paths)?;
4422 Ok(Uninstalled {
4423 removed_registration,
4424 removed_record,
4425 removed_definition: removed_definition.filter(|_| removed_registration),
4426 preserved: self
4427 .paths
4428 .all()
4429 .iter()
4430 .map(|(_, path)| (*path).to_path_buf())
4431 .collect(),
4432 })
4433 }
4434
4435 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4457 let Some(record) = InstallRecord::read(&self.paths)? else {
4458 return Err(ServiceError::NotInstalled {
4459 name: self.identity.name().to_string(),
4460 operation: "switch the start mode of",
4461 });
4462 };
4463 let from = record.start_mode;
4464 if from == to {
4465 return Ok(StartModeChange {
4469 from,
4470 to,
4471 changed: false,
4472 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4473 runner_root: RootAccessSummary::NotApplicable,
4474 });
4475 }
4476
4477 self.refuse_foreign("switch the start mode of")?;
4480
4481 #[cfg(windows)]
4482 let arguments = {
4483 let mut arguments = record.arguments.clone();
4484 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4485 if to == StartMode::Boot {
4486 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4487 }
4488 arguments
4489 };
4490 #[cfg(not(windows))]
4491 let arguments = record.arguments.clone();
4492 let plan = InstallPlan::unchecked(
4493 self.identity.clone(),
4494 to,
4495 record.binary.clone(),
4496 record.directories.clone(),
4497 )
4498 .with_arguments(arguments)
4499 .with_restart(record.restart());
4500 let plan = if record.starts_on_demand {
4501 plan.started_on_demand()
4502 } else {
4503 plan
4504 };
4505 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4506 Ok(store) => plan.with_secret_guard(store.guard()),
4507 Err(_) => plan,
4508 };
4509
4510 let target = self.controls.control(to)?;
4517 let previous = self.controls.control(from)?;
4522
4523 let root = self.prepare_runner_root(to)?;
4531
4532 let definition = match target.install(&plan) {
4533 Ok(definition) => definition,
4534 Err(cause) => {
4535 return Err(undo_runner_root(
4536 &root,
4537 "switch start mode",
4538 &self.identity,
4539 cause,
4540 ));
4541 }
4542 };
4543 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4544 if let Err(cause) = next_record.write(&self.paths) {
4545 return Err(rolled_back(
4546 retained_runner_root(&root),
4547 target.uninstall(&self.identity),
4548 "switch start mode",
4549 &self.identity,
4550 cause,
4551 ));
4552 }
4553
4554 if let Err(cause) = previous.uninstall(&self.identity) {
4558 let target_rollback = target.uninstall(&self.identity);
4559 let record_rollback = record.write(&self.paths);
4560 return Err(rolled_back(
4561 retained_runner_root(&root),
4562 target_rollback.and(record_rollback),
4563 "switch start mode",
4564 &self.identity,
4565 cause,
4566 ));
4567 }
4568 Ok(StartModeChange {
4569 from,
4570 to,
4571 changed: true,
4572 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4573 runner_root: root.summary().clone(),
4574 })
4575 }
4576
4577 pub fn start(&self) -> Result<(), ServiceError> {
4583 let mode = self.own_registration_mode("start")?;
4584 self.controls.control(mode)?.start(&self.identity)
4585 }
4586
4587 fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
4595 match self.registration_for_status()? {
4596 None => Err(ServiceError::NotInstalled {
4597 name: self.identity.name().to_string(),
4598 operation,
4599 }),
4600 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4601 Some((registration, None)) => Ok(registration.start_mode),
4602 }
4603 }
4604
4605 pub fn stop(&self) -> Result<bool, ServiceError> {
4611 let mode = self.own_registration_mode("stop")?;
4612 self.controls.control(mode)?.stop(&self.identity)
4613 }
4614
4615 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4646 match self.registration_for_status()? {
4647 Some((registration, Some(directories))) => {
4648 Ok(self.foreign_status(registration, directories))
4649 }
4650 found => {
4651 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4652 Ok(record) => (record, None),
4653 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4654 Err(error) => return Err(error),
4655 };
4656 self.status_at(
4657 &self.paths,
4658 record,
4659 record_refused.as_ref(),
4660 found.map(|(registration, _)| registration),
4661 )
4662 }
4663 }
4664 }
4665
4666 fn status_at(
4669 &self,
4670 paths: &AppPaths,
4671 record: Option<InstallRecord>,
4672 record_refused: Option<&ServiceError>,
4673 found: Option<Registration>,
4674 ) -> Result<ServiceStatus, ServiceError> {
4675 let last_github_contact = last_github_contact(paths)?;
4676 let drift = record
4677 .as_ref()
4678 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4679 let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
4680 let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
4681 let probed = self.controls.unattended_login();
4682 let unattended = record.as_ref().and_then(|record| {
4683 let account = probed
4686 .as_ref()
4687 .map_or_else(String::new, |_| service_account_name(&record.account));
4688 unattended_gap(record.start_mode, &account, probed.as_ref())
4689 });
4690 let now = Utc::now();
4691 let installed = record.is_some() || found.is_some();
4692 Ok(ServiceStatus::compose(
4693 self.identity.clone(),
4694 record,
4695 record_refused,
4696 found,
4697 last_github_contact,
4698 paths,
4699 )
4700 .with_definition_drift(drift)
4701 .with_credential_rejection(credential_rejected_since)
4702 .with_credential_unreadable(credential_unreadable_since)
4703 .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
4704 .with_unattended_gap(unattended)
4705 .with_host_unfit(crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed))
4706 .with_stall(crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact)))
4707 }
4708
4709 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4716 Ok(InstallRecord::read(&self.paths)?
4717 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4718 }
4719
4720 #[cfg(target_os = "macos")]
4742 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4743 let root = unsafe { libc::getuid() } == 0;
4749 if root != (drift.start_mode == StartMode::Boot) {
4750 return Err(ServiceError::Control {
4751 operation: "rewrite",
4752 name: self.identity.name().to_string(),
4753 manager: "launchd",
4754 detail: format!(
4755 "the install record says --start-at {}, but this daemon runs as {}; the plist \
4756 is left as it is",
4757 drift.start_mode,
4758 if root { "root" } else { "the operator" }
4759 ),
4760 });
4761 }
4762 replace_definition(self.identity.name(), drift)?;
4763 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4767 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4768 let _ = record.write(&self.paths);
4769 }
4770 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4771 }
4772
4773 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4783 #[cfg(not(windows))]
4784 {
4785 let _ = (mode, &self.runner_root);
4788 Ok(RootAccessChange::not_applicable())
4789 }
4790 #[cfg(windows)]
4791 {
4792 let wrap = |source| ServiceError::RunnerRoot {
4793 source: Box::new(source),
4794 };
4795 if let Some(root) = self
4813 .runner_root
4814 .as_ref()
4815 .filter(|_| self.identity.is_fixture() || cfg!(test))
4816 {
4817 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4818 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4819 .map_err(wrap);
4820 }
4821
4822 let admission = match ServiceAccount::for_start_mode(mode) {
4823 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4826 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4830 RootAdmission::of_this_account().map_err(wrap)?
4831 }
4832 };
4833 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4834 }
4835 }
4836
4837 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4839 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4840 |source| match source {
4841 held @ LockError::Held { .. } => ServiceError::LockHeld {
4842 source: Box::new(held),
4843 },
4844 other => ServiceError::LockUnreadable {
4845 source: Box::new(other),
4846 },
4847 },
4848 )
4849 }
4850
4851 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4853 for mode in [StartMode::Boot, StartMode::Login] {
4854 let control = self.controls.control(mode)?;
4855 if let Some(registration) = control.query(&self.identity)? {
4856 return Ok(Some((registration.start_mode, registration)));
4857 }
4858 }
4859 Ok(None)
4860 }
4861
4862 fn registration_for_status(
4871 &self,
4872 ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
4873 let Some((mode, found)) = self.find_registration()? else {
4874 return Ok(None);
4875 };
4876 let Some(directories) = self.foreign_directories(&found) else {
4877 return Ok(Some((found, None)));
4878 };
4879 let other = match mode {
4880 StartMode::Boot => StartMode::Login,
4881 StartMode::Login => StartMode::Boot,
4882 };
4883 let own = self
4884 .controls
4885 .control(other)
4886 .ok()
4887 .and_then(|control| control.query(&self.identity).ok().flatten())
4888 .filter(|registration| self.foreign_directories(registration).is_none());
4889 Ok(Some(match own {
4890 Some(own) => (own, None),
4891 None => (found, Some(directories)),
4892 }))
4893 }
4894
4895 fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
4898 match self.registration_for_status()? {
4899 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
4900 _ => Ok(()),
4901 }
4902 }
4903
4904 fn foreign_refusal(
4907 &self,
4908 operation: &'static str,
4909 directories: ServiceDirectories,
4910 ) -> ServiceError {
4911 let foreign = self.foreign_service_at(directories);
4912 ServiceError::ForeignInstallation {
4913 name: self.identity.name().to_string(),
4914 operation,
4915 installed_by: foreign.installed_by(),
4916 act_as: foreign.act_as(),
4917 }
4918 }
4919
4920 fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
4923 ServiceDirectories::from_command_line(®istration.command_line)
4924 .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
4925 }
4926
4927 #[must_use]
4932 pub fn foreign_service(&self) -> Option<ForeignService> {
4933 let (_, directories) = self.registration_for_status().ok()??;
4934 Some(self.foreign_service_at(directories?))
4935 }
4936
4937 fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
4940 let root = directories.root();
4941 let home = host_home();
4942 let this_account = home
4945 .as_deref()
4946 .filter(|home| contains_directory(home, &root))
4947 .and_then(|_| {
4948 if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
4949 Some(format!(
4950 "with `--data-dir {}`",
4951 quote_argument(&root.to_string_lossy())
4952 ))
4953 } else {
4954 AppPaths::discover()
4955 .ok()
4956 .filter(|standard| directories == ServiceDirectories::of(standard))
4957 .map(|_| "without `--data-dir`".to_owned())
4958 }
4959 });
4960 ForeignService {
4961 owner: data_root_owner(&root, home.as_deref()),
4962 this_account,
4963 root,
4964 directories,
4965 local_root: ServiceDirectories::of(&self.paths).root(),
4966 local_record: InstallRecord::path(&self.paths),
4967 unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
4968 unreadable: None,
4969 }
4970 }
4971
4972 fn foreign_status(
4979 &self,
4980 registration: Registration,
4981 directories: ServiceDirectories,
4982 ) -> ServiceStatus {
4983 let service_paths = directories.paths();
4984 let mut foreign = self.foreign_service_at(directories);
4985 let unreadable = match InstallRecord::read(&service_paths) {
4986 Ok(Some(record)) => {
4987 match self.status_at(
4988 &service_paths,
4989 Some(record),
4990 None,
4991 Some(registration.clone()),
4992 ) {
4993 Ok(status) => return status.with_foreign(foreign),
4994 Err(error) => error.to_string(),
4995 }
4996 }
4997 Ok(None) => format!(
4998 "there is no install record at {}",
4999 InstallRecord::path(&service_paths).display()
5000 ),
5001 Err(
5006 ServiceError::RecordNotPermitted { path, detail }
5007 | ServiceError::RecordUnreadable { path, detail }
5008 | ServiceError::Record { path, detail, .. },
5009 ) => format!("this account cannot read {} ({detail})", path.display()),
5010 Err(error) => error.to_string(),
5011 };
5012 foreign.unreadable = Some(unreadable);
5013 ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5014 }
5015
5016 pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5040 let config = self.paths.config_dir();
5041 let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5042 let mut service_root = None;
5043 for mode in [StartMode::Boot, StartMode::Login] {
5044 let registration = self
5045 .controls
5046 .control(mode)
5047 .and_then(|control| control.query(&self.identity))
5048 .map_err(|error| {
5049 in_use(format!(
5050 "whether a {mode} registration runs against it could not be read ({error})"
5051 ))
5052 })?;
5053 let Some(registration) = registration else {
5054 continue;
5055 };
5056 let Some(directories) = self.foreign_directories(®istration) else {
5057 return Err(in_use(format!(
5058 "{} holds a registration that runs against {}. `service status` describes it",
5059 registration.manager,
5060 config.display()
5061 )));
5062 };
5063 if let Some(overlap) = directories.all().into_iter().find(|directory| {
5064 contains_directory(directory, config) || contains_directory(config, directory)
5065 }) {
5066 return Err(in_use(format!(
5067 "the machine's service runs against {}, which overlaps {}",
5068 overlap.display(),
5069 config.display()
5070 )));
5071 }
5072 service_root.get_or_insert_with(|| directories.root());
5073 }
5074 let _lock =
5075 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5076 in_use(format!(
5077 "an agent may be running against these directories: {source}"
5078 ))
5079 })?;
5080 let failed = |operation: &'static str, path: &Path| {
5081 let path = path.to_path_buf();
5082 move |error: std::io::Error| ServiceError::Record {
5083 operation,
5084 path,
5085 detail: error.to_string(),
5086 }
5087 };
5088 let kept = [
5090 self.paths.state_dir(),
5091 self.paths.runtime_dir(),
5092 self.paths.logs_dir(),
5093 ];
5094 let mut entries = Vec::new();
5095 for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5096 let entry = entry.map_err(failed("list", config))?.path();
5097 let archived = entry
5098 .file_name()
5099 .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5100 if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5101 entries.push(entry);
5102 }
5103 }
5104 if entries.is_empty() {
5105 return Ok(ForgottenLocal {
5106 archive: None,
5107 moved: entries,
5108 service_root,
5109 });
5110 }
5111 entries.sort();
5112 let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5113 std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5114 let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5115 for (index, entry) in entries.iter().enumerate() {
5116 if let Err(error) = std::fs::rename(entry, inside(entry)) {
5117 let stranded: Vec<String> = entries[..index]
5118 .iter()
5119 .rev()
5120 .filter(|done| std::fs::rename(inside(done), done).is_err())
5121 .map(|done| inside(done).display().to_string())
5122 .collect();
5123 let _ = std::fs::remove_dir(&archive);
5124 let mut detail = error.to_string();
5125 if !stranded.is_empty() {
5126 detail.push_str(&format!(
5127 "; these could not be put back and are still in the archive: {}",
5128 stranded.join(", ")
5129 ));
5130 }
5131 return Err(ServiceError::Record {
5132 operation: "archive",
5133 path: entry.clone(),
5134 detail,
5135 });
5136 }
5137 }
5138 Ok(ForgottenLocal {
5139 archive: Some(archive),
5140 moved: entries,
5141 service_root,
5142 })
5143 }
5144}
5145
5146pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5149
5150#[derive(Debug, Clone, PartialEq, Eq)]
5152pub struct ForgottenLocal {
5153 pub archive: Option<PathBuf>,
5155 pub moved: Vec<PathBuf>,
5157 pub service_root: Option<PathBuf>,
5160}
5161
5162impl fmt::Display for ForgottenLocal {
5163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5164 let Some(archive) = &self.archive else {
5165 return write!(
5166 f,
5167 "Nothing to archive: this account's configuration directory is empty."
5168 );
5169 };
5170 writeln!(f, "Archived this account's unused local configuration")?;
5171 writeln!(f, " archive {}", archive.display())?;
5172 for path in &self.moved {
5173 writeln!(f, " moved {}", path.display())?;
5174 }
5175 if let Some(root) = &self.service_root {
5176 writeln!(
5177 f,
5178 " service data root {} (untouched)",
5179 root.display()
5180 )?;
5181 }
5182 write!(
5183 f,
5184 " restore move everything in the archive back into its parent \
5185 directory"
5186 )
5187 }
5188}
5189
5190fn same_directory(left: &Path, right: &Path) -> bool {
5193 let text = |path: &Path| {
5194 path.to_string_lossy()
5195 .trim_end_matches(['/', '\\'])
5196 .to_owned()
5197 };
5198 same_path_text(&text(left), &text(right))
5199 || matches!(
5200 (std::fs::canonicalize(left), std::fs::canonicalize(right)),
5201 (Ok(left), Ok(right)) if left == right
5202 )
5203}
5204
5205const REMEDY_OPENINGS: [&str; 8] = [
5208 ". Run ",
5209 ". If nothing is waiting",
5210 ". Re-register",
5211 ". Switch the start mode",
5212 " Sign in again",
5213 ". Fix: ",
5214 "; to do it now",
5215 "; if the service manager denies",
5216];
5217
5218fn diagnosis_only(detail: &str) -> String {
5227 let cut = REMEDY_OPENINGS
5228 .iter()
5229 .filter_map(|opening| detail.find(opening))
5230 .min()
5231 .unwrap_or(detail.len());
5232 let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
5233 let names_a_change = |text: &str| {
5234 [
5235 "service install",
5236 "service uninstall",
5237 "auth login",
5238 "launchctl",
5239 "systemctl",
5240 ]
5241 .iter()
5242 .any(|command| text.contains(command))
5243 };
5244 if !names_a_change(diagnosis) {
5245 return diagnosis.to_owned();
5246 }
5247 let end = [", ", "; ", ". "]
5249 .iter()
5250 .filter_map(|separator| diagnosis.find(separator))
5251 .min()
5252 .unwrap_or(diagnosis.len());
5253 let first = &diagnosis[..end];
5254 if names_a_change(first) {
5255 "the installation's own records report this".to_owned()
5256 } else {
5257 first.to_owned()
5258 }
5259}
5260
5261fn path_key(path: &Path) -> Vec<String> {
5264 path.components()
5265 .map(|component| {
5266 let text = component.as_os_str().to_string_lossy();
5267 if cfg!(windows) {
5268 text.to_lowercase()
5269 } else {
5270 text.into_owned()
5271 }
5272 })
5273 .collect()
5274}
5275
5276fn contains_directory(outer: &Path, inner: &Path) -> bool {
5279 path_key(inner).starts_with(&path_key(outer))
5280 || matches!(
5281 (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
5282 (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
5283 )
5284}
5285
5286#[must_use]
5291pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
5292 let target = path_key(root);
5293 if cfg!(windows) {
5294 if target
5295 .windows(3)
5296 .any(|run| run == ["system32", "config", "systemprofile"])
5297 {
5298 return Some("LocalSystem".to_owned());
5299 }
5300 } else if ["/root", "/var/root", "/private/var/root"]
5301 .iter()
5302 .any(|home| root.starts_with(home))
5303 {
5304 return Some("root".to_owned());
5305 }
5306 let users = path_key(home?.parent()?);
5307 if users.len() < 2 {
5310 return None;
5311 }
5312 if target.len() > users.len() && target[..users.len()] == users[..] {
5313 root.components()
5314 .nth(users.len())
5315 .map(|name| name.as_os_str().to_string_lossy().into_owned())
5316 } else {
5317 None
5318 }
5319}
5320
5321#[derive(Debug, Clone, PartialEq, Eq)]
5335pub struct ForeignService {
5336 pub directories: ServiceDirectories,
5338 pub root: PathBuf,
5340 pub owner: Option<String>,
5342 pub this_account: Option<String>,
5347 pub local_root: PathBuf,
5349 pub local_record: PathBuf,
5351 pub unused_local_record: Option<InstallRecord>,
5353 pub unreadable: Option<String>,
5356}
5357
5358impl ForeignService {
5359 #[must_use]
5361 pub fn where_to_look(&self) -> String {
5362 if let Some(reach) = &self.this_account {
5363 return reach.clone();
5364 }
5365 let elevated = if cfg!(windows) {
5366 "from an elevated terminal (Run as administrator)"
5367 } else {
5368 "with sudo"
5369 };
5370 match &self.owner {
5371 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5372 format!("{elevated}, or signed in as {owner}")
5373 }
5374 _ => elevated.to_owned(),
5375 }
5376 }
5377
5378 #[must_use]
5383 pub fn act_as(&self) -> String {
5384 if let Some(reach) = &self.this_account {
5385 return reach.clone();
5386 }
5387 match &self.owner {
5388 Some(owner) if owner != "LocalSystem" && owner != "root" => {
5389 format!("signed in as {owner}")
5390 }
5391 _ => format!(
5392 "from the account whose data root {} is",
5393 self.root.display()
5394 ),
5395 }
5396 }
5397
5398 #[must_use]
5401 pub const fn is_unread(&self) -> bool {
5402 self.unreadable.is_some()
5403 }
5404
5405 #[must_use]
5407 pub fn installed_by(&self) -> String {
5408 match &self.owner {
5409 Some(owner) => format!("{owner}'s data root {}", self.root.display()),
5410 None => format!("the data root {}", self.root.display()),
5411 }
5412 }
5413
5414 #[must_use]
5416 pub fn label(&self) -> String {
5417 match &self.owner {
5418 Some(owner) => format!("{} ({owner})", self.root.display()),
5419 None => self.root.display().to_string(),
5420 }
5421 }
5422
5423 #[must_use]
5425 pub fn unused_local_note(&self) -> Option<String> {
5426 let record = self.unused_local_record.as_ref()?;
5427 Some(format!(
5428 "unused local configuration from an earlier install: {} records a {} registration \
5429 made by runner-manager {} on {}, and no service on this machine runs against it. \
5430 `runner-manager host forget-local` moves it aside; it never touches the service's \
5431 own directories or any secret.",
5432 self.local_record.display(),
5433 record.start_mode,
5434 record.installed_by_version,
5435 record.installed_at.format("%Y-%m-%d"),
5436 ))
5437 }
5438}
5439
5440#[derive(Debug, Clone, PartialEq, Eq)]
5446pub struct StatusProblem {
5447 pub subject: &'static str,
5449 pub detail: String,
5451}
5452
5453impl fmt::Display for StatusProblem {
5454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5455 write!(f, "{}: {}", self.subject, self.detail)
5456 }
5457}
5458
5459#[derive(Debug, Clone)]
5468pub struct ServiceStatus {
5469 identity: ServiceIdentity,
5470 record: Option<InstallRecord>,
5471 registration: Option<Registration>,
5472 binary: Option<BinaryPath>,
5473 log_file: PathBuf,
5474 store: Option<crate::secrets::ActiveStore>,
5475 last_github_contact: Option<DateTime<Utc>>,
5476 runner_root: Option<(PathBuf, RootAccessReport)>,
5477 credential_rejected_since: Option<DateTime<Utc>>,
5478 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
5479 unattended_gap: Option<UnattendedGap>,
5480 foreign: Option<ForeignService>,
5481 problems: Vec<StatusProblem>,
5482 notes: Vec<String>,
5483}
5484
5485#[derive(Debug, Clone, PartialEq, Eq)]
5488pub struct UnattendedGap {
5489 pub reason: String,
5491 pub remedy: String,
5493}
5494
5495#[must_use]
5498pub fn service_account_name(account: &ServiceAccount) -> String {
5499 match account {
5500 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
5502 .ok()
5503 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
5504 .or_else(crate::unattended_login::current_account)
5505 .unwrap_or_else(|| "the operator".to_owned()),
5506 other => other.as_str().to_owned(),
5507 }
5508}
5509
5510#[must_use]
5519pub fn unattended_gap(
5520 mode: StartMode,
5521 account: &str,
5522 found: Option<&crate::unattended_login::UnattendedLogin>,
5523) -> Option<UnattendedGap> {
5524 let Some(found) = found else {
5525 return (mode == StartMode::Login).then(|| UnattendedGap {
5526 reason: "This registration starts at login, so the agent does not run until the \
5527 operator signs in; this host does not resume work after an unattended \
5528 reboot."
5529 .to_owned(),
5530 remedy: "For machine-on operation, move the service and credential to `--start-at \
5531 boot` from an elevated terminal."
5532 .to_owned(),
5533 });
5534 };
5535 let resume = crate::unattended_login::resume(found, mode, account);
5536 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
5537 return None;
5540 }
5541 Some(UnattendedGap {
5542 reason: resume.explain(account)?,
5543 remedy: resume.remedy().unwrap_or_else(|| {
5544 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
5545 FileVault can be read."
5546 .to_owned()
5547 }),
5548 })
5549}
5550
5551impl ServiceStatus {
5552 fn compose(
5557 identity: ServiceIdentity,
5558 record: Option<InstallRecord>,
5559 record_refused: Option<&ServiceError>,
5560 registration: Option<Registration>,
5561 last_github_contact: Option<DateTime<Utc>>,
5562 paths: &AppPaths,
5563 ) -> Self {
5564 let mut problems = Vec::new();
5565 let mut notes = Vec::new();
5566
5567 if let Some(refusal) = record_refused {
5568 problems.push(StatusProblem {
5573 subject: "install record",
5574 detail: refusal.to_string(),
5575 });
5576 }
5577
5578 let log_file = record.as_ref().map_or_else(
5579 || ServiceDirectories::of(paths).log_file(),
5580 |record| record.log_file.clone(),
5581 );
5582
5583 let binary = record.as_ref().map(|record| {
5584 inspect_binary(
5585 &record.binary,
5586 registration
5587 .as_ref()
5588 .and_then(Registration::binary)
5589 .as_deref(),
5590 )
5591 });
5592 if let Some(state) = &binary
5593 && state.is_error()
5594 {
5595 problems.push(StatusProblem {
5596 subject: "binary",
5597 detail: state.to_string(),
5598 });
5599 }
5600
5601 match (&record, ®istration) {
5602 (Some(_), None) => problems.push(StatusProblem {
5603 subject: "registration",
5604 detail: "this host has a service record but no service manager knows the \
5605 registration. Run `service install` again; it deletes nothing."
5606 .to_string(),
5607 }),
5608 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
5613 subject: "record",
5614 detail: format!(
5615 "{} holds a registration for this service but there is no install record, so \
5616 the path it was installed from and the directories it was installed against \
5617 are unknown. Run `service uninstall` and `service install`.",
5618 found.manager
5619 ),
5620 }),
5621 (None, Some(_)) => {}
5622 (Some(record), Some(found)) => {
5623 if record.start_mode != found.start_mode {
5624 problems.push(StatusProblem {
5625 subject: "start mode",
5626 detail: format!(
5627 "the record says {} and {} holds a {} registration. Switch the start \
5628 mode again to make them agree.",
5629 record.start_mode, found.manager, found.start_mode
5630 ),
5631 });
5632 }
5633 if record.start_mode == StartMode::Boot && !found.starts_automatically {
5634 problems.push(StatusProblem {
5635 subject: "start mode",
5636 detail: format!(
5637 "{} holds the registration but will not start it by itself, so this \
5638 host does not resume work after a reboot.",
5639 found.manager
5640 ),
5641 });
5642 }
5643 if let Some(actual) = found.restart_delay {
5644 let expected = record.restart().effective_delay(found.manager);
5645 if actual != expected {
5646 problems.push(StatusProblem {
5647 subject: "restart policy",
5648 detail: format!(
5649 "the record says the service restarts after {}s and {} reports \
5650 {}s. Something has edited the registration since it was \
5651 installed.",
5652 expected.as_secs(),
5653 found.manager,
5654 actual.as_secs()
5655 ),
5656 });
5657 } else if expected != record.restart().delay() {
5658 notes.push(format!(
5663 "{} expresses the restart delay in whole minutes, so the {}s asked \
5664 for is enforced as {}s. The service therefore never restarts faster \
5665 than the configured bound.",
5666 found.manager,
5667 record.restart().delay().as_secs(),
5668 expected.as_secs()
5669 ));
5670 }
5671 }
5672 if found.starts_automatically && !found.running {
5673 problems.push(StatusProblem {
5674 subject: "runtime",
5675 detail: format!(
5676 "{} holds an automatic registration, but the daemon is stopped. Run \
5677 `runner-manager service install --start-at {}`; if the service \
5678 manager denies replacement, retry from an elevated terminal. If it \
5679 stops again, inspect {}.",
5680 found.manager,
5681 found.start_mode,
5682 log_file.display()
5683 ),
5684 });
5685 }
5686 }
5687 (None, None) => {}
5688 }
5689
5690 let store = record.as_ref().and_then(|record| {
5695 let registered_mode = registration
5696 .as_ref()
5697 .map_or(record.start_mode, |found| found.start_mode);
5698 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
5699 .ok()
5700 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
5701 });
5702 if let Some(store) = &store
5703 && !store.agrees_with_start_mode()
5704 {
5705 problems.push(StatusProblem {
5706 subject: "secret store",
5707 detail: format!(
5708 "{store}. Run `auth login` again so the token is stored where the registered \
5709 start mode can read it."
5710 ),
5711 });
5712 }
5713
5714 if last_github_contact.is_none() {
5715 notes.push(
5716 "GitHub has not been reached successfully since this host's state directory was \
5717 created."
5718 .to_string(),
5719 );
5720 }
5721
5722 let runner_root = crate::runner_root::default_runner_root(paths)
5729 .ok()
5730 .map(|root| {
5731 let path = root.as_path().to_path_buf();
5732 let report = crate::runner_root_access::report(&path);
5733 (path, report)
5734 });
5735 if let Some((
5745 path,
5746 RootAccessReport::Present {
5747 broad_write: true, ..
5748 },
5749 )) = &runner_root
5750 {
5751 notes.push(format!(
5752 "the platform default runner root {} can be written by ordinary local users, so \
5753 it is not a safe place to run jobs. `service install` refuses it rather than \
5754 tightening it, because the contents of a directory anybody could write cannot \
5755 be trusted: remove or empty it, or choose another root with `runner-manager \
5756 host set-runtime-root --path <PATH>`.",
5757 path.display()
5758 ));
5759 }
5760
5761 match runner_root_refusals(paths) {
5784 Ok(refusals) => {
5785 for refusal in refusals {
5786 notes.push(format!(
5787 "policy {} started no runner: its runner root {} refused the launch \
5788 ({}), last at {}. {} This clears when that policy next places a \
5789 runner.",
5790 refusal.policy,
5791 refusal.root,
5792 refusal.kind,
5793 refusal.at.to_rfc3339(),
5794 refusal.detail,
5795 ));
5796 }
5797 }
5798 Err(error) => notes.push(format!(
5799 "whether the agent could use its runner roots could not be read: {error}"
5800 )),
5801 }
5802
5803 Self {
5804 identity,
5805 record,
5806 registration,
5807 binary,
5808 log_file,
5809 store,
5810 last_github_contact,
5811 runner_root,
5812 credential_rejected_since: None,
5813 launches_blocked: None,
5814 unattended_gap: None,
5815 foreign: None,
5816 problems,
5817 notes,
5818 }
5819 }
5820
5821 fn with_foreign(mut self, foreign: ForeignService) -> Self {
5832 let act_as = foreign.act_as();
5833 for problem in &mut self.problems {
5834 problem.detail = format!(
5835 "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
5836 own directories, not the service's.",
5837 diagnosis_only(&problem.detail)
5838 );
5839 }
5840 if let Some(note) = foreign.unused_local_note() {
5841 self.notes.push(note);
5842 }
5843 self.foreign = Some(foreign);
5844 self
5845 }
5846
5847 fn foreign_unread(
5855 identity: ServiceIdentity,
5856 registration: Registration,
5857 foreign: ForeignService,
5858 ) -> Self {
5859 let mut problems = Vec::new();
5860 let look = foreign.where_to_look();
5861 if registration.starts_automatically && !registration.running {
5862 problems.push(StatusProblem {
5863 subject: "runtime",
5864 detail: format!(
5865 "{} holds an automatic registration for the service installed from {}, but \
5866 it is stopped. Run `runner-manager service status` {look} to see why; do not \
5867 reinstall it from this account.",
5868 registration.manager,
5869 foreign.installed_by(),
5870 ),
5871 });
5872 }
5873 if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
5874 problems.push(StatusProblem {
5875 subject: "start mode",
5876 detail: format!(
5877 "{} holds the boot registration installed from {} but will not start it by \
5878 itself. Run `runner-manager service status` {look}.",
5879 registration.manager,
5880 foreign.installed_by(),
5881 ),
5882 });
5883 }
5884 let mut notes = Vec::new();
5885 if let Some(reason) = &foreign.unreadable {
5886 notes.push(format!(
5887 "its health is not known here: {reason}. Run `runner-manager service status` \
5888 {look} to see it."
5889 ));
5890 }
5891 notes.extend(foreign.unused_local_note());
5892 Self {
5893 identity,
5894 record: None,
5895 log_file: foreign.directories.log_file(),
5896 registration: Some(registration),
5897 binary: None,
5898 store: None,
5899 last_github_contact: None,
5900 runner_root: None,
5901 credential_rejected_since: None,
5902 launches_blocked: None,
5903 unattended_gap: None,
5904 foreign: Some(foreign),
5905 problems,
5906 notes,
5907 }
5908 }
5909
5910 #[must_use]
5913 pub const fn foreign(&self) -> Option<&ForeignService> {
5914 self.foreign.as_ref()
5915 }
5916
5917 #[must_use]
5920 pub fn is_unread(&self) -> bool {
5921 self.foreign.as_ref().is_some_and(ForeignService::is_unread)
5922 }
5923
5924 #[must_use]
5926 pub fn verdict(&self) -> String {
5927 if !self.is_healthy() {
5928 return "NOT healthy".to_owned();
5929 }
5930 match &self.foreign {
5931 Some(foreign) if foreign.is_unread() => format!(
5932 "{}; installed from {}, so its health is read {}",
5933 if self.is_running() {
5934 "running"
5935 } else {
5936 "not running"
5937 },
5938 foreign.installed_by(),
5939 foreign.where_to_look()
5940 ),
5941 _ => "healthy".to_owned(),
5942 }
5943 }
5944
5945 #[must_use]
5947 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
5948 if let Some(drift) = drift {
5949 if drift.manager == DefinitionKind::LaunchdPlist {
5950 let restart = match drift.start_mode {
5954 StartMode::Boot => format!(
5955 "sudo launchctl kickstart -k system/{}",
5956 self.identity.launchd_label()
5957 ),
5958 StartMode::Login => format!(
5959 "launchctl kickstart -k gui/$(id -u)/{}",
5960 self.identity.launchd_label()
5961 ),
5962 };
5963 self.problems.push(StatusProblem {
5964 subject: "definition",
5965 detail: format!(
5966 "{} was written by runner-manager {} with a `ProcessType` other than \
5967 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
5968 daemon starts, so their jobs run below normal priority. The daemon \
5969 rewrites it and reloads itself when it starts with no runner, which it \
5970 does after every upgrade; to do it now, restart the service while no \
5971 job is running: `{restart}`. Configuration and credentials are kept.",
5972 drift.path.display(),
5973 drift.installed_by_version,
5974 ),
5975 });
5976 return self;
5977 }
5978 let repair = match drift.start_mode {
5979 StartMode::Boot => {
5980 "`sudo systemctl stop runner-manager && sudo runner-manager service install \
5981 --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
5982 --distribution <name>` from Windows)"
5983 }
5984 StartMode::Login => {
5985 "`systemctl --user stop runner-manager && runner-manager service install \
5986 --start-at login`"
5987 }
5988 };
5989 self.problems.push(StatusProblem {
5990 subject: "definition",
5991 detail: format!(
5992 "{} was written by runner-manager {} and differs from what {} renders, so \
5993 the daemon runs under outdated settings (for example a credential frozen \
5994 at service start). Re-register it with {repair}; configuration and \
5995 credentials are kept.",
5996 drift.path.display(),
5997 drift.installed_by_version,
5998 env!("CARGO_PKG_VERSION"),
5999 ),
6000 });
6001 }
6002 self
6003 }
6004
6005 #[must_use]
6007 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6008 if let Some(since) = since {
6009 self.credential_rejected_since = Some(since);
6010 self.problems.push(StatusProblem {
6011 subject: "github credential",
6012 detail: format!(
6013 "GitHub has rejected the service's credential since {}, so the service \
6014 cannot see queued jobs or start runners. Run `runner-manager auth login` \
6015 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6016 from Windows).",
6017 since.to_rfc3339()
6018 ),
6019 });
6020 }
6021 self
6022 }
6023
6024 #[must_use]
6026 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6027 self.credential_rejected_since
6028 }
6029
6030 #[must_use]
6033 pub fn with_launches_blocked(
6034 mut self,
6035 blocked: Option<crate::launch_health::LaunchesBlocked>,
6036 ) -> Self {
6037 if let Some(blocked) = &blocked {
6038 self.problems.push(StatusProblem {
6039 subject: LAUNCHES_BLOCKED_SUBJECT,
6040 detail: format!(
6041 "the service has started no runner {blocked}. Fix: {}.",
6042 blocked.remedy
6043 ),
6044 });
6045 }
6046 self.launches_blocked = blocked;
6047 self
6048 }
6049
6050 #[must_use]
6052 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6053 self.launches_blocked.as_ref()
6054 }
6055
6056 #[must_use]
6059 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6060 if let Some(since) = since {
6061 let mode = self.start_mode().unwrap_or_default();
6062 self.problems.push(StatusProblem {
6063 subject: CREDENTIAL_UNREADABLE_SUBJECT,
6064 detail: format!(
6065 "the service has been unable to read its stored GitHub credential since {}, \
6066 so it starts no runner; the diagnostic log has the store's own answer.{} \
6067 Sign in again: {}.",
6068 since.to_rfc3339(),
6069 if cfg!(target_os = "macos") {
6070 " That is what a keychain item written by a different build looks like."
6071 } else {
6072 ""
6073 },
6074 sign_in_instruction(mode)
6075 ),
6076 });
6077 }
6078 self
6079 }
6080
6081 #[must_use]
6089 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6090 self.runner_root
6091 .as_ref()
6092 .map(|(path, report)| (path.as_path(), report))
6093 }
6094
6095 #[must_use]
6097 pub const fn is_installed(&self) -> bool {
6098 self.registration.is_some() || self.record.is_some()
6099 }
6100
6101 #[must_use]
6103 pub fn is_running(&self) -> bool {
6104 self.registration
6105 .as_ref()
6106 .is_some_and(|registration| registration.running)
6107 }
6108
6109 #[must_use]
6111 pub fn is_healthy(&self) -> bool {
6112 self.problems.is_empty()
6113 }
6114
6115 #[must_use]
6117 pub fn problems(&self) -> &[StatusProblem] {
6118 &self.problems
6119 }
6120
6121 #[must_use]
6124 pub fn notes(&self) -> &[String] {
6125 &self.notes
6126 }
6127
6128 #[must_use]
6133 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6134 if let Some(unfit) = unfit {
6135 self.problems.push(StatusProblem {
6136 subject: HOST_UNFIT_SUBJECT,
6137 detail: format!(
6138 "the service has started no runner since {}, because a required host check \
6139 fails from its own session: {}",
6140 unfit.since.to_rfc3339(),
6141 unfit.describe()
6142 ),
6143 });
6144 }
6145 self
6146 }
6147
6148 #[must_use]
6152 pub fn with_stall(mut self, stall: Option<String>) -> Self {
6153 if let Some(detail) = stall {
6154 self.problems.push(StatusProblem {
6155 subject: DAEMON_STALLED_SUBJECT,
6156 detail,
6157 });
6158 }
6159 self
6160 }
6161
6162 #[must_use]
6165 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
6166 if let Some(gap) = &gap {
6167 self.notes.push(gap.reason.clone());
6168 }
6169 self.unattended_gap = gap;
6170 self
6171 }
6172
6173 #[must_use]
6176 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
6177 self.unattended_gap.as_ref()
6178 }
6179
6180 #[must_use]
6184 pub fn start_mode(&self) -> Option<StartMode> {
6185 self.record
6186 .as_ref()
6187 .map(|record| record.start_mode)
6188 .or_else(|| {
6189 self.registration
6190 .as_ref()
6191 .filter(|_| self.is_unread())
6192 .map(|registration| registration.start_mode)
6193 })
6194 }
6195
6196 #[must_use]
6200 pub fn service_binary(&self) -> Option<PathBuf> {
6201 self.record
6202 .as_ref()
6203 .map(|record| record.binary.clone())
6204 .or_else(|| {
6205 self.registration
6206 .as_ref()
6207 .filter(|_| self.is_unread())
6208 .and_then(Registration::binary)
6209 })
6210 }
6211
6212 #[must_use]
6215 pub const fn binary(&self) -> Option<&BinaryPath> {
6216 self.binary.as_ref()
6217 }
6218
6219 #[must_use]
6221 pub fn log_file(&self) -> &Path {
6222 &self.log_file
6223 }
6224
6225 #[must_use]
6227 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
6228 self.last_github_contact
6229 }
6230
6231 #[must_use]
6234 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
6235 self.store.as_ref()
6236 }
6237
6238 #[must_use]
6240 pub const fn record(&self) -> Option<&InstallRecord> {
6241 self.record.as_ref()
6242 }
6243
6244 #[must_use]
6246 pub const fn registration(&self) -> Option<&Registration> {
6247 self.registration.as_ref()
6248 }
6249}
6250
6251impl fmt::Display for ServiceStatus {
6252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6253 writeln!(f, "Service: {}", self.identity)?;
6254 match (&self.record, &self.registration) {
6255 (None, None) => {
6256 writeln!(
6257 f,
6258 " installed no. `service install` registers `{} {}`.",
6259 SERVICE_NAME,
6260 DAEMON_ARGUMENTS.join(" ")
6261 )?;
6262 }
6263 _ => {
6264 let manager = self
6265 .registration
6266 .as_ref()
6267 .map(|registration| registration.manager.manager());
6268 writeln!(
6269 f,
6270 " installed {}",
6271 manager.unwrap_or("yes, but no service manager knows it")
6272 )?;
6273 writeln!(
6274 f,
6275 " state {}",
6276 if self.is_running() {
6277 "running"
6278 } else {
6279 "not running"
6280 }
6281 )?;
6282 }
6283 }
6284 if let Some(foreign) = &self.foreign {
6285 writeln!(
6286 f,
6287 " installed from {} -- another installation's, not this account's ({})",
6288 foreign.installed_by(),
6289 foreign.local_root.display()
6290 )?;
6291 if foreign.is_unread()
6293 && let Some(registration) = &self.registration
6294 {
6295 writeln!(f, " start mode {}", registration.start_mode)?;
6296 if let Some(account) = ®istration.account {
6297 writeln!(f, " account {account}")?;
6298 }
6299 if let Some(binary) = self.service_binary() {
6300 writeln!(f, " binary {}", binary.display())?;
6301 }
6302 }
6303 }
6304 if let Some(record) = &self.record {
6305 writeln!(f, " start mode {}", record.start_mode)?;
6306 writeln!(f, " account {}", record.account)?;
6307 writeln!(f, " restart on failure {}", record.restart())?;
6308 writeln!(
6309 f,
6310 " arguments {}",
6311 record.arguments.join(" ")
6312 )?;
6313 }
6314 if let Some(binary) = &self.binary {
6315 writeln!(f, " binary {binary}")?;
6316 }
6317 writeln!(f, " diagnostic log {}", self.log_file.display())?;
6318 if let Some((path, report)) = &self.runner_root {
6319 writeln!(f, " default runner root {}", path.display())?;
6326 if *report != RootAccessReport::NotApplicable {
6327 writeln!(f, " default root access {report}")?;
6328 }
6329 }
6330 if let Some(store) = &self.store {
6331 writeln!(f, " secret store {store}")?;
6332 }
6333 if !self.is_unread() {
6336 writeln!(
6337 f,
6338 " last GitHub contact {}",
6339 match self.last_github_contact {
6340 Some(at) => at.to_rfc3339(),
6341 None => "never".to_string(),
6342 }
6343 )?;
6344 }
6345 for note in &self.notes {
6346 writeln!(f, " note {note}")?;
6347 }
6348 for problem in &self.problems {
6349 writeln!(f, " ERROR {problem}")?;
6350 }
6351 write!(f, " verdict {}", self.verdict())
6352 }
6353}
6354
6355#[derive(Debug, Clone, Default)]
6372pub struct RecordingControls {
6373 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6374}
6375
6376#[derive(Debug, Default)]
6377struct RecordingState {
6378 registrations: BTreeMap<(StartMode, String), Registration>,
6379 definitions: BTreeMap<String, ServiceDefinition>,
6380 calls: Vec<String>,
6381 #[cfg(test)]
6382 install_failures: BTreeMap<StartMode, String>,
6383 #[cfg(test)]
6384 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
6385}
6386
6387#[cfg(test)]
6388#[derive(Debug, Clone)]
6389enum TestInstallSideEffect {
6390 HideDirectory { directory: PathBuf, hidden: PathBuf },
6391}
6392
6393impl RecordingControls {
6394 #[must_use]
6396 pub fn new() -> Self {
6397 Self::default()
6398 }
6399
6400 #[must_use]
6403 pub fn calls(&self) -> Vec<String> {
6404 self.state.lock().expect("not poisoned").calls.clone()
6405 }
6406
6407 #[must_use]
6409 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
6410 self.state
6411 .lock()
6412 .expect("not poisoned")
6413 .registrations
6414 .iter()
6415 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
6416 .collect()
6417 }
6418
6419 #[must_use]
6421 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
6422 self.state
6423 .lock()
6424 .expect("not poisoned")
6425 .definitions
6426 .get(name)
6427 .cloned()
6428 }
6429
6430 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
6440 let mut state = self.state.lock().expect("not poisoned");
6441 if let Some((_, registration)) = state
6442 .registrations
6443 .iter_mut()
6444 .find(|((_, held), _)| held == name)
6445 {
6446 edit(registration);
6447 }
6448 }
6449
6450 #[cfg(test)]
6451 fn fail_next_install(&self, mode: StartMode, detail: &str) {
6452 self.state
6453 .lock()
6454 .expect("not poisoned")
6455 .install_failures
6456 .insert(mode, detail.to_string());
6457 }
6458
6459 #[cfg(test)]
6460 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
6461 self.state
6462 .lock()
6463 .expect("not poisoned")
6464 .after_install
6465 .insert(
6466 mode,
6467 TestInstallSideEffect::HideDirectory { directory, hidden },
6468 );
6469 }
6470}
6471
6472impl ControlFactory for RecordingControls {
6473 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6474 Ok(Box::new(RecordingControl {
6475 mode,
6476 state: std::sync::Arc::clone(&self.state),
6477 }))
6478 }
6479}
6480
6481#[derive(Debug)]
6482struct RecordingControl {
6483 mode: StartMode,
6484 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
6485}
6486
6487impl RecordingControl {
6488 fn note(&self, operation: &str, name: &str) {
6489 self.state
6490 .lock()
6491 .expect("not poisoned")
6492 .calls
6493 .push(format!("{operation} {name} ({})", self.mode));
6494 }
6495}
6496
6497impl ServiceControl for RecordingControl {
6498 fn manager(&self) -> DefinitionKind {
6499 host_definition_kind(self.mode)
6503 }
6504
6505 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6506 self.note("install", plan.identity().name());
6507 #[cfg(test)]
6508 if let Some(detail) = self
6509 .state
6510 .lock()
6511 .expect("not poisoned")
6512 .install_failures
6513 .remove(&self.mode)
6514 {
6515 return Err(ServiceError::Control {
6516 operation: "install",
6517 name: plan.identity().name().to_string(),
6518 manager: "recording control",
6519 detail,
6520 });
6521 }
6522 let definition = ServiceDefinition::for_host(plan)?;
6527 let mut state = self.state.lock().expect("not poisoned");
6528 state.registrations.insert(
6529 (self.mode, plan.identity().name().to_string()),
6530 Registration {
6531 manager: host_definition_kind(self.mode),
6532 start_mode: self.mode,
6533 command_line: plan.command_line(),
6534 account: Some(plan.account().as_str().to_string()),
6535 running: false,
6536 starts_automatically: true,
6537 restart_delay: Some(plan.restart().delay()),
6538 },
6539 );
6540 state
6541 .definitions
6542 .insert(plan.identity().name().to_string(), definition.clone());
6543 #[cfg(test)]
6544 let side_effect = state.after_install.remove(&self.mode);
6545 drop(state);
6546 #[cfg(test)]
6547 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
6548 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
6549 std::fs::write(&directory, b"blocks recreation")
6550 .expect("test fault can block record directory recreation");
6551 }
6552 Ok(definition)
6553 }
6554
6555 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6556 self.note("uninstall", identity.name());
6557 let mut state = self.state.lock().expect("not poisoned");
6558 state.definitions.remove(identity.name());
6559 Ok(state
6560 .registrations
6561 .remove(&(self.mode, identity.name().to_string()))
6562 .is_some())
6563 }
6564
6565 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6566 self.note("query", identity.name());
6567 Ok(self
6568 .state
6569 .lock()
6570 .expect("not poisoned")
6571 .registrations
6572 .get(&(self.mode, identity.name().to_string()))
6573 .cloned())
6574 }
6575
6576 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6577 self.note("start", identity.name());
6578 let mut state = self.state.lock().expect("not poisoned");
6579 match state
6580 .registrations
6581 .get_mut(&(self.mode, identity.name().to_string()))
6582 {
6583 Some(registration) => {
6584 registration.running = true;
6585 Ok(())
6586 }
6587 None => Err(ServiceError::NotInstalled {
6588 name: identity.name().to_string(),
6589 operation: "start",
6590 }),
6591 }
6592 }
6593
6594 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6595 self.note("stop", identity.name());
6596 let mut state = self.state.lock().expect("not poisoned");
6597 match state
6598 .registrations
6599 .get_mut(&(self.mode, identity.name().to_string()))
6600 {
6601 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
6602 None => Err(ServiceError::NotInstalled {
6603 name: identity.name().to_string(),
6604 operation: "stop",
6605 }),
6606 }
6607 }
6608}
6609
6610impl ControlFactory for HostControls {
6611 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6612 sys::control(mode)
6613 }
6614
6615 fn installed_definition(&self, path: &Path) -> Option<String> {
6616 std::fs::read_to_string(path).ok()
6617 }
6618
6619 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
6620 crate::unattended_login::probe()
6621 }
6622}
6623
6624#[must_use]
6631pub fn host_home() -> Option<PathBuf> {
6632 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
6633}
6634
6635#[cfg(unix)]
6637fn home_directory() -> Option<PathBuf> {
6638 host_home()
6639}
6640
6641fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
6650 let output = std::process::Command::new(program)
6651 .args(arguments)
6652 .output()?;
6653 Ok((
6654 output.status.success(),
6655 String::from_utf8_lossy(&output.stdout).into_owned(),
6656 String::from_utf8_lossy(&output.stderr).into_owned(),
6657 ))
6658}
6659
6660#[must_use]
6663pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
6664 if cfg!(windows) {
6665 match mode {
6666 StartMode::Boot => DefinitionKind::WindowsService,
6667 StartMode::Login => DefinitionKind::WindowsScheduledTask,
6668 }
6669 } else if cfg!(target_os = "macos") {
6670 DefinitionKind::LaunchdPlist
6671 } else {
6672 DefinitionKind::SystemdUnit
6673 }
6674}
6675
6676#[cfg(any(target_os = "macos", test))]
6683fn enable_launchd_registration(
6684 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
6685 domain: &str,
6686 service_target: &str,
6687 plist: &Path,
6688 name: &str,
6689 elevation_remedy: &'static str,
6690) -> Result<(), ServiceError> {
6691 let (enabled, cause) = launchctl(&[
6692 std::ffi::OsStr::new("enable"),
6693 std::ffi::OsStr::new(service_target),
6694 ]);
6695 if enabled {
6696 return Ok(());
6697 }
6698
6699 let (booted_out, bootout_detail) = launchctl(&[
6700 std::ffi::OsStr::new("bootout"),
6701 std::ffi::OsStr::new(service_target),
6702 ]);
6703 let removed = std::fs::remove_file(plist);
6704 if !booted_out || removed.is_err() {
6705 return Err(ServiceError::Rollback {
6706 operation: "enable launchd registration",
6707 name: name.to_string(),
6708 cause,
6709 rollback: format!(
6710 "launchctl bootout {domain}: {}; remove {}: {}",
6711 if booted_out {
6712 "succeeded".to_string()
6713 } else {
6714 bootout_detail
6715 },
6716 plist.display(),
6717 removed
6718 .err()
6719 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
6720 ),
6721 });
6722 }
6723
6724 if cause.to_ascii_lowercase().contains("permission denied") {
6725 Err(ServiceError::NeedsElevation {
6726 operation: "enable",
6727 name: name.to_string(),
6728 detail: cause,
6729 remedy: elevation_remedy,
6730 })
6731 } else {
6732 Err(ServiceError::Control {
6733 operation: "enable",
6734 name: name.to_string(),
6735 manager: "launchd",
6736 detail: cause,
6737 })
6738 }
6739}
6740
6741#[derive(Debug, Clone)]
6750pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
6751
6752impl ServiceShutdown {
6753 pub async fn wait(mut self) {
6755 if !*self.0.borrow() {
6756 let _ = self.0.changed().await;
6757 }
6758 }
6759}
6760
6761#[cfg(windows)]
6768pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
6769where
6770 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
6771{
6772 windows_host::run(Box::new(run))
6773}
6774
6775#[cfg(windows)]
6776mod windows_host {
6777 use std::ffi::OsString;
6778 use std::sync::{Arc, Mutex, OnceLock, mpsc};
6779 use std::time::Duration;
6780
6781 use windows_service::service::{
6782 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
6783 ServiceType,
6784 };
6785 use windows_service::service_control_handler::{
6786 self, ServiceControlHandlerResult, ServiceStatusHandle,
6787 };
6788
6789 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
6790
6791 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
6792
6793 struct Invocation {
6794 run: Runner,
6795 result: mpsc::SyncSender<Result<u8, String>>,
6796 }
6797
6798 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
6799
6800 windows_service::define_windows_service!(ffi_service_main, service_main);
6801
6802 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
6803 let (result_tx, result_rx) = mpsc::sync_channel(1);
6804 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
6805 let mut invocation = slot
6806 .lock()
6807 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
6808 if invocation.is_some() {
6809 return Err(host_error(
6810 "prepare",
6811 "the service-host slot was already used",
6812 ));
6813 }
6814 *invocation = Some(Invocation {
6815 run,
6816 result: result_tx,
6817 });
6818 drop(invocation);
6819
6820 windows_service::service_dispatcher::start("", ffi_service_main)
6821 .map_err(|error| host_error("connect", &error.to_string()))?;
6822 result_rx
6823 .recv()
6824 .map_err(|error| host_error("finish", &error.to_string()))?
6825 .map_err(|detail| host_error("run", &detail))
6826 }
6827
6828 fn service_main(_arguments: Vec<OsString>) {
6829 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
6830 return;
6831 };
6832 let result = run_service(invocation.run);
6833 let _ = invocation.result.send(result);
6834 }
6835
6836 fn run_service(run: Runner) -> Result<u8, String> {
6837 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
6838 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
6839 let handler_status = Arc::clone(&status);
6840 let handler = move |control| match control {
6841 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
6842 ServiceControl::Stop | ServiceControl::Shutdown => {
6843 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
6844 let _ = handle.set_service_status(service_status(
6845 ServiceState::StopPending,
6846 ServiceControlAccept::empty(),
6847 1,
6848 Duration::from_secs(300),
6849 0,
6850 ));
6851 }
6852 let _ = stop_tx.send(true);
6853 ServiceControlHandlerResult::NoError
6854 }
6855 _ => ServiceControlHandlerResult::NotImplemented,
6856 };
6857 let handle = service_control_handler::register("", handler)
6858 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
6859 *status
6860 .lock()
6861 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
6862 handle
6863 .set_service_status(service_status(
6864 ServiceState::Running,
6865 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
6866 0,
6867 Duration::default(),
6868 0,
6869 ))
6870 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
6871
6872 let exit = run(ServiceShutdown(stop_rx));
6873 handle
6874 .set_service_status(service_status(
6875 ServiceState::Stopped,
6876 ServiceControlAccept::empty(),
6877 0,
6878 Duration::default(),
6879 u32::from(exit),
6880 ))
6881 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
6882 Ok(exit)
6883 }
6884
6885 fn service_status(
6886 state: ServiceState,
6887 accepted: ServiceControlAccept,
6888 checkpoint: u32,
6889 wait_hint: Duration,
6890 exit: u32,
6891 ) -> ServiceStatus {
6892 ServiceStatus {
6893 service_type: ServiceType::OWN_PROCESS,
6894 current_state: state,
6895 controls_accepted: accepted,
6896 exit_code: if exit == 0 {
6897 ServiceExitCode::Win32(0)
6898 } else {
6899 ServiceExitCode::ServiceSpecific(exit)
6900 },
6901 checkpoint,
6902 wait_hint,
6903 process_id: None,
6904 }
6905 }
6906
6907 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
6908 ServiceError::Control {
6909 operation,
6910 name: SERVICE_NAME.to_string(),
6911 manager: "the Windows Service Control Manager",
6912 detail: detail.to_string(),
6913 }
6914 }
6915}
6916
6917#[cfg(windows)]
6918mod sys {
6919 use std::ffi::{OsStr, OsString};
6929 use std::time::{Duration, Instant};
6930
6931 use runner_manager_domain::model::StartMode;
6932 use windows_service::service::{
6933 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
6934 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
6935 ServiceState, ServiceType,
6936 };
6937 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
6938
6939 use super::{
6940 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
6941 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
6942 };
6943
6944 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
6947 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
6951 const ACCESS_DENIED: i32 = 5;
6953 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
6954 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
6955
6956 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
6957 choose \"Run as administrator\".";
6958
6959 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6960 Ok(match mode {
6961 StartMode::Boot => Box::new(ScmControl),
6962 StartMode::Login => Box::new(TaskControl),
6963 })
6964 }
6965
6966 #[derive(Debug)]
6969 struct ScmControl;
6970
6971 fn scm_error(
6975 operation: &'static str,
6976 name: &str,
6977 error: &windows_service::Error,
6978 ) -> ServiceError {
6979 let raw = match error {
6980 windows_service::Error::Winapi(io) => io.raw_os_error(),
6981 _ => None,
6982 };
6983 let detail = match error {
6984 windows_service::Error::Winapi(io) => io.to_string(),
6985 other => other.to_string(),
6986 };
6987 if raw == Some(ACCESS_DENIED) {
6988 return ServiceError::NeedsElevation {
6989 operation,
6990 name: name.to_string(),
6991 detail,
6992 remedy: ELEVATION_REMEDY,
6993 };
6994 }
6995 ServiceError::Control {
6996 operation,
6997 name: name.to_string(),
6998 manager: "the Windows Service Control Manager",
6999 detail,
7000 }
7001 }
7002
7003 fn open_manager(
7004 access: ServiceManagerAccess,
7005 operation: &'static str,
7006 name: &str,
7007 ) -> Result<ServiceManager, ServiceError> {
7008 ServiceManager::local_computer(None::<&OsStr>, access)
7009 .map_err(|error| scm_error(operation, name, &error))
7010 }
7011
7012 impl ServiceControl for ScmControl {
7013 fn manager(&self) -> DefinitionKind {
7014 DefinitionKind::WindowsService
7015 }
7016
7017 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7018 let spec = windows_service_spec(plan);
7019 let manager = open_manager(
7020 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7021 "install",
7022 &spec.name,
7023 )?;
7024 let info = ServiceInfo {
7025 name: OsString::from(&spec.name),
7026 display_name: OsString::from(&spec.display_name),
7027 service_type: ServiceType::OWN_PROCESS,
7029 start_type: if spec.automatic_start {
7030 ServiceStartType::AutoStart
7031 } else {
7032 ServiceStartType::OnDemand
7033 },
7034 error_control: ServiceErrorControl::Normal,
7035 executable_path: plan.binary().to_path_buf(),
7036 launch_arguments: plan.arguments().to_vec(),
7037 dependencies: Vec::new(),
7038 account_name: spec.account.as_ref().map(OsString::from),
7040 account_password: None,
7043 };
7044 let service = manager
7045 .create_service(
7046 &info,
7047 ServiceAccess::CHANGE_CONFIG
7048 | ServiceAccess::QUERY_CONFIG
7049 | ServiceAccess::QUERY_STATUS
7050 | ServiceAccess::START
7051 | ServiceAccess::STOP
7052 | ServiceAccess::DELETE,
7053 )
7054 .map_err(|error| scm_error("install", &spec.name, &error))?;
7055 service
7056 .set_description(&spec.description)
7057 .map_err(|error| scm_error("describe", &spec.name, &error))?;
7058 service
7059 .update_failure_actions(ServiceFailureActions {
7060 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7061 reboot_msg: None,
7062 command: None,
7063 actions: Some(vec![
7070 ServiceAction {
7071 action_type: ServiceActionType::Restart,
7072 delay: spec.restart.delay(),
7073 };
7074 3
7075 ]),
7076 })
7077 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7078 service
7081 .set_failure_actions_on_non_crash_failures(true)
7082 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7083 Ok(ServiceDefinition::windows_service(plan))
7084 }
7085
7086 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7087 let manager =
7088 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7089 let service = match manager.open_service(
7090 identity.name(),
7091 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7092 ) {
7093 Ok(service) => Some(service),
7094 Err(error) if is_missing(&error) => return Ok(false),
7095 Err(error) if is_marked_for_delete(&error) => None,
7100 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7101 };
7102 if let Some(service) = service {
7103 if let Ok(status) = service.query_status()
7107 && status.current_state != ServiceState::Stopped
7108 {
7109 let _ = service.stop();
7110 }
7111 match service.delete() {
7112 Ok(()) => {}
7113 Err(error) if is_marked_for_delete(&error) => {}
7117 Err(error) => {
7118 return Err(scm_error("uninstall", identity.name(), &error));
7119 }
7120 }
7121 drop(service);
7122 }
7123
7124 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
7131 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
7132 Ok(service) => {
7133 drop(service);
7134 Ok(false)
7135 }
7136 Err(error) if is_missing(&error) => Ok(true),
7137 Err(error) if is_marked_for_delete(&error) => Ok(false),
7138 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
7139 }
7140 })?;
7141 if !absent {
7142 return Err(ServiceError::Control {
7143 operation: "verify uninstall of",
7144 name: identity.name().to_string(),
7145 manager: "the Windows Service Control Manager",
7146 detail: format!(
7147 "the registration was still visible {} seconds after DeleteService; \
7148 retry `service uninstall` from an elevated prompt",
7149 DELETE_TIMEOUT.as_secs()
7150 ),
7151 });
7152 }
7153 Ok(true)
7154 }
7155
7156 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7157 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
7158 let service = match manager.open_service(
7159 identity.name(),
7160 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
7161 ) {
7162 Ok(service) => service,
7163 Err(error) if is_missing(&error) => return Ok(None),
7164 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
7165 };
7166 let config = service
7167 .query_config()
7168 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7169 let status = service
7170 .query_status()
7171 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
7172 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
7173 actions
7174 .actions
7175 .and_then(|actions| actions.into_iter().next())
7176 .filter(|action| action.action_type == ServiceActionType::Restart)
7177 .map(|action| action.delay)
7178 });
7179 Ok(Some(Registration {
7180 manager: DefinitionKind::WindowsService,
7181 start_mode: StartMode::Boot,
7182 command_line: config.executable_path.to_string_lossy().into_owned(),
7186 account: config
7187 .account_name
7188 .map(|account| account.to_string_lossy().into_owned()),
7189 running: status.current_state == ServiceState::Running,
7190 starts_automatically: config.start_type == ServiceStartType::AutoStart,
7191 restart_delay,
7192 }))
7193 }
7194
7195 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7196 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
7197 let service = manager
7198 .open_service(identity.name(), ServiceAccess::START)
7199 .map_err(|error| scm_error("start", identity.name(), &error))?;
7200 service
7201 .start::<&OsStr>(&[])
7202 .map_err(|error| scm_error("start", identity.name(), &error))
7203 }
7204
7205 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7206 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
7207 let service = manager
7208 .open_service(
7209 identity.name(),
7210 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
7211 )
7212 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7213 let status = service
7214 .query_status()
7215 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7216 if status.current_state == ServiceState::Stopped {
7217 return Ok(false);
7218 }
7219 service
7220 .stop()
7221 .map_err(|error| scm_error("stop", identity.name(), &error))?;
7222 Ok(true)
7223 }
7224 }
7225
7226 fn is_missing(error: &windows_service::Error) -> bool {
7227 matches!(error, windows_service::Error::Winapi(io)
7228 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
7229 }
7230
7231 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
7232 matches!(error, windows_service::Error::Winapi(io)
7233 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
7234 }
7235
7236 pub(super) fn wait_until_scm_absent(
7237 timeout: Duration,
7238 poll_interval: Duration,
7239 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
7240 ) -> Result<bool, ServiceError> {
7241 let deadline = Instant::now() + timeout;
7242 loop {
7243 if probe_absent()? {
7244 return Ok(true);
7245 }
7246 if Instant::now() >= deadline {
7247 return Ok(false);
7248 }
7249 std::thread::sleep(poll_interval);
7250 }
7251 }
7252
7253 #[derive(Debug)]
7256 struct TaskControl;
7257
7258 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
7259 if detail.to_ascii_lowercase().contains("access is denied") {
7260 return ServiceError::NeedsElevation {
7261 operation,
7262 name: name.to_string(),
7263 detail,
7264 remedy: ELEVATION_REMEDY,
7265 };
7266 }
7267 ServiceError::Control {
7268 operation,
7269 name: name.to_string(),
7270 manager: "Windows Task Scheduler",
7271 detail,
7272 }
7273 }
7274
7275 fn schtasks(
7276 operation: &'static str,
7277 name: &str,
7278 arguments: &[&OsStr],
7279 ) -> Result<(bool, String), ServiceError> {
7280 match run("schtasks.exe", arguments) {
7281 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
7282 Err(error) => Err(task_error(operation, name, error.to_string())),
7283 }
7284 }
7285
7286 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
7289 let mut bytes = vec![0xFF, 0xFE];
7290 for unit in text.encode_utf16() {
7291 bytes.extend_from_slice(&unit.to_le_bytes());
7292 }
7293 std::fs::write(path, bytes)
7294 }
7295
7296 impl ServiceControl for TaskControl {
7297 fn manager(&self) -> DefinitionKind {
7298 DefinitionKind::WindowsScheduledTask
7299 }
7300
7301 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7302 let name = plan.identity().name().to_string();
7303 let principal = TaskPrincipal::current()?;
7304 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
7305 let directory = tempfile::tempdir()
7306 .map_err(|error| task_error("install", &name, error.to_string()))?;
7307 let document = directory.path().join("task.xml");
7308 write_utf16(&document, definition.text())
7309 .map_err(|error| task_error("install", &name, error.to_string()))?;
7310 let (ok, message) = schtasks(
7311 "install",
7312 &name,
7313 &[
7314 OsStr::new("/Create"),
7315 OsStr::new("/TN"),
7316 OsStr::new(&name),
7317 OsStr::new("/XML"),
7318 document.as_os_str(),
7319 ],
7320 )?;
7321 if !ok {
7322 return Err(task_error("install", &name, message));
7323 }
7324 Ok(definition)
7325 }
7326
7327 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7328 if self.query(identity)?.is_none() {
7329 return Ok(false);
7330 }
7331 let name = identity.name().to_string();
7332 let (ok, message) = schtasks(
7333 "uninstall",
7334 &name,
7335 &[
7336 OsStr::new("/Delete"),
7337 OsStr::new("/TN"),
7338 OsStr::new(&name),
7339 OsStr::new("/F"),
7340 ],
7341 )?;
7342 if !ok {
7343 return Err(task_error("uninstall", &name, message));
7344 }
7345 Ok(true)
7346 }
7347
7348 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7349 let name = identity.name().to_string();
7350 let (ok, document) = schtasks(
7351 "inspect",
7352 &name,
7353 &[
7354 OsStr::new("/Query"),
7355 OsStr::new("/TN"),
7356 OsStr::new(&name),
7357 OsStr::new("/XML"),
7358 OsStr::new("ONE"),
7359 ],
7360 )?;
7361 if !ok {
7362 return Ok(None);
7368 }
7369 let command = xml_value(&document, "Command").unwrap_or_default();
7370 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
7371 let command_line = if arguments.is_empty() {
7372 super::quote_argument(&command)
7373 } else {
7374 format!("{} {arguments}", super::quote_argument(&command))
7375 };
7376 Ok(Some(Registration {
7377 manager: DefinitionKind::WindowsScheduledTask,
7378 start_mode: StartMode::Login,
7379 command_line,
7380 account: xml_value(&document, "UserId"),
7381 running: task_is_running(&name),
7382 starts_automatically: super::windows_login_task_starts_automatically(&document),
7383 restart_delay: xml_value(&document, "Interval")
7384 .as_deref()
7385 .and_then(parse_iso8601),
7386 }))
7387 }
7388
7389 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7390 let name = identity.name().to_string();
7391 let (ok, message) = schtasks(
7392 "start",
7393 &name,
7394 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
7395 )?;
7396 if ok {
7397 Ok(())
7398 } else {
7399 Err(task_error("start", &name, message))
7400 }
7401 }
7402
7403 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7404 let running = self
7405 .query(identity)?
7406 .is_some_and(|registration| registration.running);
7407 if !running {
7408 return Ok(false);
7409 }
7410 let name = identity.name().to_string();
7411 let (ok, message) = schtasks(
7412 "stop",
7413 &name,
7414 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
7415 )?;
7416 if ok {
7417 Ok(true)
7418 } else {
7419 Err(task_error("stop", &name, message))
7420 }
7421 }
7422 }
7423
7424 fn task_is_running(name: &str) -> bool {
7438 let Ok((true, stdout, _)) = run(
7439 "schtasks.exe",
7440 &[
7441 OsStr::new("/Query"),
7442 OsStr::new("/TN"),
7443 OsStr::new(name),
7444 OsStr::new("/FO"),
7445 OsStr::new("CSV"),
7446 OsStr::new("/NH"),
7447 ],
7448 ) else {
7449 return false;
7450 };
7451 stdout
7452 .lines()
7453 .filter_map(|line| line.rsplit(',').next())
7454 .any(|status| {
7455 status
7456 .trim()
7457 .trim_matches('"')
7458 .eq_ignore_ascii_case("running")
7459 })
7460 }
7461
7462 fn parse_iso8601(value: &str) -> Option<Duration> {
7466 let rest = value.strip_prefix("PT")?;
7467 if let Some(minutes) = rest.strip_suffix('M') {
7468 return minutes
7469 .parse::<u64>()
7470 .ok()
7471 .map(|minutes| Duration::from_secs(minutes * 60));
7472 }
7473 rest.strip_suffix('S')?
7474 .parse::<u64>()
7475 .ok()
7476 .map(Duration::from_secs)
7477 }
7478}
7479
7480#[cfg(unix)]
7492fn write_definition(
7493 operation: &'static str,
7494 name: &str,
7495 path: &Path,
7496 text: &str,
7497 remedy: &'static str,
7498) -> Result<(), ServiceError> {
7499 if let Some(parent) = path.parent()
7500 && let Err(error) = std::fs::create_dir_all(parent)
7501 && error.kind() != std::io::ErrorKind::AlreadyExists
7502 {
7503 return Err(definition_error(operation, name, error, remedy, parent));
7504 }
7505 std::fs::write(path, text)
7506 .map_err(|error| definition_error(operation, name, error, remedy, path))
7507}
7508
7509#[cfg(all(unix, any(target_os = "macos", test)))]
7516fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
7517 use std::io::Write as _;
7518
7519 let path = drift.path.as_path();
7520 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
7521 let failed = |error: std::io::Error, at: &Path| {
7522 definition_error("rewrite", name, error, SUDO_REMEDY, at)
7523 };
7524 let mut staged =
7525 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
7526 staged
7527 .write_all(drift.rendered.as_bytes())
7528 .and_then(|()| staged.as_file().sync_all())
7529 .map_err(|error| failed(error, staged.path()))?;
7530 std::fs::set_permissions(
7531 staged.path(),
7532 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
7533 )
7534 .map_err(|error| failed(error, path))?;
7535 staged
7536 .persist(path)
7537 .map(drop)
7538 .map_err(|error| failed(error.error, path))
7539}
7540
7541#[cfg(unix)]
7542fn definition_error(
7543 operation: &'static str,
7544 name: &str,
7545 error: std::io::Error,
7546 remedy: &'static str,
7547 path: &Path,
7548) -> ServiceError {
7549 let detail = format!("{}: {error}", path.display());
7550 if error.kind() == std::io::ErrorKind::PermissionDenied {
7551 ServiceError::NeedsElevation {
7552 operation,
7553 name: name.to_string(),
7554 detail,
7555 remedy,
7556 }
7557 } else {
7558 ServiceError::Control {
7559 operation,
7560 name: name.to_string(),
7561 manager: "the local service manager",
7562 detail,
7563 }
7564 }
7565}
7566
7567#[cfg(unix)]
7569const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
7570 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
7571 agent not running until you sign in.";
7572
7573#[cfg(target_os = "macos")]
7578mod sys {
7579 use std::ffi::OsStr;
7592 use std::path::PathBuf;
7593 use std::time::Duration;
7594
7595 use runner_manager_domain::model::StartMode;
7596
7597 use super::{
7598 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
7599 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
7600 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
7601 write_definition, xml_value,
7602 };
7603
7604 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7605 Ok(Box::new(LaunchdControl { mode }))
7606 }
7607
7608 #[derive(Debug)]
7609 struct LaunchdControl {
7610 mode: StartMode,
7611 }
7612
7613 impl LaunchdControl {
7614 fn domain(&self) -> String {
7616 match self.mode {
7617 StartMode::Boot => "system".to_string(),
7618 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
7621 }
7622 }
7623
7624 fn service_target(&self, identity: &ServiceIdentity) -> String {
7625 format!("{}/{}", self.domain(), identity.launchd_label())
7626 }
7627
7628 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
7631 let file = format!("{}.plist", identity.launchd_label());
7632 match self.mode {
7633 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
7634 StartMode::Login => {
7635 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
7636 }
7637 }
7638 }
7639
7640 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
7641 ServiceError::Control {
7642 operation,
7643 name: name.to_string(),
7644 manager: "launchd",
7645 detail,
7646 }
7647 }
7648
7649 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
7650 match run("launchctl", arguments) {
7651 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
7652 Err(error) => (false, error.to_string()),
7653 }
7654 }
7655 }
7656
7657 impl ServiceControl for LaunchdControl {
7658 fn manager(&self) -> DefinitionKind {
7659 DefinitionKind::LaunchdPlist
7660 }
7661
7662 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7663 let name = plan.identity().name().to_string();
7664 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
7665 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
7666 return Err(self.failed(
7667 "install",
7668 &name,
7669 "this account has no home directory, so there is nowhere to put a \
7670 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
7671 /Library/LaunchDaemons."
7672 .to_string(),
7673 ));
7674 };
7675 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
7676 let target = self.domain();
7677 let (ok, message) = self.launchctl(&[
7678 OsStr::new("bootstrap"),
7679 OsStr::new(&target),
7680 path.as_os_str(),
7681 ]);
7682 if !ok {
7683 let _ = std::fs::remove_file(&path);
7687 if message.to_ascii_lowercase().contains("permission denied") {
7688 return Err(ServiceError::NeedsElevation {
7689 operation: "install",
7690 name,
7691 detail: message,
7692 remedy: SUDO_REMEDY,
7693 });
7694 }
7695 return Err(self.failed("install", &name, message));
7696 }
7697 let service_target = self.service_target(plan.identity());
7700 enable_launchd_registration(
7701 |arguments| self.launchctl(arguments),
7702 &target,
7703 &service_target,
7704 &path,
7705 &name,
7706 SUDO_REMEDY,
7707 )?;
7708 Ok(definition)
7709 }
7710
7711 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7712 let Some(path) = self.plist_path(identity) else {
7713 return Ok(false);
7714 };
7715 if !path.exists() {
7716 return Ok(false);
7717 }
7718 let target = self.service_target(identity);
7719 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
7720 if !ok
7721 && !message.to_ascii_lowercase().contains("no such process")
7722 && !message.contains("113")
7723 {
7724 if message.to_ascii_lowercase().contains("permission denied") {
7725 return Err(ServiceError::NeedsElevation {
7726 operation: "uninstall",
7727 name: identity.name().to_string(),
7728 detail: message,
7729 remedy: SUDO_REMEDY,
7730 });
7731 }
7732 return Err(self.failed("uninstall", identity.name(), message));
7733 }
7734 std::fs::remove_file(&path).map_err(|error| {
7737 super::definition_error(
7738 "uninstall",
7739 identity.name(),
7740 error,
7741 SUDO_REMEDY,
7742 path.as_path(),
7743 )
7744 })?;
7745 Ok(true)
7746 }
7747
7748 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7749 let Some(path) = self.plist_path(identity) else {
7750 return Ok(None);
7751 };
7752 let Ok(document) = std::fs::read_to_string(&path) else {
7753 return Ok(None);
7754 };
7755 let target = self.service_target(identity);
7756 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
7757 Ok(Some(Registration {
7758 manager: DefinitionKind::LaunchdPlist,
7759 start_mode: self.mode,
7760 command_line: program_arguments(&document),
7761 account: plist_string_value(&document, "UserName")
7762 .or_else(|| Some("the invoking user".to_string())),
7763 running: loaded && printed.contains("state = running"),
7764 starts_automatically: document.contains("<key>RunAtLoad</key>")
7765 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
7766 restart_delay: xml_value(
7767 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
7768 "integer",
7769 )
7770 .and_then(|value| value.parse::<u64>().ok())
7771 .map(Duration::from_secs),
7772 }))
7773 }
7774
7775 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7776 let target = self.service_target(identity);
7777 let (ok, message) = self.launchctl(&[
7778 OsStr::new("kickstart"),
7779 OsStr::new("-k"),
7780 OsStr::new(&target),
7781 ]);
7782 if ok {
7783 Ok(())
7784 } else {
7785 Err(self.failed("start", identity.name(), message))
7786 }
7787 }
7788
7789 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7790 let running = self
7791 .query(identity)?
7792 .is_some_and(|registration| registration.running);
7793 if !running {
7794 return Ok(false);
7795 }
7796 let target = self.service_target(identity);
7797 let (ok, message) = self.launchctl(&[
7798 OsStr::new("kill"),
7799 OsStr::new("SIGTERM"),
7800 OsStr::new(&target),
7801 ]);
7802 if ok {
7803 Ok(true)
7804 } else {
7805 Err(self.failed("stop", identity.name(), message))
7806 }
7807 }
7808 }
7809
7810 pub(super) fn reload_detached(
7825 identity: &ServiceIdentity,
7826 mode: StartMode,
7827 plist: &std::path::Path,
7828 ) -> Result<(), ServiceError> {
7829 use std::os::unix::process::CommandExt;
7830
7831 const SCRIPT: &str = r#"(
7832 sleep 1
7833 /bin/launchctl bootout "$1"
7834 tries=0
7835 until /bin/launchctl bootstrap "$2" "$3"; do
7836 tries=$((tries + 1))
7837 if [ "$tries" -ge 60 ]; then
7838 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
7839 exit 1
7840 fi
7841 sleep 1
7842 done
7843) </dev/null &"#;
7844
7845 let control = LaunchdControl { mode };
7846 let reinstall = match mode {
7849 StartMode::Boot => "sudo runner-manager service install --start-at boot",
7850 StartMode::Login => "runner-manager service install --start-at login",
7851 };
7852 let mut command = std::process::Command::new("/bin/sh");
7853 command
7854 .args(["-c", SCRIPT, "runner-manager-reload"])
7855 .arg(control.service_target(identity))
7856 .arg(control.domain())
7857 .arg(plist)
7858 .arg(reinstall)
7859 .stdin(std::process::Stdio::null());
7860 unsafe {
7863 command.pre_exec(|| {
7864 if libc::setsid() == -1 {
7865 return Err(std::io::Error::last_os_error());
7866 }
7867 Ok(())
7868 });
7869 }
7870 let status = command.status().map_err(|error| {
7871 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
7872 })?;
7873 if status.success() {
7874 Ok(())
7875 } else {
7876 Err(control.failed(
7877 "reload",
7878 identity.name(),
7879 format!("the reload helper exited with {status}"),
7880 ))
7881 }
7882 }
7883
7884 fn program_arguments(document: &str) -> String {
7886 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
7887 return String::new();
7888 };
7889 let Some(end) = rest.find("</array>") else {
7890 return String::new();
7891 };
7892 let mut out = Vec::new();
7893 let mut cursor = &rest[..end];
7894 while let Some(open) = cursor.find("<string>") {
7895 let after = &cursor[open + "<string>".len()..];
7896 let Some(close) = after.find("</string>") else {
7897 break;
7898 };
7899 out.push(quote_argument(&super::xml_unescape(&after[..close])));
7900 cursor = &after[close..];
7901 }
7902 out.join(" ")
7903 }
7904}
7905
7906#[cfg(all(unix, not(target_os = "macos")))]
7911mod sys {
7912 use std::ffi::OsStr;
7922 use std::path::PathBuf;
7923 use std::time::Duration;
7924
7925 use runner_manager_domain::model::StartMode;
7926
7927 use super::{
7928 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
7929 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
7930 home_directory, ini_directives, run, write_definition,
7931 };
7932
7933 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7934 Ok(Box::new(SystemdControl { mode }))
7935 }
7936
7937 #[derive(Debug)]
7938 struct SystemdControl {
7939 mode: StartMode,
7940 }
7941
7942 impl SystemdControl {
7943 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
7944 let file = identity.systemd_unit();
7945 match self.mode {
7946 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
7947 StartMode::Login => {
7948 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
7949 }
7950 }
7951 }
7952
7953 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
7955 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
7956 if self.mode == StartMode::Login {
7957 all.push(OsStr::new("--user"));
7958 }
7959 all.extend(arguments.iter().map(OsStr::new));
7960 match run("systemctl", &all) {
7961 Ok((ok, stdout, stderr)) => (
7962 ok,
7963 if stdout.trim().is_empty() {
7964 stderr
7965 } else {
7966 stdout
7967 },
7968 ),
7969 Err(error) => (false, error.to_string()),
7970 }
7971 }
7972
7973 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
7974 ServiceError::Control {
7975 operation,
7976 name: name.to_string(),
7977 manager: "systemd",
7978 detail,
7979 }
7980 }
7981 }
7982
7983 impl ServiceControl for SystemdControl {
7984 fn manager(&self) -> DefinitionKind {
7985 DefinitionKind::SystemdUnit
7986 }
7987
7988 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7989 let name = plan.identity().name().to_string();
7990 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
7991 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
7992 return Err(self.failed(
7993 "install",
7994 &name,
7995 "this account has no home directory, so there is nowhere to put a systemd \
7996 user unit. Use --start-at boot, which installs a system unit under \
7997 /etc/systemd/system."
7998 .to_string(),
7999 ));
8000 };
8001 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8002 let unit = plan.identity().systemd_unit();
8003 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8004 if !reloaded {
8005 let _ = std::fs::remove_file(&path);
8006 return Err(self.failed("install", &name, message));
8007 }
8008 let (enabled, message) = self.systemctl(&["enable", &unit]);
8009 if !enabled {
8010 let _ = std::fs::remove_file(&path);
8011 let _ = self.systemctl(&["daemon-reload"]);
8012 return Err(self.failed("install", &name, message));
8013 }
8014 Ok(definition)
8015 }
8016
8017 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8018 let Some(path) = self.unit_path(identity) else {
8019 return Ok(false);
8020 };
8021 if !path.exists() {
8022 return Ok(false);
8023 }
8024 let unit = identity.systemd_unit();
8025 let _ = self.systemctl(&["disable", "--now", &unit]);
8030 std::fs::remove_file(&path).map_err(|error| {
8031 super::definition_error(
8032 "uninstall",
8033 identity.name(),
8034 error,
8035 SUDO_REMEDY,
8036 path.as_path(),
8037 )
8038 })?;
8039 let _ = self.systemctl(&["daemon-reload"]);
8040 Ok(true)
8041 }
8042
8043 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8044 let Some(path) = self.unit_path(identity) else {
8045 return Ok(None);
8046 };
8047 let Ok(document) = std::fs::read_to_string(&path) else {
8048 return Ok(None);
8049 };
8050 let unit = identity.systemd_unit();
8051 let directives = ini_directives(&document, "Service");
8052 let (_, active) = self.systemctl(&["is-active", &unit]);
8053 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
8054 Ok(Some(Registration {
8055 manager: DefinitionKind::SystemdUnit,
8056 start_mode: self.mode,
8057 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
8058 account: directives.get("User").cloned().or_else(|| {
8059 Some(match self.mode {
8060 StartMode::Boot => "root".to_string(),
8061 StartMode::Login => "the invoking user".to_string(),
8062 })
8063 }),
8064 running: active.trim() == "active",
8065 starts_automatically: enabled.trim() == "enabled",
8066 restart_delay: directives
8067 .get("RestartSec")
8068 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
8069 .map(Duration::from_secs),
8070 }))
8071 }
8072
8073 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8074 let unit = identity.systemd_unit();
8075 let (ok, message) = self.systemctl(&["start", &unit]);
8076 if ok {
8077 Ok(())
8078 } else {
8079 Err(self.failed("start", identity.name(), message))
8080 }
8081 }
8082
8083 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8084 let running = self
8085 .query(identity)?
8086 .is_some_and(|registration| registration.running);
8087 if !running {
8088 return Ok(false);
8089 }
8090 let unit = identity.systemd_unit();
8091 let (ok, message) = self.systemctl(&["stop", &unit]);
8092 if ok {
8093 Ok(true)
8094 } else {
8095 Err(self.failed("stop", identity.name(), message))
8096 }
8097 }
8098 }
8099}
8100
8101#[cfg(all(test, target_os = "macos"))]
8103pub(crate) mod launchd_fixture {
8104 use std::path::{Path, PathBuf};
8105
8106 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
8110
8111 pub(crate) fn enabled() -> bool {
8114 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
8115 if !enabled {
8116 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
8117 }
8118 enabled
8119 }
8120
8121 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
8129 let binary = directory.join("launch-agent-job");
8130 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
8131 .expect("test binary copied");
8132 binary
8133 }
8134
8135 pub(crate) struct LoadedAgent(String);
8137
8138 impl LoadedAgent {
8139 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
8142 let domain = format!("gui/{}", unsafe { libc::getuid() });
8145 let loaded = Self(format!("{domain}/{label}"));
8147 let bootstrap = std::process::Command::new("launchctl")
8148 .arg("bootstrap")
8149 .arg(&domain)
8150 .arg(plist)
8151 .output()
8152 .expect("launchctl runs");
8153 assert!(
8154 bootstrap.status.success(),
8155 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
8156 String::from_utf8_lossy(&bootstrap.stderr)
8157 );
8158 loaded
8159 }
8160
8161 pub(crate) fn target(&self) -> &str {
8163 &self.0
8164 }
8165 }
8166
8167 impl Drop for LoadedAgent {
8168 fn drop(&mut self) {
8169 let _ = std::process::Command::new("launchctl")
8170 .args(["bootout", &self.0])
8171 .output();
8172 }
8173 }
8174}
8175
8176#[cfg(test)]
8177mod tests {
8178 use super::*;
8179
8180 use std::collections::BTreeMap;
8181
8182 #[test]
8183 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
8184 assert!(windows_login_task_starts_automatically(
8185 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
8186 ));
8187 assert!(windows_login_task_starts_automatically(
8188 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
8189 ));
8190 assert!(!windows_login_task_starts_automatically(
8191 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
8192 ));
8193 assert!(!windows_login_task_starts_automatically(
8194 "<Task><BootTrigger></BootTrigger></Task>"
8195 ));
8196 }
8197
8198 fn linux_plan(mode: StartMode) -> InstallPlan {
8205 InstallPlan::unchecked(
8206 ServiceIdentity::product(),
8207 mode,
8208 "/opt/runner-manager/bin/runner-manager",
8209 ServiceDirectories {
8210 config: PathBuf::from("/var/lib/runner-manager/config"),
8211 state: PathBuf::from("/var/lib/runner-manager/state"),
8212 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
8213 logs: PathBuf::from("/var/log/runner-manager"),
8214 },
8215 )
8216 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
8217 }
8218
8219 fn windows_plan(mode: StartMode) -> InstallPlan {
8220 InstallPlan::unchecked(
8221 ServiceIdentity::product(),
8222 mode,
8223 "C:\\Program Files\\runner-manager\\runner-manager.exe",
8224 ServiceDirectories {
8225 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
8226 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
8227 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
8228 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
8229 },
8230 )
8231 }
8232
8233 fn edited(text: &str, from: &str, to: &str) -> String {
8241 assert!(
8242 text.contains(from),
8243 "the rendered definition does not contain `{from}`, so this test would assert \
8244 nothing about a widened one"
8245 );
8246 text.replace(from, to)
8247 }
8248
8249 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
8251 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
8252 let Ok(entries) = std::fs::read_dir(directory) else {
8253 return;
8254 };
8255 for entry in entries.flatten() {
8256 let path = entry.path();
8257 if path.is_dir() {
8258 walk(&path, out);
8259 } else if let Ok(bytes) = std::fs::read(&path) {
8260 out.insert(path, bytes);
8261 }
8262 }
8263 }
8264 let mut out = BTreeMap::new();
8265 for root in roots {
8266 walk(root, &mut out);
8267 }
8268 out
8269 }
8270
8271 struct Host {
8272 _root: tempfile::TempDir,
8273 paths: AppPaths,
8274 binary: PathBuf,
8275 runner_root: LocalAbsolutePath,
8284 controls: RecordingControls,
8285 }
8286
8287 impl Host {
8288 fn new() -> Self {
8289 let root = tempfile::tempdir().expect("a temporary directory");
8290 let paths = AppPaths::rooted_at(root.path());
8291 paths.create_all().expect("the four directories");
8292 let binary = root.path().join(if cfg!(windows) {
8293 "runner-manager.exe"
8294 } else {
8295 "runner-manager"
8296 });
8297 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
8298 let runner_root = LocalAbsolutePath::new(
8299 root.path()
8300 .join("runner-root")
8301 .to_str()
8302 .expect("a unicode temporary path"),
8303 )
8304 .expect("a local absolute path");
8305 Self {
8306 _root: root,
8307 paths,
8308 binary,
8309 runner_root,
8310 controls: RecordingControls::new(),
8311 }
8312 }
8313
8314 fn operations(&self) -> ServiceOperations {
8315 ServiceOperations::with_controls(
8316 self.paths.clone(),
8317 ServiceIdentity::product(),
8318 std::sync::Arc::new(self.controls.clone()),
8319 )
8320 .with_runner_root(self.runner_root.clone())
8321 }
8322
8323 fn request(&self, mode: StartMode) -> InstallRequest {
8324 InstallRequest::new(mode).for_binary(&self.binary)
8325 }
8326 }
8327
8328 #[cfg(windows)]
8329 #[test]
8330 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
8331 let probes = std::cell::Cell::new(0);
8332 let absent =
8333 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
8334 let next = probes.get() + 1;
8335 probes.set(next);
8336 Ok(next == 3)
8337 })
8338 .expect("the simulated SCM probe succeeds");
8339
8340 assert!(absent);
8341 assert_eq!(
8342 probes.get(),
8343 3,
8344 "uninstall must recheck after transient presence instead of treating it as a leak"
8345 );
8346 }
8347
8348 #[cfg(windows)]
8349 #[test]
8350 fn windows_recognises_an_already_pending_service_deletion() {
8351 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
8352
8353 assert!(super::sys::is_marked_for_delete(&error));
8354 }
8355
8356 #[test]
8357 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
8358 let root = tempfile::tempdir().expect("a temporary directory");
8359 let plist = root.path().join("fixture.plist");
8360 std::fs::write(&plist, b"fixture").expect("a plist fixture");
8361 let calls = std::cell::RefCell::new(Vec::new());
8362
8363 let error = enable_launchd_registration(
8364 |arguments| {
8365 let call = arguments
8366 .iter()
8367 .map(|argument| argument.to_string_lossy().into_owned())
8368 .collect::<Vec<_>>();
8369 let operation = call[0].clone();
8370 calls.borrow_mut().push(call);
8371 if operation == "enable" {
8372 (false, "label remains disabled".to_string())
8373 } else {
8374 (true, String::new())
8375 }
8376 },
8377 "system",
8378 "system/com.openai.runner-manager-selftest",
8379 &plist,
8380 "runner-manager-selftest",
8381 "rerun with administrative rights",
8382 )
8383 .expect_err("enable failure must fail the install");
8384
8385 assert!(
8386 matches!(
8387 error,
8388 ServiceError::Control {
8389 operation: "enable",
8390 ..
8391 }
8392 ),
8393 "{error}"
8394 );
8395 assert_eq!(calls.borrow().len(), 2);
8396 assert_eq!(calls.borrow()[0][0], "enable");
8397 assert_eq!(calls.borrow()[1][0], "bootout");
8398 assert!(!plist.exists(), "rollback must remove the plist");
8399 }
8400
8401 #[test]
8406 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
8407 let plan = windows_plan(StartMode::Boot);
8408 let command_line = plan.command_line();
8409 assert!(
8410 command_line.starts_with('"'),
8411 "a path with a space must be quoted, got {command_line}"
8412 );
8413 assert_eq!(
8414 executable_from_command_line(&command_line).as_deref(),
8415 Some(plan.binary())
8416 );
8417 }
8418
8419 #[test]
8420 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
8421 let plan = linux_plan(StartMode::Boot);
8422 let command_line = plan.command_line();
8423 assert!(!command_line.starts_with('"'), "got {command_line}");
8424 assert_eq!(
8425 executable_from_command_line(&command_line).as_deref(),
8426 Some(plan.binary())
8427 );
8428 }
8429
8430 #[test]
8431 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
8432 let awkward = r#"C:\odd "name"\rm.exe"#;
8435 let quoted = quote_argument(awkward);
8436 assert_eq!(
8437 executable_from_command_line(&format!("{quoted} daemon run"))
8438 .as_deref()
8439 .map(Path::to_string_lossy)
8440 .as_deref(),
8441 Some(awkward)
8442 );
8443 }
8444
8445 #[test]
8446 fn an_empty_command_line_has_no_executable() {
8447 assert_eq!(executable_from_command_line(" "), None);
8448 assert_eq!(executable_from_command_line(""), None);
8449 }
8450
8451 #[test]
8452 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
8453 let awkward = r#"DOMAIN\R&D <team> "ops""#;
8454 assert_eq!(
8455 xml_escape(awkward),
8456 "DOMAIN\\R&D <team> "ops""
8457 );
8458 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
8459 }
8460
8461 #[test]
8466 fn a_restart_delay_under_the_floor_is_refused() {
8467 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
8468 .expect_err("half a second is under the one-second floor");
8469 assert!(
8470 matches!(error, ServiceError::RestartDelay { .. }),
8471 "{error}"
8472 );
8473 }
8474
8475 #[test]
8476 fn a_restart_delay_over_the_ceiling_is_refused() {
8477 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
8478 .expect_err("an hour is over the five-minute ceiling");
8479 assert!(
8480 matches!(error, ServiceError::RestartDelay { .. }),
8481 "{error}"
8482 );
8483 }
8484
8485 #[test]
8486 fn a_reset_window_no_longer_than_the_delay_is_refused() {
8487 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
8488 .expect_err("a window equal to the delay can never elapse between restarts");
8489 assert!(
8490 matches!(error, ServiceError::RestartResetWindow { .. }),
8491 "{error}"
8492 );
8493 }
8494
8495 #[test]
8496 fn a_delay_inside_the_bound_is_accepted() {
8497 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
8498 .expect("twenty seconds is inside the bound");
8499 assert_eq!(policy.delay(), Duration::from_secs(20));
8500 assert_eq!(policy.reset_after(), Duration::from_secs(300));
8501 }
8502
8503 #[test]
8508 fn a_fixture_identity_can_never_be_the_product_identity() {
8509 let fixture = ServiceIdentity::fixture("abc123");
8510 assert!(fixture.is_fixture());
8511 assert!(!ServiceIdentity::product().is_fixture());
8512 assert_ne!(fixture.name(), ServiceIdentity::product().name());
8513 assert_ne!(
8514 fixture.launchd_label(),
8515 ServiceIdentity::product().launchd_label()
8516 );
8517 assert_ne!(
8518 fixture.systemd_unit(),
8519 ServiceIdentity::product().systemd_unit()
8520 );
8521 }
8522
8523 #[test]
8524 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
8525 let fixture = ServiceIdentity::fixture("A b/c:\\d");
8526 assert!(
8527 fixture
8528 .name()
8529 .chars()
8530 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
8531 "got {}",
8532 fixture.name()
8533 );
8534 }
8535
8536 #[test]
8541 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
8542 let unit = systemd_unit(&linux_plan(StartMode::Boot));
8543 assert!(unit.contains("KillMode=process\n"), "{unit}");
8544 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
8545 assert!(unit.contains("RestartSec=15\n"), "{unit}");
8546 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
8547 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
8548 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
8549 }
8550
8551 #[test]
8552 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
8553 let unit = systemd_unit(&linux_plan(StartMode::Boot));
8554 assert!(
8555 !unit.contains("LoadCredential="),
8556 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
8557 );
8558 }
8559
8560 #[test]
8561 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
8562 let unit = systemd_unit(&linux_plan(StartMode::Login));
8563 assert!(
8564 !unit.contains("LoadCredential="),
8565 "a user unit must not name a root-owned credential file, got:\n{unit}"
8566 );
8567 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
8568 }
8569
8570 #[test]
8571 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
8572 let plan = linux_plan(StartMode::Boot);
8573 let unit = systemd_unit(&plan);
8574 let directives = ini_directives(&unit, "Service");
8575 let listed = split_quoted(
8576 directives
8577 .get("ReadWritePaths")
8578 .expect("the unit names its writable paths"),
8579 );
8580 assert_eq!(listed.len(), 5, "{listed:?}");
8581 for path in plan.directories().all() {
8582 assert!(
8583 listed.iter().any(|entry| entry == &path.to_string_lossy()),
8584 "{} is missing from {listed:?}",
8585 path.display()
8586 );
8587 }
8588 assert!(
8589 listed
8590 .iter()
8591 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
8592 "atomic credential replacement needs its parent directory in {listed:?}"
8593 );
8594 }
8595
8596 #[test]
8597 fn the_unit_records_the_absolute_binary_path() {
8598 let plan = linux_plan(StartMode::Boot);
8599 let unit = systemd_unit(&plan);
8600 assert!(
8601 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
8602 "{unit}"
8603 );
8604 }
8605
8606 #[test]
8611 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
8612 let plist = launchd_plist(&linux_plan(StartMode::Boot));
8613 assert!(
8616 plist.contains(
8617 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
8618 ),
8619 "{plist}"
8620 );
8621 assert!(
8622 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
8623 "{plist}"
8624 );
8625 }
8626
8627 #[test]
8628 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
8629 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
8630 assert_eq!(
8631 plist_string_value(&daemon, "UserName").as_deref(),
8632 Some("root")
8633 );
8634 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
8635
8636 let agent = launchd_plist(&linux_plan(StartMode::Login));
8637 assert_eq!(
8638 plist_string_value(&agent, "UserName"),
8639 None,
8640 "a LaunchAgent already runs as the operator:\n{agent}"
8641 );
8642 }
8643
8644 #[test]
8645 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
8646 for mode in [StartMode::Boot, StartMode::Login] {
8647 let plan = linux_plan(mode);
8648 let plist = launchd_plist(&plan);
8649 assert_eq!(
8650 plist_string_value(&plist, "ProcessType").as_deref(),
8651 Some("Interactive"),
8652 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
8653 );
8654 let review = review_least_privilege(
8655 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
8656 &plan,
8657 );
8658 assert!(
8659 review
8660 .controls()
8661 .iter()
8662 .any(|control| control == "ProcessType=Interactive"),
8663 "{review}"
8664 );
8665 assert!(
8666 !review
8667 .findings()
8668 .iter()
8669 .any(|finding| finding.subject == "ProcessType"),
8670 "{review}"
8671 );
8672 }
8673 }
8674
8675 #[test]
8679 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
8680 let plan = linux_plan(StartMode::Login);
8681 let rendered = launchd_plist(&plan);
8682 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
8683 for (label, throttled) in [
8684 (
8685 "Background",
8686 edited(
8687 &rendered,
8688 interactive,
8689 "<key>ProcessType</key>\n <string>Background</string>\n",
8690 ),
8691 ),
8692 ("absent", edited(&rendered, interactive, "")),
8693 ] {
8694 let review = review_least_privilege(
8695 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
8696 &plan,
8697 );
8698 assert!(review.is_least_privilege(), "{label}: {review}");
8699 assert!(
8700 review.findings().iter().any(|finding| {
8701 finding.kind == FindingKind::Shortfall
8702 && finding.subject == "ProcessType"
8703 && finding.detail.contains(label)
8704 }),
8705 "{label}: {review}"
8706 );
8707 }
8708 }
8709
8710 #[test]
8711 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
8712 let host = Host::new();
8713 let identity = ServiceIdentity::product();
8714 let plan = InstallPlan::resolve(
8715 identity.clone(),
8716 &host.request(StartMode::Login),
8717 ServiceDirectories::of(&host.paths),
8718 )
8719 .expect("a resolvable plan");
8720 let installed = launchd_plist(&plan);
8721 let definition =
8722 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
8723 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8724 record.installed_by_version = "0.4.31".into();
8725 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
8726
8727 assert_eq!(
8728 launchd_definition_drift(&identity, &record, path.clone(), &installed),
8729 None
8730 );
8731 let edited_by_operator = edited(
8732 &installed,
8733 "</dict>\n</plist>\n",
8734 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
8735 );
8736 assert_eq!(
8737 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
8738 None,
8739 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
8740 );
8741
8742 let written_by_0_4_31 = edited(
8743 &installed,
8744 "<string>Interactive</string>",
8745 "<string>Background</string>",
8746 );
8747 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
8748 .expect("a Background plist is drift");
8749 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
8750 assert_eq!(drift.start_mode, StartMode::Login);
8751 assert_eq!(
8752 drift.rendered, installed,
8753 "the repair must write exactly what `service install` writes"
8754 );
8755
8756 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8757 .with_definition_drift(Some(drift));
8758 let problem = status
8759 .problems()
8760 .iter()
8761 .find(|problem| problem.subject == "definition")
8762 .expect("the drift is reported");
8763 assert!(
8766 problem.detail.contains("0.4.31")
8767 && problem.detail.contains(
8768 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
8769 )
8770 && !problem.detail.contains("service install")
8771 && !problem.detail.contains("systemctl")
8772 && !problem.detail.contains("sudo"),
8773 "{}",
8774 problem.detail
8775 );
8776 }
8777
8778 #[cfg(unix)]
8779 #[test]
8780 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
8781 use std::os::unix::fs::PermissionsExt as _;
8782
8783 let root = tempfile::tempdir().expect("a temporary directory");
8784 let path = root
8785 .path()
8786 .join("io.github.IvanMurzak.runner-manager.plist");
8787 std::fs::write(&path, "<string>Background</string>").expect("old plist");
8788 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
8789 let drift = DefinitionDrift {
8790 path: path.clone(),
8791 installed_by_version: "0.4.31".into(),
8792 rendered: launchd_plist(&linux_plan(StartMode::Login)),
8793 start_mode: StartMode::Login,
8794 manager: DefinitionKind::LaunchdPlist,
8795 };
8796
8797 replace_definition("runner-manager", &drift).expect("replaced");
8798
8799 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
8800 assert_eq!(
8801 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
8802 0o644
8803 );
8804 assert_eq!(
8805 std::fs::read_dir(root.path()).unwrap().count(),
8806 1,
8807 "no staging file is left beside the plist"
8808 );
8809 }
8810
8811 #[cfg(target_os = "macos")]
8820 #[test]
8821 #[ignore = "run only by launchd, as the job of the detached-reload test"]
8822 fn detached_reload_job() {
8823 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
8824 let tag = label
8825 .split_once("-selftest-")
8826 .map(|(_, tag)| tag.to_string())
8827 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
8828 let identity = ServiceIdentity::fixture(&tag);
8829 assert_eq!(identity.launchd_label(), label);
8830 let path = std::env::current_dir()
8831 .expect("working directory")
8832 .join(format!("{label}.plist"));
8833 let installed = std::fs::read_to_string(&path).expect("own plist");
8834 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
8835 let drift = DefinitionDrift {
8836 path: path.clone(),
8837 installed_by_version: "0.4.31".into(),
8838 rendered: installed.replace(
8839 "<string>Background</string>",
8840 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
8841 ),
8842 start_mode: StartMode::Login,
8843 manager: DefinitionKind::LaunchdPlist,
8844 };
8845 replace_definition(identity.name(), &drift).expect("plist replaced");
8846 super::sys::reload_detached(&identity, StartMode::Login, &path)
8847 .expect("helper started");
8848 }
8849 std::thread::sleep(Duration::from_secs(120));
8850 }
8851
8852 #[cfg(target_os = "macos")]
8862 #[test]
8863 fn a_job_reloads_itself_from_its_rewritten_plist() {
8864 if !super::launchd_fixture::enabled() {
8865 return;
8866 }
8867 let root = tempfile::tempdir().expect("a temporary directory");
8868 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
8869 let label = identity.launchd_label();
8870 let path = root.path().join(format!("{label}.plist"));
8871 let binary = super::launchd_fixture::job_binary(root.path());
8872 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
8873 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
8874 plist.push_str(&plist_string("Label", &label));
8875 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
8876 for argument in [
8877 binary.to_string_lossy().as_ref(),
8878 "--ignored",
8879 "--exact",
8880 "service::tests::detached_reload_job",
8881 "--test-threads=1",
8882 ] {
8883 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
8884 }
8885 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
8886 plist.push_str(&plist_string(
8887 "WorkingDirectory",
8888 &root.path().to_string_lossy(),
8889 ));
8890 plist.push_str(&plist_string("ProcessType", "Background"));
8891 plist.push_str("</dict>\n</plist>\n");
8892 std::fs::write(&path, plist).expect("plist written");
8893
8894 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
8895 let spawn_type = || {
8896 let printed = std::process::Command::new("launchctl")
8897 .args(["print", loaded.target()])
8898 .output()
8899 .expect("launchctl runs");
8900 String::from_utf8_lossy(&printed.stdout)
8901 .lines()
8902 .find_map(|line| {
8903 line.trim()
8904 .strip_prefix("spawn type = ")
8905 .map(str::to_string)
8906 })
8907 };
8908 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
8909
8910 let deadline = std::time::Instant::now() + Duration::from_secs(90);
8911 while spawn_type().as_deref() != Some("interactive (4)") {
8912 assert!(
8913 std::time::Instant::now() < deadline,
8914 "the job did not come back under its rewritten plist; launchd reports {:?}",
8915 spawn_type()
8916 );
8917 std::thread::sleep(Duration::from_millis(250));
8918 }
8919 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
8920 assert_eq!(
8921 plist_string_value(&rewritten, "ProcessType").as_deref(),
8922 Some(LAUNCHD_PROCESS_TYPE)
8923 );
8924 }
8925
8926 #[test]
8927 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
8928 let plist = launchd_plist(&linux_plan(StartMode::Boot));
8929 assert!(
8930 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
8931 "{plist}"
8932 );
8933 assert!(plist.contains("<string>daemon</string>"), "{plist}");
8934 assert!(plist.contains("<string>run</string>"), "{plist}");
8935 }
8936
8937 #[test]
8938 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
8939 assert_eq!(
8940 ServiceIdentity::product().launchd_label(),
8941 "io.github.IvanMurzak.runner-manager"
8942 );
8943 }
8944
8945 #[test]
8950 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
8951 let xml = windows_scheduled_task_xml(
8952 &windows_plan(StartMode::Login),
8953 &TaskPrincipal::named("HOST\\operator"),
8954 );
8955 assert!(xml.contains("<LogonTrigger>"), "{xml}");
8956 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
8957 assert!(
8958 xml.contains("<LogonType>InteractiveToken</LogonType>"),
8959 "{xml}"
8960 );
8961 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
8962 assert!(
8963 xml.contains("<Interval>PT1M</Interval>"),
8964 "Task Scheduler takes whole minutes only, and rejects the registration outright \
8965 for anything finer:\n{xml}"
8966 );
8967 }
8968
8969 #[test]
8970 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
8971 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
8976 let policy =
8977 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
8978 .expect("inside the supported range");
8979 assert_eq!(
8980 policy
8981 .effective_delay(DefinitionKind::WindowsScheduledTask)
8982 .as_secs(),
8983 enforced,
8984 "a {asked}s delay must be enforced as {enforced}s"
8985 );
8986 assert!(
8987 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
8988 "rounding must never shorten the bound"
8989 );
8990 }
8991 }
8992
8993 #[test]
8994 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
8995 let policy = RestartPolicy::default();
8996 for kind in [
8997 DefinitionKind::WindowsService,
8998 DefinitionKind::LaunchdPlist,
8999 DefinitionKind::SystemdUnit,
9000 ] {
9001 assert_eq!(
9002 policy.effective_delay(kind),
9003 policy.delay(),
9004 "{kind:?} takes seconds and enforces exactly what it is given"
9005 );
9006 }
9007 }
9008
9009 #[test]
9010 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9011 let host = Host::new();
9012 let operations = host.operations();
9013 operations
9014 .install(&host.request(StartMode::Login))
9015 .expect("an install at login");
9016 host.controls.edit("runner-manager", |registration| {
9018 registration.manager = DefinitionKind::WindowsScheduledTask;
9019 registration.restart_delay = Some(Duration::from_secs(60));
9020 });
9021
9022 let status = operations.status().expect("a status");
9023 assert!(
9024 status.is_healthy(),
9025 "minute granularity is the manager's, not a mis-registration: {status}"
9026 );
9027 assert!(
9028 status
9029 .notes()
9030 .iter()
9031 .any(|note| note.contains("whole minutes")),
9032 "but the operator must be told why 15 became 60: {status}"
9033 );
9034
9035 host.controls.edit("runner-manager", |registration| {
9038 registration.restart_delay = Some(Duration::from_secs(1));
9039 });
9040 assert!(
9041 !operations.status().expect("a status").is_healthy(),
9042 "a one-second delay is not what any manager was asked for"
9043 );
9044 }
9045
9046 #[test]
9047 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
9048 let plan = windows_plan(StartMode::Login);
9049 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
9050 assert_eq!(
9051 xml_value(&xml, "Command").as_deref(),
9052 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
9053 "{xml}"
9054 );
9055 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
9056 }
9057
9058 #[test]
9059 fn an_account_name_holding_xml_punctuation_is_escaped() {
9060 let xml = windows_scheduled_task_xml(
9061 &windows_plan(StartMode::Login),
9062 &TaskPrincipal::named("R&D\\ops"),
9063 );
9064 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
9065 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
9066 }
9067
9068 #[test]
9073 fn the_service_starts_automatically_under_the_account_the_store_admits() {
9074 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
9075 let directives = ini_directives(&text, "windows-service");
9076 assert_eq!(
9077 directives.get("StartType").map(String::as_str),
9078 Some("AutoStart")
9079 );
9080 assert_eq!(
9081 directives.get("Account").map(String::as_str),
9082 Some("NT AUTHORITY\\SYSTEM")
9083 );
9084 assert_eq!(
9085 directives.get("ServiceType").map(String::as_str),
9086 Some("OWN_PROCESS")
9087 );
9088 assert_eq!(
9089 directives
9090 .get("FailureActionRestartDelaySecs")
9091 .map(String::as_str),
9092 Some("15")
9093 );
9094 assert_eq!(
9095 directives
9096 .get("FailureActionsOnNonCrashFailures")
9097 .map(String::as_str),
9098 Some("true"),
9099 "without this flag a non-zero exit is not a failure the manager restarts"
9100 );
9101 }
9102
9103 #[test]
9104 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
9105 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
9106 assert_eq!(spec.account, None);
9107 assert!(spec.automatic_start);
9108 assert!(
9109 spec.command_line.contains("daemon run"),
9110 "{}",
9111 spec.command_line
9112 );
9113 }
9114
9115 #[test]
9120 fn each_definition_goes_where_its_platform_expects_it() {
9121 let home = PathBuf::from("/home/op");
9122 assert_eq!(
9123 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9124 Some(Path::new(
9125 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
9126 ))
9127 );
9128 assert_eq!(
9129 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9130 Some(Path::new(
9131 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
9132 ))
9133 );
9134 assert_eq!(
9135 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
9136 Some(Path::new("/etc/systemd/system/runner-manager.service"))
9137 );
9138 assert_eq!(
9139 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
9140 Some(Path::new(
9141 "/home/op/.config/systemd/user/runner-manager.service"
9142 ))
9143 );
9144 assert_eq!(
9145 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
9146 None,
9147 "the Service Control Manager has no file"
9148 );
9149 }
9150
9151 #[test]
9152 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
9153 assert_eq!(
9154 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
9155 None
9156 );
9157 assert_eq!(
9158 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
9159 None
9160 );
9161 }
9162
9163 #[test]
9168 fn the_rendered_definitions_are_all_least_privilege() {
9169 let linux = linux_plan(StartMode::Boot);
9170 let windows = windows_plan(StartMode::Boot);
9171 for (definition, plan) in [
9172 (ServiceDefinition::systemd(&linux, None), &linux),
9173 (ServiceDefinition::launchd(&linux, None), &linux),
9174 (ServiceDefinition::windows_service(&windows), &windows),
9175 ] {
9176 let review = review_least_privilege(&definition, plan);
9177 assert!(
9178 review.is_least_privilege(),
9179 "{:?} should be least privilege, got:\n{review}",
9180 definition.kind()
9181 );
9182 assert!(
9183 !review.controls().is_empty(),
9184 "a review that confirms nothing proves nothing: {:?}",
9185 definition.kind()
9186 );
9187 }
9188 }
9189
9190 #[test]
9191 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
9192 let plan = windows_plan(StartMode::Login);
9193 let definition =
9194 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
9195 let review = review_least_privilege(&definition, &plan);
9196 assert!(review.is_least_privilege(), "{review}");
9197 assert!(
9198 review
9199 .controls()
9200 .iter()
9201 .any(|control| control.contains("LeastPrivilege")),
9202 "{review}"
9203 );
9204 }
9205
9206 #[test]
9207 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
9208 let plan = linux_plan(StartMode::Boot);
9209 let rendered = systemd_unit(&plan);
9210 let widened = edited(
9211 &rendered,
9212 "ReadWritePaths=/var/lib/runner-manager/config",
9213 "ReadWritePaths=/etc /var/lib/runner-manager/config",
9214 );
9215 let review = review_least_privilege(
9216 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9217 &plan,
9218 );
9219 assert!(!review.is_least_privilege(), "{review}");
9220 assert!(
9221 review
9222 .excesses()
9223 .iter()
9224 .any(|finding| finding.detail.contains("/etc")),
9225 "the review must name the directory it objects to: {review}"
9226 );
9227 }
9228
9229 #[test]
9230 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
9231 let plan = linux_plan(StartMode::Boot);
9232 let rendered = systemd_unit(&plan);
9233 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
9234 let review = review_least_privilege(
9235 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
9236 &plan,
9237 );
9238 assert!(!review.is_least_privilege(), "{review}");
9239 assert!(
9240 review
9241 .excesses()
9242 .iter()
9243 .any(|finding| finding.subject == "NoNewPrivileges"),
9244 "{review}"
9245 );
9246 }
9247
9248 #[test]
9249 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
9250 let plan = linux_plan(StartMode::Boot);
9251 let rendered = systemd_unit(&plan);
9252 let widened = edited(
9253 &rendered,
9254 "CapabilityBoundingSet=\n",
9255 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
9256 );
9257 let review = review_least_privilege(
9258 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9259 &plan,
9260 );
9261 assert!(!review.is_least_privilege(), "{review}");
9262 assert!(
9263 review
9264 .excesses()
9265 .iter()
9266 .any(|finding| finding.subject == "CapabilityBoundingSet"),
9267 "{review}"
9268 );
9269 }
9270
9271 #[test]
9272 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
9273 let plan = linux_plan(StartMode::Boot);
9274 let rendered = systemd_unit(&plan);
9275 let widened = edited(
9276 &rendered,
9277 "[Install]",
9278 "ListenStream=127.0.0.1:9000\n\n[Install]",
9279 );
9280 let review = review_least_privilege(
9281 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
9282 &plan,
9283 );
9284 assert!(
9285 !review.is_least_privilege(),
9286 "07-security.md rule 2 forbids any inbound surface: {review}"
9287 );
9288 }
9289
9290 #[test]
9291 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
9292 let plan = linux_plan(StartMode::Boot);
9293 let rendered = systemd_unit(&plan);
9294 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
9295 let review = review_least_privilege(
9296 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
9297 &plan,
9298 );
9299 assert!(
9300 review.is_least_privilege(),
9301 "too little authority is not an excess: {review}"
9302 );
9303 assert!(
9304 review
9305 .findings()
9306 .iter()
9307 .any(|finding| finding.kind == FindingKind::Shortfall
9308 && finding.detail.contains("runtime")),
9309 "{review}"
9310 );
9311 }
9312
9313 #[test]
9314 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
9315 let plan = linux_plan(StartMode::Login);
9316 let rendered = launchd_plist(&plan);
9317 let widened = edited(
9318 &rendered,
9319 "<key>ProcessType</key>",
9320 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
9321 );
9322 let review = review_least_privilege(
9323 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
9324 &plan,
9325 );
9326 assert!(!review.is_least_privilege(), "{review}");
9327 assert!(
9328 review
9329 .excesses()
9330 .iter()
9331 .any(|finding| finding.subject == "UserName"),
9332 "{review}"
9333 );
9334 }
9335
9336 #[test]
9337 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
9338 let plan = linux_plan(StartMode::Boot);
9339 let rendered = launchd_plist(&plan);
9340 let widened = edited(
9341 &rendered,
9342 "<key>SessionCreate</key>\n <false/>",
9343 "<key>SessionCreate</key>\n <true/>",
9344 );
9345 let review = review_least_privilege(
9346 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
9347 &plan,
9348 );
9349 assert!(!review.is_least_privilege(), "{review}");
9350 assert!(
9351 review
9352 .excesses()
9353 .iter()
9354 .any(|finding| finding.subject == "SessionCreate"),
9355 "{review}"
9356 );
9357 }
9358
9359 #[test]
9360 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
9361 let plan = linux_plan(StartMode::Boot);
9362 let rendered = launchd_plist(&plan);
9363 let widened = edited(
9364 &rendered,
9365 "<key>ProcessType</key>",
9366 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
9367 );
9368 let review = review_least_privilege(
9369 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
9370 &plan,
9371 );
9372 assert!(!review.is_least_privilege(), "{review}");
9373 }
9374
9375 #[test]
9376 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
9377 let plan = windows_plan(StartMode::Login);
9378 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
9379 let widened = edited(
9380 &rendered,
9381 "<RunLevel>LeastPrivilege</RunLevel>",
9382 "<RunLevel>HighestAvailable</RunLevel>",
9383 );
9384 let review = review_least_privilege(
9385 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
9386 &plan,
9387 );
9388 assert!(!review.is_least_privilege(), "{review}");
9389 assert!(
9390 review
9391 .excesses()
9392 .iter()
9393 .any(|finding| finding.subject == "RunLevel"),
9394 "{review}"
9395 );
9396 }
9397
9398 #[test]
9399 fn a_task_that_would_store_a_password_is_not_least_privilege() {
9400 let plan = windows_plan(StartMode::Login);
9401 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
9402 let widened = edited(
9403 &rendered,
9404 "<LogonType>InteractiveToken</LogonType>",
9405 "<LogonType>Password</LogonType>",
9406 );
9407 let review = review_least_privilege(
9408 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
9409 &plan,
9410 );
9411 assert!(!review.is_least_privilege(), "{review}");
9412 }
9413
9414 #[test]
9415 fn an_interactive_windows_service_is_not_least_privilege() {
9416 let plan = windows_plan(StartMode::Boot);
9417 let rendered = windows_service_descriptor(&plan);
9418 let widened = edited(
9419 &rendered,
9420 "ServiceType=OWN_PROCESS",
9421 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
9422 );
9423 let review = review_least_privilege(
9424 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
9425 &plan,
9426 );
9427 assert!(!review.is_least_privilege(), "{review}");
9428 assert!(
9429 review
9430 .excesses()
9431 .iter()
9432 .any(|finding| finding.subject == "ServiceType"),
9433 "{review}"
9434 );
9435 }
9436
9437 #[test]
9438 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
9439 let plan = windows_plan(StartMode::Boot);
9440 let rendered = windows_service_descriptor(&plan);
9441 let changed = edited(
9442 &rendered,
9443 "Account=NT AUTHORITY\\SYSTEM",
9444 "Account=NT AUTHORITY\\LocalService",
9445 );
9446 let review = review_least_privilege(
9447 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
9448 &plan,
9449 );
9450 assert!(review.is_least_privilege(), "{review}");
9456 assert!(
9457 review.findings().iter().any(|finding| {
9458 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
9459 }),
9460 "{review}"
9461 );
9462 }
9463
9464 #[test]
9469 fn a_recorded_path_that_is_still_there_is_current() {
9470 let host = Host::new();
9471 let state = inspect_binary(&host.binary, Some(&host.binary));
9472 assert!(!state.is_error(), "{state}");
9473 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
9474 }
9475
9476 #[test]
9477 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
9478 let host = Host::new();
9479 let recorded = host.binary.clone();
9483 let healthy = inspect_binary(&recorded, Some(&recorded));
9484 assert!(
9485 !healthy.is_error(),
9486 "the discriminator: before the binary moves, this must be healthy"
9487 );
9488
9489 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
9490
9491 let state = inspect_binary(&recorded, Some(&recorded));
9492 assert!(state.is_error(), "{state}");
9493 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
9494 assert!(
9495 state.to_string().contains("npm"),
9496 "the message must name the cause an operator will not otherwise connect: {state}"
9497 );
9498 }
9499
9500 #[test]
9501 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
9502 let root = tempfile::tempdir().expect("a temporary directory");
9503 let state = inspect_binary(root.path(), None);
9504 assert!(state.is_error(), "{state}");
9505 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
9506 }
9507
9508 #[test]
9509 fn a_registration_naming_a_different_binary_is_a_divergence() {
9510 let host = Host::new();
9511 let other = host.binary.with_file_name("something-else");
9512 let state = inspect_binary(&host.binary, Some(&other));
9513 assert!(state.is_error(), "{state}");
9514 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
9515 }
9516
9517 #[test]
9518 fn absence_is_reported_before_divergence() {
9519 let host = Host::new();
9520 let recorded = host.binary.clone();
9521 std::fs::remove_file(&recorded).expect("removable");
9522 let other = recorded.with_file_name("something-else");
9523 assert!(matches!(
9527 inspect_binary(&recorded, Some(&other)),
9528 BinaryPath::Missing { .. }
9529 ));
9530 }
9531
9532 #[test]
9537 fn the_record_round_trips_through_toml() {
9538 let host = Host::new();
9539 let plan = InstallPlan::resolve(
9540 ServiceIdentity::product(),
9541 &host.request(StartMode::Boot),
9542 ServiceDirectories::of(&host.paths),
9543 )
9544 .expect("a resolvable plan");
9545 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
9546 let record = InstallRecord::of(&plan, &definition, Utc::now());
9547 record.write(&host.paths).expect("a writable record");
9548 let read = InstallRecord::read(&host.paths)
9549 .expect("a readable record")
9550 .expect("a record is there");
9551 assert_eq!(read, record);
9552 assert_eq!(read.binary, host.binary);
9553 assert!(read.binary.is_absolute());
9554 }
9555
9556 #[test]
9557 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
9558 let host = Host::new();
9559 let identity = ServiceIdentity::product();
9560 let plan = InstallPlan::resolve(
9561 identity.clone(),
9562 &host.request(StartMode::Boot),
9563 ServiceDirectories::of(&host.paths),
9564 )
9565 .expect("a resolvable plan");
9566 let installed = systemd_unit(&plan);
9567 let definition =
9568 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
9569 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9570 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
9571
9572 assert_eq!(
9573 systemd_definition_drift(&identity, &record, path.clone(), &installed),
9574 None,
9575 "the record must rebuild exactly the unit `service install` wrote"
9576 );
9577
9578 record.installed_by_version = "0.4.7".into();
9579 let frozen = installed.replace(
9580 "[Service]\n",
9581 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
9582 );
9583 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
9584 .expect("an outdated unit is drift");
9585 assert_eq!(drift.installed_by_version, "0.4.7");
9586 assert_eq!(drift.rendered, installed);
9587
9588 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9589 .with_definition_drift(Some(drift));
9590 assert!(!status.is_healthy());
9591 assert!(
9592 status
9593 .problems()
9594 .iter()
9595 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
9596 "{status}"
9597 );
9598 }
9599
9600 #[test]
9601 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
9602 let host = Host::new();
9603 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
9604 let first = Utc::now() - chrono::Duration::hours(9);
9605 record_github_credential_rejected(&host.paths, first).unwrap();
9606 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
9607 let since = github_credential_rejected_since(&host.paths).unwrap();
9608 assert_eq!(since, Some(first));
9609
9610 let status = ServiceStatus::compose(
9611 ServiceIdentity::product(),
9612 None,
9613 None,
9614 None,
9615 None,
9616 &host.paths,
9617 )
9618 .with_credential_rejection(since);
9619 assert!(!status.is_healthy());
9620 assert_eq!(status.credential_rejected_since(), Some(first));
9621 assert!(status.to_string().contains("auth login"), "{status}");
9622
9623 clear_github_credential_rejection(&host.paths).unwrap();
9624 clear_github_credential_rejection(&host.paths).unwrap();
9625 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
9626 }
9627
9628 #[test]
9630 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
9631 use crate::launch_health::{
9632 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
9633 record_launches_blocked,
9634 };
9635
9636 let host = Host::new();
9637 let compose = || {
9638 ServiceStatus::compose(
9639 ServiceIdentity::product(),
9640 None,
9641 None,
9642 None,
9643 None,
9644 &host.paths,
9645 )
9646 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
9647 };
9648 assert!(
9649 !compose()
9650 .problems()
9651 .iter()
9652 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
9653 );
9654 let blocked = BlockCause::FenceHeld {
9655 directory: PathBuf::from("launch-fence"),
9656 owner: None,
9657 }
9658 .blocked_since(Utc::now(), Viewpoint::Guest);
9659 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
9660
9661 let status = compose();
9662 assert!(!status.is_healthy());
9663 assert_eq!(status.launches_blocked(), Some(&blocked));
9664 let problem = status
9665 .problems()
9666 .iter()
9667 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
9668 .expect("reported under its own subject");
9669 assert!(problem.detail.contains("systemctl restart"), "{status}");
9670
9671 clear_launches_blocked(&host.paths).unwrap();
9672 assert_eq!(compose().launches_blocked(), None);
9673 }
9674
9675 #[test]
9678 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
9679 let host = Host::new();
9680 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
9681 let first = Utc::now() - chrono::Duration::minutes(40);
9682 record_credential_unreadable(&host.paths, first).unwrap();
9683 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
9684 let since = credential_unreadable_since(&host.paths).unwrap();
9685 assert_eq!(since, Some(first), "a restart loop must not move the start");
9686
9687 let status = ServiceStatus::compose(
9688 ServiceIdentity::product(),
9689 None,
9690 None,
9691 None,
9692 None,
9693 &host.paths,
9694 )
9695 .with_credential_unreadable(since);
9696 assert!(!status.is_healthy());
9697 let problem = status
9698 .problems()
9699 .iter()
9700 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
9701 .expect("reported under its own subject");
9702 assert!(
9704 problem
9705 .detail
9706 .contains(&sign_in_instruction(StartMode::default())),
9707 "{status}"
9708 );
9709
9710 clear_credential_unreadable(&host.paths).unwrap();
9711 clear_credential_unreadable(&host.paths).unwrap();
9712 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
9713 let healthy = ServiceStatus::compose(
9714 ServiceIdentity::product(),
9715 None,
9716 None,
9717 None,
9718 None,
9719 &host.paths,
9720 )
9721 .with_credential_unreadable(None);
9722 assert!(
9723 !healthy
9724 .problems()
9725 .iter()
9726 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
9727 "{healthy}"
9728 );
9729 }
9730
9731 #[test]
9732 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
9733 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
9734 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
9735 assert_eq!(
9736 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
9737 cfg!(unix),
9738 "a machine-scoped store needs root on Unix"
9739 );
9740 assert_eq!(
9741 sign_in_instruction(StartMode::Login).contains("SSH"),
9742 cfg!(target_os = "macos"),
9743 "only the macOS login keychain is out of an SSH session's reach"
9744 );
9745 }
9746
9747 #[cfg(unix)]
9756 #[test]
9757 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
9758 use std::os::unix::fs::PermissionsExt as _;
9759
9760 let host = Host::new();
9761 let plan = InstallPlan::resolve(
9762 ServiceIdentity::product(),
9763 &host.request(StartMode::Boot),
9764 ServiceDirectories::of(&host.paths),
9765 )
9766 .expect("a resolvable plan");
9767 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
9768 InstallRecord::of(&plan, &definition, Utc::now())
9769 .write(&host.paths)
9770 .expect("a writable record");
9771
9772 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
9773 .expect("the record is there")
9774 .permissions()
9775 .mode()
9776 & 0o777;
9777 assert_eq!(
9778 mode, 0o644,
9779 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
9780 service install` wrote, and `service status` fails on their own host. It holds no \
9781 credential and sits in a 0700 directory, so 0644 discloses nothing"
9782 );
9783 }
9784
9785 #[test]
9789 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
9790 let host = Host::new();
9791 let path = InstallRecord::path(&host.paths);
9792 std::fs::write(
9793 &path,
9794 format!(
9795 "schema_version = {RECORD_SCHEMA_VERSION}
9796service_name = \"runner-manager\"
9797 manager = \"systemd\"
9798start_mode = \"boot\"
9799account = \"root\"
9800 binary = \"/x\"
9801arguments = []
9802restart_delay_secs = 15
9803 restart_reset_secs = 600
9804log_file = \"/x\"
9805 installed_at = \"2026-01-01T00:00:00Z\"
9806installed_by_version = \"0.1.0\"
9807 [directories]
9808config = \"/a\"
9809state = \"/b\"
9810runtime = \"/c\"
9811logs = \"/d\"
9812"
9813 ),
9814 )
9815 .expect("a writable record");
9816 let read = InstallRecord::read(&host.paths)
9817 .expect("a record missing an optional field is still readable")
9818 .expect("a record is there");
9819 assert_eq!(
9820 read.source_binary, None,
9821 "the legacy layout has no source, and must not invent one"
9822 );
9823 }
9824
9825 #[test]
9827 fn a_registration_remembers_the_file_it_was_copied_from() {
9828 let host = Host::new();
9829 let source = host.binary.with_file_name("npm-installed-runner-manager");
9830 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
9831 let plan = InstallPlan::resolve(
9832 ServiceIdentity::product(),
9833 &host.request(StartMode::Boot).copied_from(&source),
9834 ServiceDirectories::of(&host.paths),
9835 )
9836 .expect("a resolvable plan");
9837 let definition = ServiceDefinition::from_text(
9838 DefinitionKind::SystemdUnit,
9839 "[Service]
9840",
9841 );
9842 let record = InstallRecord::of(&plan, &definition, Utc::now());
9843 record.write(&host.paths).expect("a writable record");
9844
9845 let read = InstallRecord::read(&host.paths)
9846 .expect("a readable record")
9847 .expect("a record is there");
9848 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
9849 assert_ne!(
9850 read.source_binary.as_deref(),
9851 Some(read.binary.as_path()),
9852 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
9853 );
9854 }
9855
9856 #[test]
9857 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
9858 let host = Host::new();
9859 let path = InstallRecord::path(&host.paths);
9860 std::fs::write(
9861 &path,
9862 format!(
9863 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
9864 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
9865 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
9866 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
9867 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
9868 RECORD_SCHEMA_VERSION + 1
9869 ),
9870 )
9871 .expect("a writable record");
9872 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
9873 assert!(
9874 matches!(error, ServiceError::RecordUnreadable { .. }),
9875 "{error}"
9876 );
9877 assert!(
9878 error.to_string().contains("service uninstall"),
9879 "the message must say how to recover: {error}"
9880 );
9881 }
9882
9883 #[test]
9884 fn no_record_is_not_an_error() {
9885 let host = Host::new();
9886 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
9887 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
9888 }
9889
9890 #[test]
9895 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
9896 let host = Host::new();
9897 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
9898 }
9899
9900 #[test]
9901 fn the_heartbeat_round_trips_to_the_second() {
9902 let host = Host::new();
9903 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
9904 .expect("a valid timestamp")
9905 .with_timezone(&Utc);
9906 record_github_contact(&host.paths, at).expect("a writable heartbeat");
9907 assert_eq!(
9908 last_github_contact(&host.paths).expect("readable"),
9909 Some(at)
9910 );
9911 }
9912
9913 #[test]
9914 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
9915 let host = Host::new();
9916 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
9917 let error = last_github_contact(&host.paths)
9918 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
9919 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9920 }
9921
9922 #[test]
9928 fn a_runner_root_refusal_round_trips_for_service_status() {
9929 let host = Host::new();
9930 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
9931
9932 assert!(
9933 runner_root_refusals(&host.paths)
9934 .expect("readable")
9935 .is_empty(),
9936 "no record means every policy is placing runners"
9937 );
9938
9939 record_runner_root_refusal(
9940 &host.paths,
9941 "policy-a",
9942 at,
9943 "denied_by_privacy_policy",
9944 "/Volumes/NVME/runners",
9945 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
9946 )
9947 .expect("a writable record");
9948
9949 let refusals = runner_root_refusals(&host.paths).expect("readable");
9950 assert_eq!(refusals.len(), 1);
9951 assert_eq!(refusals[0].policy, "policy-a");
9952 assert_eq!(refusals[0].at, at);
9953 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
9954 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
9955 assert!(
9956 refusals[0].detail.contains("/Volumes/NVME/runners")
9957 && refusals[0].detail.contains("Full Disk Access"),
9958 "the path and the remediation are the whole point of this file: {refusals:?}"
9959 );
9960 }
9961
9962 #[test]
9970 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
9971 let host = Host::new();
9972 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
9973 record_runner_root_refusal(
9974 &host.paths,
9975 "broken",
9976 at,
9977 "denied_by_privacy_policy",
9978 "/Volumes/NVME/runners",
9979 "detail",
9980 )
9981 .expect("a writable record");
9982 record_runner_root_refusal(
9983 &host.paths,
9984 "also-broken",
9985 at,
9986 "not_writable",
9987 "/srv/other",
9988 "detail",
9989 )
9990 .expect("a writable record");
9991
9992 clear_runner_root_refusal(&host.paths, "healthy")
9994 .expect("clearing an absent policy is fine");
9995 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
9996
9997 let refusals = runner_root_refusals(&host.paths).expect("readable");
9998 assert_eq!(
9999 refusals
10000 .iter()
10001 .map(|r| r.policy.as_str())
10002 .collect::<Vec<_>>(),
10003 vec!["broken"],
10004 "the policy that is still refused must keep its record"
10005 );
10006 }
10007
10008 #[test]
10011 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
10012 let host = Host::new();
10013 record_runner_root_refusal(
10014 &host.paths,
10015 "p",
10016 Utc::now(),
10017 "not_writable",
10018 "/srv/x",
10019 "detail",
10020 )
10021 .expect("a writable record");
10022
10023 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
10024 assert!(
10025 !root_refusal_path(&host.paths).exists(),
10026 "a host with nothing refused leaves nothing behind"
10027 );
10028 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
10029 }
10030
10031 #[test]
10034 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
10035 let host = Host::new();
10036 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
10037 let error = runner_root_refusals(&host.paths)
10038 .expect_err("an unparseable record is not the same as no record");
10039 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10040 }
10041
10042 #[test]
10052 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
10053 let host = Host::new();
10054 record_runner_root_refusal(
10055 &host.paths,
10056 "policy-a",
10057 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
10058 "denied_by_privacy_policy",
10059 "/Volumes/NVME/runners",
10060 "Grant Full Disk Access to the program that runs the service",
10061 )
10062 .expect("a writable record");
10063
10064 let status = host.operations().status().expect("a readable status");
10065 let notes = status.notes().join("\n");
10066
10067 assert!(
10068 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
10069 "the directory and the remediation the log had to scrub must appear here: {notes}"
10070 );
10071 assert!(
10072 notes.contains("policy-a"),
10073 "the operator has to know which target placed no runner: {notes}"
10074 );
10075 assert!(
10076 !status
10077 .problems()
10078 .iter()
10079 .any(|problem| problem.subject == "runner root"),
10080 "a record nothing an operator types can clear must not drive the exit code"
10081 );
10082 }
10083
10084 #[test]
10090 fn install_records_the_absolute_binary_path_and_the_four_directories() {
10091 let host = Host::new();
10092 let installed = host
10093 .operations()
10094 .install(&host.request(StartMode::Boot))
10095 .expect("an install against the recording controls");
10096
10097 assert_eq!(installed.record.binary, host.binary);
10098 assert!(installed.record.binary.is_absolute());
10099 assert_eq!(installed.record.start_mode, StartMode::Boot);
10100 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
10101 assert_eq!(
10102 installed.record.directories,
10103 ServiceDirectories::of(&host.paths)
10104 );
10105 assert_eq!(
10106 installed.record.log_file,
10107 host.paths.logs_dir().join(LOG_FILE_STEM)
10108 );
10109 assert_eq!(installed.record.restart_delay_secs, 15);
10110
10111 let registrations = host.controls.registrations();
10112 assert_eq!(registrations.len(), 1);
10113 assert_eq!(registrations[0].0, StartMode::Boot);
10114 assert_eq!(registrations[0].1, "runner-manager");
10115 }
10116
10117 #[test]
10118 fn install_is_refused_while_the_single_instance_lock_is_held() {
10119 let host = Host::new();
10120 {
10122 let operations = host.operations();
10123 operations
10124 .install(&host.request(StartMode::Boot))
10125 .expect("an install with the lock free");
10126 operations.uninstall().expect("a clean slate");
10127 }
10128
10129 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
10130 .expect("this process takes the lock first");
10131
10132 let error = host
10133 .operations()
10134 .install(&host.request(StartMode::Boot))
10135 .expect_err("a second agent must not be registered while one is running");
10136 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
10137 assert!(
10138 error.to_string().contains("already running"),
10139 "the message must be actionable: {error}"
10140 );
10141 assert!(
10142 host.controls.registrations().is_empty(),
10143 "a refused install must register nothing"
10144 );
10145 assert_eq!(
10146 InstallRecord::read(&host.paths).expect("readable"),
10147 None,
10148 "a refused install must write no record"
10149 );
10150 }
10151
10152 #[test]
10160 fn installing_over_the_same_start_mode_replaces_the_registration() {
10161 let host = Host::new();
10162 let operations = host.operations();
10163 operations
10164 .install(&host.request(StartMode::Boot))
10165 .expect("the first install");
10166
10167 let again = operations
10168 .install(&host.request(StartMode::Boot))
10169 .expect("an install over the same mode replaces rather than refusing");
10170
10171 assert!(
10172 again.replaced_existing,
10173 "the operator is told this replaced something rather than made it"
10174 );
10175 assert_eq!(
10176 host.controls.registrations().len(),
10177 1,
10178 "replacing must not leave two registrations behind"
10179 );
10180 assert_eq!(
10181 InstallRecord::read(&host.paths)
10182 .expect("readable")
10183 .expect("a record")
10184 .start_mode,
10185 StartMode::Boot
10186 );
10187 }
10188
10189 #[test]
10193 fn installing_over_the_other_start_mode_is_refused() {
10194 let host = Host::new();
10195 let operations = host.operations();
10196 operations
10197 .install(&host.request(StartMode::Boot))
10198 .expect("the first install");
10199
10200 let error = operations
10201 .install(&host.request(StartMode::Login))
10202 .expect_err("a mode change is not an install");
10203 assert!(
10204 matches!(
10205 error,
10206 ServiceError::AlreadyInstalled {
10207 existing: StartMode::Boot,
10208 requested: StartMode::Login,
10209 ..
10210 }
10211 ),
10212 "{error}"
10213 );
10214 assert!(
10215 !error
10216 .to_string()
10217 .contains("switch the start mode in place,"),
10218 "the old remedy named a capability no command offers; the terminal UI is where \
10219 the start mode moves: {error}"
10220 );
10221 assert_eq!(host.controls.registrations().len(), 1);
10222 }
10223
10224 #[test]
10225 fn install_rolls_back_the_registration_when_record_persistence_fails() {
10226 let host = Host::new();
10227 let record_path = InstallRecord::path(&host.paths);
10228 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
10229
10230 let error = host
10231 .operations()
10232 .install(&host.request(StartMode::Boot))
10233 .expect_err("record persistence must fail");
10234
10235 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10236 assert!(
10237 host.controls.registrations().is_empty(),
10238 "a failed install must not leave a live unrecorded registration"
10239 );
10240 assert!(
10241 host.controls
10242 .calls()
10243 .iter()
10244 .any(|call| call == "uninstall runner-manager (boot)"),
10245 "the registration must be explicitly rolled back: {:?}",
10246 host.controls.calls()
10247 );
10248 assert!(
10249 !host.runner_root.as_path().exists(),
10250 "the rollback must take the runner root this install created with it; a directory \
10251 prepared for a registration that does not exist is litter, and on Windows it is \
10252 litter with a security descriptor"
10253 );
10254 }
10255
10256 #[test]
10261 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
10262 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
10263
10264 assert_eq!(
10268 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
10269 ServiceAccount::LocalSystem
10270 );
10271 assert_eq!(
10272 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
10273 ServiceAccount::InvokingUser
10274 );
10275
10276 let boot = default_root_sddl(&RootAdmission::LocalSystem);
10277 assert!(!grants_broad_write(&boot), "{boot}");
10278 assert!(
10279 !boot.contains("S-1-5-21"),
10280 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
10281 );
10282
10283 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
10288 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
10289 assert!(!grants_broad_write(&login), "{login}");
10290 }
10291
10292 #[test]
10293 fn an_install_reports_the_runner_root_it_prepared() {
10294 let host = Host::new();
10295 let installed = host
10296 .operations()
10297 .install(&host.request(StartMode::Boot))
10298 .expect("an install");
10299 let rendered = installed.runner_root.to_string();
10300 assert!(
10301 !rendered.contains("S-1-5-21"),
10302 "the report must add no identity to the output: {rendered}"
10303 );
10304 if cfg!(windows) {
10305 assert_eq!(
10306 installed.runner_root.path(),
10307 Some(host.runner_root.as_path())
10308 );
10309 assert!(
10310 host.runner_root.as_path().is_dir(),
10311 "the directory jobs would run in has to exist once the service is registered"
10312 );
10313 } else {
10314 assert_eq!(
10315 installed.runner_root,
10316 crate::runner_root_access::RootAccessSummary::NotApplicable,
10317 "macOS and Linux keep the runtime directory they have always used"
10318 );
10319 }
10320 }
10321
10322 #[test]
10323 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
10324 let host = Host::new();
10325 let operations = host.operations();
10326 operations
10327 .install(&host.request(StartMode::Boot))
10328 .expect("an install at boot");
10329
10330 let change = operations
10331 .set_start_mode(StartMode::Login)
10332 .expect("a switch to login");
10333
10334 assert!(change.changed);
10335 if cfg!(windows) {
10336 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
10337 assert!(
10338 host.runner_root.as_path().is_dir(),
10339 "the switch must not remove the directory it reconciled"
10340 );
10341 }
10342 assert!(
10345 change.to_string().contains("runner root"),
10346 "{}",
10347 change.to_string()
10348 );
10349 }
10350
10351 #[test]
10352 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
10353 let host = Host::new();
10354 let operations = host.operations();
10355 operations
10356 .install(&host.request(StartMode::Boot))
10357 .expect("an install at boot");
10358
10359 let change = operations
10360 .set_start_mode(StartMode::Boot)
10361 .expect("a switch to the mode already in force");
10362
10363 assert!(!change.changed);
10364 assert_eq!(
10365 change.runner_root,
10366 crate::runner_root_access::RootAccessSummary::NotApplicable,
10367 "nothing moves, so nothing about the root's access control has to; reconciling here \
10368 would turn a no-op command into one that can fail on a permission it does not need"
10369 );
10370 }
10371
10372 #[cfg(windows)]
10373 #[test]
10374 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
10375 let host = Host::new();
10376 host.controls
10377 .fail_next_install(StartMode::Boot, "injected registration failure");
10378
10379 let error = host
10380 .operations()
10381 .install(&host.request(StartMode::Boot))
10382 .expect_err("the manager refuses the registration");
10383
10384 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
10385 assert!(
10386 !host.runner_root.as_path().exists(),
10387 "the directory was created for a registration that does not exist"
10388 );
10389 }
10390
10391 #[cfg(windows)]
10392 #[test]
10393 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
10394 let host = Host::new();
10395 crate::runner_root_access::create_with_descriptor_for_tests(
10399 host.runner_root.as_path(),
10400 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
10401 )
10402 .expect("a deliberately open runner root");
10403 let before = crate::runner_root_access::report(host.runner_root.as_path());
10404
10405 let error = host
10406 .operations()
10407 .install(&host.request(StartMode::Boot))
10408 .expect_err("an open runner root is refused");
10409
10410 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
10411 assert!(
10412 error.to_string().contains("nothing was registered"),
10413 "{error}"
10414 );
10415 assert!(
10416 host.controls.registrations().is_empty(),
10417 "the refusal has to come before the platform is asked to register anything: {:?}",
10418 host.controls.calls()
10419 );
10420 assert_eq!(
10421 crate::runner_root_access::report(host.runner_root.as_path()),
10422 before,
10423 "an open directory is refused rather than tightened: its contents cannot be trusted, \
10424 so adopting it would be worse than declining it"
10425 );
10426 }
10427
10428 #[cfg(windows)]
10429 #[test]
10430 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
10431 let host = Host::new();
10432 let operations = host.operations();
10433 operations
10434 .install(&host.request(StartMode::Boot))
10435 .expect("an install");
10436 assert!(host.runner_root.as_path().is_dir());
10437
10438 operations.uninstall().expect("an uninstall");
10439
10440 assert!(
10441 host.runner_root.as_path().is_dir(),
10442 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
10443 runner root may hold an operator's retained workspaces."
10444 );
10445 }
10446
10447 #[test]
10448 fn install_reviews_what_it_registered() {
10449 let host = Host::new();
10450 let installed = host
10451 .operations()
10452 .install(&host.request(StartMode::Boot))
10453 .expect("an install");
10454 assert!(
10455 installed.review.is_least_privilege(),
10456 "{}",
10457 installed.review
10458 );
10459 assert!(
10460 !installed.review.controls().is_empty(),
10461 "a review that confirms nothing proves nothing: {}",
10462 installed.review
10463 );
10464 assert_eq!(
10465 installed.review.kind(),
10466 host_definition_kind(StartMode::Boot),
10467 "the review must be of the definition this host's manager was given"
10468 );
10469 assert!(
10470 !installed.review.account().justification().is_empty(),
10471 "a privileged account with no stated reason is an unreviewed one"
10472 );
10473 }
10474
10475 #[test]
10480 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
10481 let host = Host::new();
10482 let operations = host.operations();
10483 operations
10484 .install(&host.request(StartMode::Boot))
10485 .expect("an install");
10486
10487 let config = host.paths.config_dir();
10491 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
10492 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
10493 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
10494 std::fs::write(
10495 host.paths
10496 .state_dir()
10497 .join("packages/2.330.0/runner.tar.gz"),
10498 b"cached package",
10499 )
10500 .expect("writable");
10501 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
10502 std::fs::write(
10503 host.paths.state_dir().join("secrets/user-access-token"),
10504 b"a stand-in for the stored credential",
10505 )
10506 .expect("writable");
10507 std::fs::write(
10508 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
10509 b"diagnostics",
10510 )
10511 .expect("writable");
10512
10513 let roots: Vec<PathBuf> = host
10514 .paths
10515 .all()
10516 .iter()
10517 .map(|(_, path)| (*path).to_path_buf())
10518 .collect();
10519 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
10520 let before = snapshot(&roots);
10521
10522 assert!(
10525 before.len() >= 6,
10526 "the fixture must actually contain the files this test is about, got {before:#?}"
10527 );
10528 let record_path = InstallRecord::path(&host.paths);
10529 assert!(
10530 before.contains_key(&record_path),
10531 "the install record must be present before uninstall"
10532 );
10533
10534 let uninstalled = operations.uninstall().expect("an uninstall");
10535 assert!(uninstalled.removed_registration);
10536 assert!(uninstalled.removed_record);
10537
10538 let after = snapshot(&roots);
10539
10540 let mut expected = before.clone();
10542 expected.remove(&record_path);
10543 assert_eq!(
10544 after, expected,
10545 "uninstall must remove its own record and nothing else"
10546 );
10547 assert!(
10548 !record_path.exists(),
10549 "the record itself must go, or `uninstall` did nothing at all"
10550 );
10551 assert!(
10552 uninstalled
10553 .preserved
10554 .iter()
10555 .all(|path| roots.contains(&path.as_path())),
10556 "the preserved list must name the four directories: {uninstalled}"
10557 );
10558 }
10559
10560 #[test]
10561 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
10562 let host = Host::new();
10563 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
10564 assert!(!uninstalled.removed_registration);
10565 assert!(!uninstalled.removed_record);
10566 }
10567
10568 #[test]
10569 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
10570 let host = Host::new();
10571 let operations = host.operations();
10572 operations
10573 .install(&host.request(StartMode::Boot))
10574 .expect("an install");
10575 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
10576
10577 let uninstalled = operations.uninstall().expect("an uninstall");
10578 assert!(
10579 uninstalled.removed_registration,
10580 "a lost record must not strand a registration"
10581 );
10582 assert!(host.controls.registrations().is_empty());
10583 }
10584
10585 #[test]
10590 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
10591 let host = Host::new();
10592 let operations = host.operations();
10593 operations
10594 .install(&host.request(StartMode::Boot))
10595 .expect("an install at boot");
10596
10597 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
10601
10602 let change = operations
10603 .set_start_mode(StartMode::Login)
10604 .expect("a switch that does not reinstall the product");
10605 assert!(change.changed);
10606 assert_eq!(change.from, StartMode::Boot);
10607 assert_eq!(change.to, StartMode::Login);
10608 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
10609
10610 let record = InstallRecord::read(&host.paths)
10611 .expect("readable")
10612 .expect("a record");
10613 assert_eq!(record.start_mode, StartMode::Login);
10614 assert_eq!(
10615 record.binary, host.binary,
10616 "the recorded path must survive the switch untouched"
10617 );
10618
10619 let registrations = host.controls.registrations();
10620 assert_eq!(registrations.len(), 1, "{registrations:?}");
10621 assert_eq!(registrations[0].0, StartMode::Login);
10622 assert!(
10623 registrations[0]
10624 .2
10625 .command_line
10626 .contains(&host.binary.to_string_lossy().into_owned()),
10627 "{:?}",
10628 registrations[0].2
10629 );
10630 }
10631
10632 #[test]
10633 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
10634 let host = Host::new();
10635 let operations = host.operations();
10636 operations
10637 .install(&host.request(StartMode::Boot))
10638 .expect("an install at boot");
10639 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
10640 host.controls
10641 .fail_next_install(StartMode::Login, "injected target failure");
10642
10643 let error = operations
10644 .set_start_mode(StartMode::Login)
10645 .expect_err("the target manager refuses the install");
10646
10647 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
10648 assert_eq!(
10649 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
10650 record_before
10651 );
10652 let registrations = host.controls.registrations();
10653 assert_eq!(registrations.len(), 1, "{registrations:?}");
10654 assert_eq!(registrations[0].0, StartMode::Boot);
10655 }
10656
10657 #[test]
10658 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
10659 let host = Host::new();
10660 let operations = host.operations();
10661 operations
10662 .install(&host.request(StartMode::Boot))
10663 .expect("an install at boot");
10664 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
10665 let config = host.paths.config_dir().to_path_buf();
10666 let hidden = config.with_file_name("config-hidden-by-fault");
10667 host.controls.hide_directory_after_install(
10668 StartMode::Login,
10669 config.clone(),
10670 hidden.clone(),
10671 );
10672
10673 let error = operations
10674 .set_start_mode(StartMode::Login)
10675 .expect_err("the injected filesystem fault prevents persistence");
10676
10677 std::fs::remove_file(&config).expect("remove the injected blocker");
10678 std::fs::rename(&hidden, &config).expect("restore the record directory");
10679 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
10680 assert_eq!(
10681 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
10682 record_before
10683 );
10684 let registrations = host.controls.registrations();
10685 assert_eq!(registrations.len(), 1, "{registrations:?}");
10686 assert_eq!(registrations[0].0, StartMode::Boot);
10687 assert!(
10688 host.controls
10689 .calls()
10690 .iter()
10691 .any(|call| call == "uninstall runner-manager (login)"),
10692 "the target must be rolled back: {:?}",
10693 host.controls.calls()
10694 );
10695 }
10696
10697 #[test]
10698 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
10699 let host = Host::new();
10700 let operations = host.operations();
10701 operations
10702 .install(&host.request(StartMode::Boot))
10703 .expect("an install");
10704 let before = host.controls.calls().len();
10705
10706 let change = operations
10707 .set_start_mode(StartMode::Boot)
10708 .expect("a no-op switch");
10709 assert!(!change.changed);
10710 assert_eq!(
10711 host.controls.calls().len(),
10712 before,
10713 "a no-op switch must not touch the service manager"
10714 );
10715 }
10716
10717 #[test]
10718 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
10719 let host = Host::new();
10720 let error = host
10721 .operations()
10722 .set_start_mode(StartMode::Login)
10723 .expect_err("there is nothing to switch");
10724 assert!(
10725 matches!(error, ServiceError::NotInstalled { .. }),
10726 "{error}"
10727 );
10728 }
10729
10730 #[test]
10735 fn status_reports_the_four_facts_journey_five_asks_for() {
10736 let host = Host::new();
10737 let operations = host.operations();
10738 operations
10739 .install(&host.request(StartMode::Boot))
10740 .expect("an install");
10741 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
10742 .expect("a valid timestamp")
10743 .with_timezone(&Utc);
10744 record_github_contact(&host.paths, at).expect("a heartbeat");
10745
10746 let status = operations.status().expect("a status");
10747 assert_eq!(status.start_mode(), Some(StartMode::Boot));
10748 assert_eq!(
10749 status.binary().map(BinaryPath::recorded),
10750 Some(host.binary.as_path())
10751 );
10752 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
10753 assert_eq!(status.last_github_contact(), Some(at));
10754 assert!(status.is_installed());
10755 assert!(status.is_healthy(), "{status}");
10756
10757 let printed = status.to_string();
10758 for fragment in [
10759 "start mode",
10760 "diagnostic log",
10761 "last GitHub contact",
10762 "binary",
10763 ] {
10764 assert!(printed.contains(fragment), "{printed}");
10765 }
10766 }
10767
10768 #[cfg(unix)]
10780 #[test]
10781 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
10782 use std::os::unix::fs::PermissionsExt as _;
10783
10784 if unsafe { libc::geteuid() } == 0 {
10788 return;
10789 }
10790
10791 let host = Host::new();
10792 let operations = host.operations();
10793 operations
10794 .install(&host.request(StartMode::Boot))
10795 .expect("an install");
10796 assert!(
10797 operations.status().expect("a status").is_healthy(),
10798 "the discriminator: healthy before the record is made unreadable"
10799 );
10800
10801 let record = InstallRecord::path(&host.paths);
10802 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
10803 .expect("the mode is applied");
10804
10805 let status = operations
10806 .status()
10807 .expect("a record this account may not read is reported, not thrown");
10808 assert!(status.is_installed(), "{status}");
10809 assert!(!status.is_healthy(), "{status}");
10810
10811 let printed = status.to_string();
10812 assert!(
10813 printed.contains("this account may not read it"),
10814 "the operator is told which of the two states this is: {printed}"
10815 );
10816 assert!(
10817 !printed.contains("there is no install record"),
10818 "a record that is there and unreadable is not a record that is missing, and the \
10819 missing one's remedy starts with `service uninstall`: {printed}"
10820 );
10821
10822 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
10825 .expect("the mode is restored");
10826 }
10827
10828 #[test]
10829 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
10830 let host = Host::new();
10831 let operations = host.operations();
10832 operations
10833 .install(&host.request(StartMode::Boot))
10834 .expect("an install");
10835
10836 assert!(
10839 operations.status().expect("a status").is_healthy(),
10840 "the freshly installed host must be healthy"
10841 );
10842
10843 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
10844
10845 let status = operations.status().expect("a status");
10846 assert!(!status.is_healthy(), "{status}");
10847 assert!(
10848 status
10849 .problems()
10850 .iter()
10851 .any(|problem| problem.subject == "binary"),
10852 "{status}"
10853 );
10854 assert!(status.to_string().contains("STALE"), "{status}");
10855 }
10856
10857 #[test]
10858 fn status_reports_a_registration_that_would_not_start_at_boot() {
10859 let host = Host::new();
10860 let operations = host.operations();
10861 operations
10862 .install(&host.request(StartMode::Boot))
10863 .expect("an install");
10864 assert!(operations.status().expect("a status").is_healthy());
10865
10866 host.controls.edit("runner-manager", |registration| {
10867 registration.starts_automatically = false;
10868 });
10869
10870 let status = operations.status().expect("a status");
10871 assert!(!status.is_healthy(), "{status}");
10872 assert!(
10873 status
10874 .problems()
10875 .iter()
10876 .any(|problem| problem.detail.contains("after a reboot")),
10877 "{status}"
10878 );
10879 }
10880
10881 #[test]
10882 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
10883 let host = Host::new();
10884 let operations = host.operations();
10885 operations
10886 .install(&host.request(StartMode::Login))
10887 .expect("install and immediate start");
10888
10889 assert!(
10890 operations.status().expect("running status").is_running(),
10891 "install must not wait for the next login trigger"
10892 );
10893 operations.stop().expect("stop the registration");
10894 let status = operations.status().expect("stopped status");
10895 assert!(!status.is_healthy(), "{status}");
10896 assert!(
10897 status
10898 .problems()
10899 .iter()
10900 .any(|problem| problem.subject == "runtime"),
10901 "{status}"
10902 );
10903 let rendered = status.to_string();
10904 assert!(
10905 rendered.contains("runner-manager service install --start-at login"),
10906 "{rendered}"
10907 );
10908 assert!(
10909 !rendered.contains("runner-manager service start"),
10910 "{rendered}"
10911 );
10912 }
10913
10914 #[test]
10915 fn status_reports_a_restart_policy_something_else_edited() {
10916 let host = Host::new();
10917 let operations = host.operations();
10918 operations
10919 .install(&host.request(StartMode::Boot))
10920 .expect("an install");
10921 assert!(operations.status().expect("a status").is_healthy());
10922
10923 host.controls.edit("runner-manager", |registration| {
10924 registration.restart_delay = Some(Duration::from_secs(1));
10925 });
10926
10927 let status = operations.status().expect("a status");
10928 assert!(!status.is_healthy(), "{status}");
10929 assert!(
10930 status
10931 .problems()
10932 .iter()
10933 .any(|problem| problem.subject == "restart policy"),
10934 "{status}"
10935 );
10936 }
10937
10938 #[test]
10939 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
10940 let host = Host::new();
10941 let operations = host.operations();
10942 operations
10943 .install(&host.request(StartMode::Boot))
10944 .expect("an install");
10945 let other = host.binary.with_file_name("someone-elses.exe");
10946 std::fs::write(&other, b"x").expect("writable");
10947
10948 host.controls.edit("runner-manager", |registration| {
10949 registration.command_line = quote_argument(&other.to_string_lossy());
10950 });
10951
10952 let status = operations.status().expect("a status");
10953 assert!(!status.is_healthy(), "{status}");
10954 assert!(
10955 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
10956 "{status}"
10957 );
10958 }
10959
10960 #[test]
10961 fn status_reports_a_record_no_service_manager_knows_about() {
10962 let host = Host::new();
10963 let operations = host.operations();
10964 operations
10965 .install(&host.request(StartMode::Boot))
10966 .expect("an install");
10967 for mode in [StartMode::Boot, StartMode::Login] {
10969 host.controls
10970 .control(mode)
10971 .expect("a control")
10972 .uninstall(&ServiceIdentity::product())
10973 .expect("removed");
10974 }
10975
10976 let status = operations.status().expect("a status");
10977 assert!(!status.is_healthy(), "{status}");
10978 assert!(
10979 status
10980 .problems()
10981 .iter()
10982 .any(|problem| problem.subject == "registration"),
10983 "{status}"
10984 );
10985 }
10986
10987 #[test]
10988 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
10989 let host = Host::new();
10990 let status = host.operations().status().expect("a status");
10991 assert!(!status.is_installed());
10992 assert!(
10993 status.is_healthy(),
10994 "a host that never installed the service has no fault to report: {status}"
10995 );
10996 assert!(status.to_string().contains("installed"), "{status}");
10997 }
10998
10999 #[test]
11000 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
11001 let host = Host::new();
11002 let operations = host.operations();
11003 operations
11004 .install(&host.request(StartMode::Login))
11005 .expect("an install at login");
11006 let status = operations.status().expect("a status");
11007 assert!(
11008 status
11009 .notes()
11010 .iter()
11011 .any(|note| note.contains("does not run until the operator signs in")),
11012 "05-infrastructure.md requires `service status` to say so: {status}"
11013 );
11014 }
11015
11016 #[test]
11019 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
11020 use crate::unattended_login::{AutoLogin, UnattendedLogin};
11021 let automatic = UnattendedLogin {
11022 auto_login: AutoLogin::As("ivan".into()),
11023 filevault_on: Some(false),
11024 };
11025 assert_eq!(
11026 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
11027 None
11028 );
11029
11030 let off = UnattendedLogin {
11031 auto_login: AutoLogin::Off,
11032 filevault_on: Some(false),
11033 };
11034 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
11035 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
11036 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
11037
11038 let locked = UnattendedLogin {
11039 auto_login: AutoLogin::As("ivan".into()),
11040 filevault_on: Some(true),
11041 };
11042 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
11043 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
11044
11045 let unread = UnattendedLogin {
11048 auto_login: AutoLogin::As("ivan".into()),
11049 filevault_on: None,
11050 };
11051 assert_eq!(
11052 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
11053 None
11054 );
11055
11056 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
11058 assert!(
11059 gap.reason
11060 .contains("does not run until the operator signs in")
11061 );
11062 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
11063 }
11064
11065 #[test]
11068 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
11069 let host = Host::new();
11070 let operations = host.operations();
11071 operations
11072 .install(&host.request(StartMode::Login))
11073 .expect("an install at login");
11074 let stalled_problem = |operations: &ServiceOperations| {
11075 operations
11076 .status()
11077 .expect("a status")
11078 .problems()
11079 .iter()
11080 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
11081 .map(|problem| problem.detail.clone())
11082 };
11083 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
11084 assert_eq!(stalled_problem(&operations), None, "beating");
11085
11086 crate::daemon_heartbeat::beat(
11087 &operations.paths,
11088 Utc::now() - chrono::Duration::minutes(20),
11089 )
11090 .unwrap();
11091 let detail = stalled_problem(&operations).expect("a stall is a problem");
11092 assert!(detail.contains("has made no progress since"), "{detail}");
11093 assert!(detail.contains("runner-manager service stop"), "{detail}");
11094 }
11095
11096 #[test]
11098 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
11099 let host = Host::new();
11100 let operations = host.operations();
11101 operations
11102 .install(&host.request(StartMode::Login))
11103 .expect("an install at login");
11104 let paths = operations.paths.clone();
11105 crate::host_fitness::record_host_unfit(
11106 &paths,
11107 &[crate::host_fitness::UnfitFinding {
11108 id: "host.runner_root_responsive".into(),
11109 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
11110 remedy: Some("click Allow".into()),
11111 }],
11112 Utc::now(),
11113 )
11114 .unwrap();
11115 let status = operations.status().expect("a status");
11116 assert!(!status.is_healthy(), "{status}");
11117 let problem = status
11118 .problems()
11119 .iter()
11120 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
11121 .expect("the refusal is a problem");
11122 assert!(
11123 problem.detail.contains("/Volumes/NVME/runners"),
11124 "{problem:?}"
11125 );
11126 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
11127
11128 crate::host_fitness::clear_host_unfit(&paths).unwrap();
11131 crate::host_fitness::record_host_unfit(
11132 &paths,
11133 &[],
11134 Utc::now() - chrono::Duration::hours(2),
11135 )
11136 .unwrap();
11137 let status = operations.status().expect("a status");
11138 assert!(
11139 !status
11140 .problems()
11141 .iter()
11142 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
11143 "{status}"
11144 );
11145 }
11146
11147 #[test]
11152 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
11153 let host = Host::new();
11154 let operations = host.operations();
11155 operations
11156 .install(&host.request(StartMode::Login))
11157 .expect("an install at login");
11158 operations.start().expect("a start");
11159 assert!(operations.status().expect("a status").is_running());
11160 assert!(operations.stop().expect("a stop"));
11161 assert!(!operations.status().expect("a status").is_running());
11162 }
11163
11164 #[test]
11165 fn starting_a_host_with_no_registration_is_refused() {
11166 let host = Host::new();
11167 let error = host.operations().start().expect_err("nothing to start");
11168 assert!(
11169 matches!(error, ServiceError::NotInstalled { .. }),
11170 "{error}"
11171 );
11172 }
11173
11174 #[cfg(windows)]
11190 #[test]
11191 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
11192 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
11193
11194 let root = tempfile::tempdir().expect("a temporary directory");
11195 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
11196 .expect("a rooted machine-scoped store");
11197 store
11198 .store(&secrecy::SecretString::from("a stand-in for the token"))
11199 .expect("the store accepts a value");
11200 let protection = store.protection().expect("the DACL can be read back");
11201
11202 assert!(
11203 protection.description().contains(";;;SY)"),
11204 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
11205 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
11206 protection.description()
11207 );
11208 assert_eq!(
11209 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11210 ServiceAccount::LocalSystem,
11211 "and that is the account this installer registers, which is why SY is what matters"
11212 );
11213 assert!(
11214 !protection.readable_by_other_local_users(),
11215 "the same DACL must still exclude ordinary local users: {}",
11216 protection.description()
11217 );
11218
11219 for rejected in [";;;LS)", ";;;NS)"] {
11223 assert!(
11224 !protection.description().contains(rejected),
11225 "if the store ever admitted {rejected}, the least-privilege analysis in \
11226 docs/service-account.md would need redoing: {}",
11227 protection.description()
11228 );
11229 }
11230 }
11231
11232 fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
11237 let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
11238 arguments.extend(ServiceDirectories::of(&host.paths).arguments());
11239 host.request(mode).with_arguments(arguments)
11240 }
11241
11242 fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
11245 ServiceOperations::with_controls(
11246 account.paths.clone(),
11247 ServiceIdentity::product(),
11248 std::sync::Arc::new(machine.controls.clone()),
11249 )
11250 .with_runner_root(account.runner_root.clone())
11251 }
11252
11253 fn a_machine_with_a_stale_second_account() -> (Host, Host) {
11257 let owner = Host::new();
11258 owner
11259 .operations()
11260 .install(&request_with_directories(&owner, StartMode::Boot))
11261 .expect("the owner's boot install");
11262 let earlier = Host::new();
11263 earlier
11264 .operations()
11265 .install(&request_with_directories(&earlier, StartMode::Login))
11266 .expect("the second account's earlier login install");
11267 assert!(
11268 InstallRecord::read(&earlier.paths).unwrap().is_some(),
11269 "the earlier install left its record behind"
11270 );
11271 (owner, earlier)
11272 }
11273
11274 const DESTRUCTIVE_ADVICE: [&str; 4] = [
11277 "service uninstall",
11278 "service install`",
11279 "service install --",
11280 "auth login",
11281 ];
11282
11283 #[test]
11284 fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
11285 let (owner, earlier) = a_machine_with_a_stale_second_account();
11286
11287 let status = second_account(&earlier, &owner).status().expect("a status");
11288
11289 let foreign = status.foreign().expect("the service is the owner's");
11290 assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
11291 assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
11292 assert!(foreign.unreadable.is_none(), "{status}");
11293 assert!(
11294 status.is_healthy(),
11295 "the owner's service is healthy, and a stale record elsewhere is not its fault: \
11296 {status}"
11297 );
11298 for subject in [
11299 "binary",
11300 "start mode",
11301 "secret store",
11302 "record",
11303 "registration",
11304 ] {
11305 assert!(
11306 !status
11307 .problems()
11308 .iter()
11309 .any(|problem| problem.subject == subject),
11310 "{subject} compared the second account's record against the owner's \
11311 registration: {status}"
11312 );
11313 }
11314 let rendered = status.to_string();
11315 for advice in DESTRUCTIVE_ADVICE {
11316 assert!(!rendered.contains(advice), "{advice}: {rendered}");
11317 }
11318 assert!(rendered.contains("another installation's"), "{rendered}");
11319 assert!(
11320 status
11321 .notes()
11322 .iter()
11323 .any(|note| note.contains("unused local configuration")
11324 && note.contains("host forget-local")),
11325 "{status}"
11326 );
11327 assert_eq!(status.start_mode(), Some(StartMode::Boot));
11329 }
11330
11331 #[test]
11332 fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
11333 let (owner, earlier) = a_machine_with_a_stale_second_account();
11334 InstallRecord::remove(&owner.paths).expect("the owner's record removed");
11337
11338 let operations = second_account(&earlier, &owner);
11339 let status = operations.status().expect("a status");
11340 let foreign = status.foreign().expect("the service is the owner's");
11341 assert!(foreign.unreadable.is_some(), "{status}");
11342 assert!(
11343 status.is_healthy(),
11344 "running, and nothing it could judge: {status}"
11345 );
11346 let rendered = status.to_string();
11347 for advice in DESTRUCTIVE_ADVICE {
11348 assert!(!rendered.contains(advice), "{advice}: {rendered}");
11349 }
11350 assert!(rendered.contains("running; installed from"), "{rendered}");
11351 assert!(!rendered.contains("last GitHub contact"), "{rendered}");
11352
11353 owner.operations().stop().expect("stopped");
11355 let stopped = operations.status().expect("a status");
11356 assert!(!stopped.is_healthy(), "{stopped}");
11357 let rendered = stopped.to_string();
11358 for advice in DESTRUCTIVE_ADVICE {
11359 assert!(!rendered.contains(advice), "{advice}: {rendered}");
11360 }
11361 }
11362
11363 #[test]
11364 fn this_accounts_own_registration_still_reports_every_mismatch() {
11365 let host = Host::new();
11366 let operations = host.operations();
11367 operations
11368 .install(&request_with_directories(&host, StartMode::Boot))
11369 .expect("an install");
11370 let other = host.binary.with_file_name("someone-elses");
11371 std::fs::write(&other, b"x").expect("writable");
11372 host.controls.edit("runner-manager", |registration| {
11374 let mut arguments = split_command_line(®istration.command_line);
11375 arguments[0] = other.to_string_lossy().into_owned();
11376 registration.command_line = arguments
11377 .iter()
11378 .map(|argument| quote_argument(argument))
11379 .collect::<Vec<_>>()
11380 .join(" ");
11381 registration.start_mode = StartMode::Login;
11382 });
11383
11384 let status = operations.status().expect("a status");
11385 assert!(status.foreign().is_none(), "{status}");
11386 assert!(
11387 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
11388 "{status}"
11389 );
11390 assert!(
11391 status
11392 .problems()
11393 .iter()
11394 .any(|problem| problem.subject == "start mode"),
11395 "{status}"
11396 );
11397 assert!(status.to_string().contains("service uninstall"), "{status}");
11398 }
11399
11400 #[test]
11401 fn directories_read_back_from_a_command_line_with_spaces() {
11402 let root = Path::new("/Users/someone/Library/Application Support/runner manager");
11403 let directories = ServiceDirectories {
11404 config: root.join("config"),
11405 state: root.join("data").join("state"),
11406 runtime: root.join("data").join("runtime"),
11407 logs: root.join("data").join("logs"),
11408 };
11409 let mut command_line = quote_argument("/usr/local/bin/runner-manager");
11410 for argument in ["daemon", "run"]
11411 .map(OsString::from)
11412 .into_iter()
11413 .chain(directories.arguments())
11414 {
11415 command_line.push(' ');
11416 command_line.push_str("e_argument(&argument.to_string_lossy()));
11417 }
11418 assert_eq!(
11419 ServiceDirectories::from_command_line(&command_line),
11420 Some(directories.clone())
11421 );
11422 assert_eq!(directories.root(), root);
11423 assert_eq!(
11424 ServiceDirectories::from_command_line("runner-manager daemon run"),
11425 None,
11426 "a registration that names no directories cannot be judged foreign"
11427 );
11428 }
11429
11430 #[test]
11431 fn the_owner_of_a_data_root_is_read_from_its_path() {
11432 let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
11433 let home = users.join("ivand");
11434 assert_eq!(
11435 data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
11436 Some("yuriv")
11437 );
11438 assert_eq!(
11439 data_root_owner(
11440 Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
11441 Some(&home)
11442 ),
11443 None
11444 );
11445 if cfg!(windows) {
11446 assert_eq!(
11447 data_root_owner(
11448 Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
11449 Some(&home)
11450 )
11451 .as_deref(),
11452 Some("LocalSystem")
11453 );
11454 } else {
11455 assert_eq!(
11456 data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
11457 Some("root")
11458 );
11459 }
11460 }
11461
11462 #[test]
11463 fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
11464 let (owner, earlier) = a_machine_with_a_stale_second_account();
11465 let owner_before = snapshot(&[owner._root.path()]);
11466 let config = earlier.paths.config_dir().to_path_buf();
11467 let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
11468
11469 let forgotten = second_account(&earlier, &owner)
11470 .forget_local(Utc::now())
11471 .expect("archived");
11472
11473 assert_eq!(
11474 snapshot(&[owner._root.path()]),
11475 owner_before,
11476 "the owner's directories must not change by one byte"
11477 );
11478 let archive = forgotten.archive.expect("something was archived");
11479 assert!(archive.starts_with(&config), "{}", archive.display());
11480 assert_eq!(
11481 std::fs::read(archive.join(RECORD_FILE)).unwrap(),
11482 record,
11483 "moved, not rewritten or deleted"
11484 );
11485 assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
11486 assert_eq!(
11487 forgotten.service_root,
11488 Some(ServiceDirectories::of(&owner.paths).root())
11489 );
11490 let status = second_account(&earlier, &owner).status().expect("status");
11492 assert!(
11493 !status
11494 .notes()
11495 .iter()
11496 .any(|note| note.contains("unused local configuration")),
11497 "{status}"
11498 );
11499 }
11500
11501 #[test]
11502 fn forget_local_refuses_a_configuration_a_service_runs_against() {
11503 let (owner, earlier) = a_machine_with_a_stale_second_account();
11504 let before = snapshot(&[owner._root.path()]);
11505 let refused = owner.operations().forget_local(Utc::now());
11506 assert!(
11507 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
11508 "{refused:?}"
11509 );
11510 assert_eq!(snapshot(&[owner._root.path()]), before);
11511
11512 let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
11514 .expect("the agent's lock");
11515 let refused = second_account(&earlier, &owner).forget_local(Utc::now());
11516 assert!(
11517 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
11518 "{refused:?}"
11519 );
11520 assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
11521 }
11522
11523 #[test]
11524 fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
11525 let (owner, earlier) = a_machine_with_a_stale_second_account();
11526 owner.operations().stop().expect("stopped");
11529 record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
11530 record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
11531
11532 let status = second_account(&earlier, &owner).status().expect("a status");
11533 assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
11534 let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
11535 for subject in [
11536 "runtime",
11537 "github credential",
11538 CREDENTIAL_UNREADABLE_SUBJECT,
11539 ] {
11540 assert!(subjects.contains(&subject), "{subject}: {status}");
11541 }
11542 let rendered = status.to_string();
11543 for advice in DESTRUCTIVE_ADVICE {
11544 assert!(!rendered.contains(advice), "{advice}: {rendered}");
11545 }
11546 for problem in status.problems() {
11547 assert!(problem.detail.contains("Act on it"), "{problem}");
11548 }
11549 }
11550
11551 #[test]
11552 fn no_command_of_this_account_changes_another_installations_service() {
11553 let (owner, earlier) = a_machine_with_a_stale_second_account();
11554 let operations = second_account(&earlier, &owner);
11555 let foreign = |result: Result<(), ServiceError>| {
11556 assert!(
11557 matches!(result, Err(ServiceError::ForeignInstallation { .. })),
11558 "{result:?}"
11559 );
11560 };
11561 foreign(
11562 operations
11563 .install(&request_with_directories(&earlier, StartMode::Boot))
11564 .map(drop),
11565 );
11566 foreign(operations.uninstall().map(drop));
11567 foreign(operations.set_start_mode(StartMode::Boot).map(drop));
11568 foreign(operations.start());
11569 foreign(operations.stop().map(drop));
11570
11571 let registration = owner
11572 .controls
11573 .control(StartMode::Boot)
11574 .unwrap()
11575 .query(&ServiceIdentity::product())
11576 .unwrap()
11577 .expect("the owner's registration is still there");
11578 assert_eq!(
11579 ServiceDirectories::from_command_line(®istration.command_line),
11580 Some(ServiceDirectories::of(&owner.paths)),
11581 "and still runs against the owner's directories"
11582 );
11583 assert!(registration.running, "and was never stopped");
11584 assert!(
11585 InstallRecord::read(&earlier.paths).unwrap().is_some(),
11586 "the refused uninstall kept this account's record"
11587 );
11588 }
11589
11590 #[test]
11591 fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
11592 let root = tempfile::tempdir().unwrap();
11595 let config = root.path().join("Application Support");
11596 let paths = AppPaths::from_directories(
11597 &config,
11598 config.join("state"),
11599 config.join("runtime"),
11600 config.join("logs"),
11601 );
11602 paths.create_all().unwrap();
11603 std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
11604 std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
11605 let operations = ServiceOperations::with_controls(
11606 paths.clone(),
11607 ServiceIdentity::product(),
11608 std::sync::Arc::new(RecordingControls::new()),
11609 );
11610
11611 let forgotten = operations.forget_local(Utc::now()).expect("archived");
11612
11613 assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
11614 for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
11615 assert!(kept.is_dir(), "{} was moved", kept.display());
11616 }
11617 assert!(paths.logs_dir().join("runner-manager.log").is_file());
11618 }
11619
11620 #[test]
11621 fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
11622 for (detail, diagnosis) in [
11623 (
11624 "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
11625 service install --start-at boot`; if the service manager denies replacement",
11626 "x holds an automatic registration, but the daemon is stopped",
11627 ),
11628 (
11629 "store -- MISMATCH. Run `auth login` again so the token is stored",
11630 "store -- MISMATCH",
11631 ),
11632 (
11633 "unit differs, so the daemon runs under outdated settings. Re-register it with \
11634 `sudo runner-manager service install --start-at boot`",
11635 "unit differs, so the daemon runs under outdated settings",
11636 ),
11637 (
11638 "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
11639 "it starts no runner; the log has it",
11640 ),
11641 (
11642 "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
11643 install`",
11644 "C:\\a\\runner-manager.exe -- STALE: moved",
11645 ),
11646 (
11647 "a new builder: run `runner-manager service install` here, or not",
11648 "the installation's own records report this",
11649 ),
11650 ] {
11651 assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
11652 }
11653 }
11654}