1use std::collections::{BTreeMap, BTreeSet};
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106 Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109pub const SERVICE_NAME: &str = "runner-manager";
115pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126pub const DESCRIPTION: &str =
128 "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137pub const RECORD_FILE: &str = "service.toml";
145
146pub const CONTACT_FILE: &str = "github-contact.toml";
152
153pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158pub const CREDENTIAL_UNREADABLE_FILE: &str = "credential-unreadable.toml";
163
164pub const CREDENTIAL_UNREADABLE_SUBJECT: &str = "stored credential";
167
168pub const LAUNCHES_BLOCKED_SUBJECT: &str = "runner launches";
170
171pub const DAEMON_STALLED_SUBJECT: &str = "daemon progress";
173
174pub const HOST_UNFIT_SUBJECT: &str = "host fitness";
177
178pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
182
183pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
194
195#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ServiceIdentity {
209 name: String,
210 display_name: String,
211 description: String,
212}
213
214impl ServiceIdentity {
215 #[must_use]
217 pub fn product() -> Self {
218 Self {
219 name: SERVICE_NAME.to_string(),
220 display_name: DISPLAY_NAME.to_string(),
221 description: DESCRIPTION.to_string(),
222 }
223 }
224
225 #[must_use]
232 pub fn fixture(tag: &str) -> Self {
233 let tag: String = tag
234 .chars()
235 .map(|c| {
236 if c.is_ascii_alphanumeric() {
237 c.to_ascii_lowercase()
238 } else {
239 '-'
240 }
241 })
242 .collect();
243 let name = format!("{SERVICE_NAME}-selftest-{tag}");
244 Self {
245 display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
246 description: "Disposable fixture created by runner-manager's own installer tests. \
247 Safe to remove."
248 .to_string(),
249 name,
250 }
251 }
252
253 #[must_use]
258 pub fn is_fixture(&self) -> bool {
259 self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
260 }
261
262 #[must_use]
265 pub fn name(&self) -> &str {
266 &self.name
267 }
268
269 #[must_use]
271 pub fn display_name(&self) -> &str {
272 &self.display_name
273 }
274
275 #[must_use]
277 pub fn description(&self) -> &str {
278 &self.description
279 }
280
281 #[must_use]
287 pub fn launchd_label(&self) -> String {
288 format!(
289 "{}.{}.{}",
290 crate::paths::QUALIFIER,
291 crate::paths::ORGANIZATION,
292 self.name
293 )
294 }
295
296 #[must_use]
298 pub fn systemd_unit(&self) -> String {
299 format!("{}.service", self.name)
300 }
301
302 #[must_use]
310 pub fn restart_command(&self, mode: StartMode) -> String {
311 let name = &self.name;
312 if cfg!(windows) {
313 match mode {
314 StartMode::Boot => {
315 format!("Restart-Service -Name {name}")
316 }
317 StartMode::Login => format!("schtasks /End /TN {name}; schtasks /Run /TN {name}"),
318 }
319 } else if cfg!(target_os = "macos") {
320 let label = self.launchd_label();
321 match mode {
322 StartMode::Boot => format!("sudo launchctl kickstart -k system/{label}"),
323 StartMode::Login => format!("launchctl kickstart -k gui/$(id -u)/{label}"),
324 }
325 } else {
326 let unit = self.systemd_unit();
327 match mode {
328 StartMode::Boot => format!("sudo systemctl restart {unit}"),
329 StartMode::Login => format!("systemctl --user restart {unit}"),
330 }
331 }
332 }
333}
334
335impl fmt::Display for ServiceIdentity {
336 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337 f.write_str(&self.name)
338 }
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub struct RestartPolicy {
356 delay: Duration,
357 reset_after: Duration,
358}
359
360impl RestartPolicy {
361 pub const MIN_DELAY: Duration = Duration::from_secs(1);
368
369 pub const MAX_DELAY: Duration = Duration::from_secs(300);
372
373 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
375
376 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
378
379 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
387 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
388 return Err(ServiceError::RestartDelay {
389 requested_secs: delay.as_secs(),
390 min_secs: Self::MIN_DELAY.as_secs(),
391 max_secs: Self::MAX_DELAY.as_secs(),
392 });
393 }
394 if reset_after <= delay {
395 return Err(ServiceError::RestartResetWindow {
396 reset_secs: reset_after.as_secs(),
397 delay_secs: delay.as_secs(),
398 });
399 }
400 Ok(Self { delay, reset_after })
401 }
402
403 #[must_use]
405 pub const fn delay(&self) -> Duration {
406 self.delay
407 }
408
409 #[must_use]
411 pub const fn reset_after(&self) -> Duration {
412 self.reset_after
413 }
414
415 #[must_use]
432 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
433 match kind {
434 DefinitionKind::WindowsScheduledTask => {
435 let seconds = self.delay.as_secs();
436 let minutes = seconds.div_ceil(60);
437 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
438 }
439 _ => self.delay,
440 }
441 }
442}
443
444impl Default for RestartPolicy {
445 fn default() -> Self {
446 Self {
447 delay: Self::DEFAULT_DELAY,
448 reset_after: Self::DEFAULT_RESET_AFTER,
449 }
450 }
451}
452
453impl fmt::Display for RestartPolicy {
454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455 write!(
456 f,
457 "restart after {}s, failure count resets after {}s",
458 self.delay.as_secs(),
459 self.reset_after.as_secs()
460 )
461 }
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum ServiceAccount {
477 LocalSystem,
480 Root,
483 InvokingUser,
485}
486
487impl ServiceAccount {
488 #[must_use]
497 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
498 match (kind, mode) {
499 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
500 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
501 (_, StartMode::Boot) => Self::Root,
502 }
503 }
504
505 #[must_use]
508 pub const fn for_start_mode(mode: StartMode) -> Self {
509 Self::for_definition(host_definition_kind(mode), mode)
510 }
511
512 #[must_use]
514 pub const fn as_str(&self) -> &'static str {
515 match self {
516 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
517 Self::Root => "root",
518 Self::InvokingUser => "the invoking user",
519 }
520 }
521
522 #[must_use]
529 pub const fn justification(&self) -> &'static str {
530 match self {
531 Self::LocalSystem => {
532 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
533 NetworkService cannot read it, and widening the DACL to reach them would grant \
534 every service on this host read access to the one credential this product holds"
535 }
536 Self::Root => {
537 "a boot-time registration runs outside every login session: on macOS the System \
538 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
539 machine-scoped store is a 0600 file under /var/lib that only root can open \
540 before a session exists"
541 }
542 Self::InvokingUser => {
543 "a login-mode registration reads the user-scoped store, which is deliberately \
544 readable by exactly one account and needs no elevation at all"
545 }
546 }
547 }
548
549 #[must_use]
551 pub const fn needs_elevation(&self) -> bool {
552 matches!(self, Self::LocalSystem | Self::Root)
553 }
554}
555
556impl fmt::Display for ServiceAccount {
557 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558 f.write_str(self.as_str())
559 }
560}
561
562#[derive(Debug, thiserror::Error)]
573pub enum ServiceError {
574 #[error(
578 "this host's data stays under {} for now: {reason}",
579 from.display()
580 )]
581 MoveDeferred {
582 from: PathBuf,
584 reason: String,
586 },
587
588 #[error("cannot install the service while an agent is already running on this host: {source}")]
594 LockHeld {
595 #[source]
597 source: Box<LockError>,
598 },
599
600 #[error(
603 "cannot tell whether an agent is already running on this host, so the service was not \
604 installed: {source}. Fix the reported problem with the state directory and try again."
605 )]
606 LockUnreadable {
607 #[source]
609 source: Box<LockError>,
610 },
611
612 #[error(
620 "this registration would run jobs under the default runner root, and that root could \
621 not be prepared, so nothing was registered: {source}"
622 )]
623 RunnerRoot {
624 #[source]
626 source: Box<RootAccessError>,
627 },
628
629 #[error(
631 "cannot resolve the absolute path of this executable, so there is nothing to register: \
632 {detail}. Run the installer from the installed binary rather than through a shell \
633 function or a wrapper that replaces argv[0]."
634 )]
635 BinaryPath {
636 detail: String,
638 },
639
640 #[error(
642 "{} is not a file, so registering it would create a service that cannot start. Install \
643 the product first, then run `service install` from the installed binary.",
644 path.display()
645 )]
646 BinaryMissing {
647 path: PathBuf,
649 },
650
651 #[error(
653 "a restart delay of {requested_secs}s is outside the supported range \
654 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
655 value, so `service status` would report a delay that is not the one in force."
656 )]
657 RestartDelay {
658 requested_secs: u64,
660 min_secs: u64,
662 max_secs: u64,
664 },
665
666 #[error(
668 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
669 restart delay, so the count would reset between every pair of restarts and no \
670 start limit could ever apply."
671 )]
672 RestartResetWindow {
673 reset_secs: u64,
675 delay_secs: u64,
677 },
678
679 #[error(
685 "{name} is already registered to start at {existing}, and this asks for {requested}. \
686 Changing the start mode moves the registration between two different service \
687 managers, so it is not something an install does by itself: run `service uninstall` \
688 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
689 switches it in place and keeps the registration running throughout."
690 )]
691 AlreadyInstalled {
692 name: String,
694 existing: StartMode,
696 requested: StartMode,
698 },
699
700 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
702 NotInstalled {
703 name: String,
705 operation: &'static str,
707 },
708
709 #[error("cannot {operation} the service record {}: {detail}", path.display())]
711 Record {
712 operation: &'static str,
714 path: PathBuf,
716 detail: String,
718 },
719
720 #[error(
722 "the service record {} was not written by this product, or was written by a version \
723 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
724 neither touches configuration, secrets, or the cache.",
725 path.display()
726 )]
727 RecordUnreadable {
728 path: PathBuf,
730 detail: String,
732 },
733
734 #[error(
748 "the service record {} is there and this account may not read it: {detail}. It was \
749 written by whichever account installed the service -- on a boot-mode host, `sudo \
750 service install`. Running `service install` again rewrites it readable; until then \
751 only what the service manager itself reports is available.",
752 path.display()
753 )]
754 RecordNotPermitted {
755 path: PathBuf,
757 detail: String,
759 },
760
761 #[error("cannot prepare this host's application-data directories: {source}")]
763 Paths {
764 #[source]
766 source: Box<crate::paths::PathsError>,
767 },
768
769 #[error("cannot {operation} {name} through {manager}: {detail}")]
771 Control {
772 operation: &'static str,
774 name: String,
776 manager: &'static str,
778 detail: String,
780 },
781
782 #[error(
784 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
785 Inspect `service status` before retrying."
786 )]
787 Rollback {
788 operation: &'static str,
790 name: String,
792 cause: String,
794 rollback: String,
796 },
797
798 #[error(
802 "this machine's {name} service was installed from {installed_by}, not from this \
803 account's data root, so this account does not {operation} it: doing so would \
804 re-register or remove that installation's service with this account's directories. \
805 Do it {act_as}. If that account no longer exists, remove the registration with the \
806 service manager itself first."
807 )]
808 ForeignInstallation {
809 name: String,
811 operation: &'static str,
813 installed_by: String,
815 act_as: String,
817 },
818
819 #[error("this account's local configuration is in use, so nothing was archived: {detail}.")]
822 LocalConfigurationInUse {
823 detail: String,
825 },
826
827 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
829 NeedsElevation {
830 operation: &'static str,
832 name: String,
834 detail: String,
836 remedy: &'static str,
838 },
839}
840
841#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853pub struct ServiceDirectories {
854 pub config: PathBuf,
856 pub state: PathBuf,
858 pub runtime: PathBuf,
860 pub logs: PathBuf,
862}
863
864impl ServiceDirectories {
865 #[must_use]
867 pub fn of(paths: &AppPaths) -> Self {
868 Self {
869 config: paths.config_dir().to_path_buf(),
870 state: paths.state_dir().to_path_buf(),
871 runtime: paths.runtime_dir().to_path_buf(),
872 logs: paths.logs_dir().to_path_buf(),
873 }
874 }
875
876 #[must_use]
878 pub fn all(&self) -> [&Path; 4] {
879 [
880 self.config.as_path(),
881 self.state.as_path(),
882 self.runtime.as_path(),
883 self.logs.as_path(),
884 ]
885 }
886
887 #[must_use]
890 pub fn log_file(&self) -> PathBuf {
891 self.logs.join(LOG_FILE_STEM)
892 }
893
894 #[must_use]
897 pub fn arguments(&self) -> [OsString; 8] {
898 let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS.map(OsString::from);
899 [
900 config,
901 self.config.clone().into_os_string(),
902 state,
903 self.state.clone().into_os_string(),
904 runtime,
905 self.runtime.clone().into_os_string(),
906 logs,
907 self.logs.clone().into_os_string(),
908 ]
909 }
910
911 #[must_use]
918 pub fn from_command_line(command_line: &str) -> Option<Self> {
919 let arguments = split_command_line(command_line);
920 let value = |option: &str| {
921 let at = arguments.iter().position(|argument| argument == option)?;
922 arguments.get(at + 1).map(PathBuf::from)
923 };
924 let [config, state, runtime, logs] = SERVICE_DIRECTORY_OPTIONS;
925 Some(Self {
926 config: value(config)?,
927 state: value(state)?,
928 runtime: value(runtime)?,
929 logs: value(logs)?,
930 })
931 }
932
933 #[must_use]
938 pub fn root(&self) -> PathBuf {
939 if *self == Self::of(&AppPaths::system()) {
940 return PathBuf::from(crate::paths::SYSTEM_DATA_ROOT);
941 }
942 let mut shared: Vec<std::path::Component<'_>> = self.config.components().collect();
943 for other in [&self.state, &self.runtime, &self.logs] {
944 let common = shared
945 .iter()
946 .zip(other.components())
947 .take_while(|(left, right)| **left == *right)
948 .count();
949 shared.truncate(common);
950 }
951 if shared.is_empty() {
952 self.config.clone()
953 } else {
954 shared.iter().collect()
955 }
956 }
957
958 #[must_use]
961 pub fn paths(&self) -> AppPaths {
962 AppPaths::from_directories(&self.config, &self.state, &self.runtime, &self.logs)
963 }
964}
965
966pub const SERVICE_DIRECTORY_OPTIONS: [&str; 4] = [
972 "--service-config-dir",
973 "--service-state-dir",
974 "--service-runtime-dir",
975 "--service-logs-dir",
976];
977
978#[derive(Debug, Clone, PartialEq, Eq)]
990pub struct InstallRequest {
991 start_mode: StartMode,
992 binary: Option<PathBuf>,
993 source_binary: Option<PathBuf>,
994 arguments: Vec<OsString>,
995 restart: RestartPolicy,
996 on_demand: bool,
997 protected: Vec<PathBuf>,
998}
999
1000impl InstallRequest {
1001 #[must_use]
1003 pub fn new(start_mode: StartMode) -> Self {
1004 Self {
1005 start_mode,
1006 binary: None,
1007 source_binary: None,
1008 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1009 restart: RestartPolicy::default(),
1010 on_demand: false,
1011 protected: Vec::new(),
1012 }
1013 }
1014
1015 #[must_use]
1018 pub fn protecting(mut self, paths: Vec<PathBuf>) -> Self {
1019 self.protected = paths;
1020 self
1021 }
1022
1023 #[must_use]
1033 pub const fn started_on_demand(mut self) -> Self {
1034 self.on_demand = true;
1035 self
1036 }
1037
1038 #[must_use]
1045 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
1046 self.binary = Some(binary.into());
1047 self
1048 }
1049
1050 #[must_use]
1067 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
1068 self.source_binary = Some(source.into());
1069 self
1070 }
1071
1072 #[must_use]
1074 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1075 where
1076 I: IntoIterator<Item = S>,
1077 S: Into<OsString>,
1078 {
1079 self.arguments = arguments.into_iter().map(Into::into).collect();
1080 self
1081 }
1082
1083 #[must_use]
1086 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1087 self.restart = restart;
1088 self
1089 }
1090
1091 #[must_use]
1093 pub const fn start_mode(&self) -> StartMode {
1094 self.start_mode
1095 }
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Eq)]
1105pub struct InstallPlan {
1106 identity: ServiceIdentity,
1107 start_mode: StartMode,
1108 binary: PathBuf,
1109 source_binary: Option<PathBuf>,
1110 arguments: Vec<OsString>,
1111 account: ServiceAccount,
1112 restart: RestartPolicy,
1113 directories: ServiceDirectories,
1114 secret_guard: Option<PathBuf>,
1115 on_demand: bool,
1116 protected: Vec<PathBuf>,
1117}
1118
1119impl InstallPlan {
1120 pub fn resolve(
1128 identity: ServiceIdentity,
1129 request: &InstallRequest,
1130 directories: ServiceDirectories,
1131 ) -> Result<Self, ServiceError> {
1132 let binary = match &request.binary {
1133 Some(named) => absolute(named)?,
1134 None => running_executable()?,
1135 };
1136 if !binary.is_file() {
1137 return Err(ServiceError::BinaryMissing { path: binary });
1138 }
1139 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
1144 .ok()
1145 .map(|store| store.guard());
1146 Ok(Self {
1147 identity,
1148 start_mode: request.start_mode,
1149 binary,
1150 source_binary: request.source_binary.clone(),
1151 arguments: request.arguments.clone(),
1152 account: ServiceAccount::for_start_mode(request.start_mode),
1153 restart: request.restart,
1154 directories,
1155 secret_guard,
1156 on_demand: request.on_demand,
1157 protected: request.protected.clone(),
1158 })
1159 }
1160
1161 #[must_use]
1168 pub fn unchecked(
1169 identity: ServiceIdentity,
1170 start_mode: StartMode,
1171 binary: impl Into<PathBuf>,
1172 directories: ServiceDirectories,
1173 ) -> Self {
1174 Self {
1175 identity,
1176 start_mode,
1177 binary: binary.into(),
1178 source_binary: None,
1179 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
1180 account: ServiceAccount::for_start_mode(start_mode),
1181 restart: RestartPolicy::default(),
1182 directories,
1183 secret_guard: None,
1184 on_demand: false,
1185 protected: Vec::new(),
1186 }
1187 }
1188
1189 #[must_use]
1191 pub fn with_protected_paths(mut self, paths: Vec<PathBuf>) -> Self {
1192 self.protected = paths;
1193 self
1194 }
1195
1196 #[must_use]
1198 pub fn protected_paths(&self) -> &[PathBuf] {
1199 &self.protected
1200 }
1201
1202 #[must_use]
1205 pub const fn started_on_demand(mut self) -> Self {
1206 self.on_demand = true;
1207 self
1208 }
1209
1210 #[must_use]
1212 pub const fn is_on_demand(&self) -> bool {
1213 self.on_demand
1214 }
1215
1216 #[must_use]
1223 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1224 self.secret_guard = Some(guard.into());
1225 self
1226 }
1227
1228 #[must_use]
1230 pub fn secret_guard(&self) -> Option<&Path> {
1231 self.secret_guard.as_deref()
1232 }
1233
1234 #[must_use]
1236 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1237 self.restart = restart;
1238 self
1239 }
1240
1241 #[must_use]
1243 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1244 where
1245 I: IntoIterator<Item = S>,
1246 S: Into<OsString>,
1247 {
1248 self.arguments = arguments.into_iter().map(Into::into).collect();
1249 self
1250 }
1251
1252 #[must_use]
1254 pub const fn identity(&self) -> &ServiceIdentity {
1255 &self.identity
1256 }
1257
1258 #[must_use]
1260 pub const fn start_mode(&self) -> StartMode {
1261 self.start_mode
1262 }
1263
1264 #[must_use]
1266 pub fn binary(&self) -> &Path {
1267 &self.binary
1268 }
1269
1270 #[must_use]
1276 pub fn source_binary(&self) -> Option<&Path> {
1277 self.source_binary.as_deref()
1278 }
1279
1280 #[must_use]
1282 pub fn arguments(&self) -> &[OsString] {
1283 &self.arguments
1284 }
1285
1286 #[must_use]
1288 pub const fn account(&self) -> &ServiceAccount {
1289 &self.account
1290 }
1291
1292 #[must_use]
1294 pub const fn restart(&self) -> RestartPolicy {
1295 self.restart
1296 }
1297
1298 #[must_use]
1300 pub const fn directories(&self) -> &ServiceDirectories {
1301 &self.directories
1302 }
1303
1304 #[must_use]
1306 pub fn command_line(&self) -> String {
1307 let mut out = quote_argument(&self.binary.to_string_lossy());
1308 for argument in &self.arguments {
1309 out.push(' ');
1310 out.push_str("e_argument(&argument.to_string_lossy()));
1311 }
1312 out
1313 }
1314}
1315
1316fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1324 let reversal = change.revert();
1325 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1326}
1327
1328fn rolled_back<T>(
1341 retained: Option<String>,
1342 rollback: Result<T, ServiceError>,
1343 operation: &'static str,
1344 identity: &ServiceIdentity,
1345 cause: ServiceError,
1346) -> ServiceError {
1347 let left_behind = match (rollback.err(), retained) {
1348 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1349 (Some(rollback), None) => Some(rollback.to_string()),
1350 (None, retained) => retained,
1351 };
1352 match left_behind {
1353 Some(rollback) => ServiceError::Rollback {
1354 operation,
1355 name: identity.name().to_string(),
1356 cause: cause.to_string(),
1357 rollback,
1358 },
1359 None => cause,
1360 }
1361}
1362
1363fn undo_runner_root(
1366 change: &RootAccessChange,
1367 operation: &'static str,
1368 identity: &ServiceIdentity,
1369 cause: ServiceError,
1370) -> ServiceError {
1371 rolled_back(
1372 retained_runner_root(change),
1373 Ok(()),
1374 operation,
1375 identity,
1376 cause,
1377 )
1378}
1379
1380fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1394 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1395 detail: format!("{} could not be made absolute: {error}", path.display()),
1396 })
1397}
1398
1399fn running_executable() -> Result<PathBuf, ServiceError> {
1401 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1402 detail: error.to_string(),
1403 })?;
1404 absolute(&raw)
1405}
1406
1407pub(crate) fn quote_argument(argument: &str) -> String {
1416 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1417 return argument.to_string();
1418 }
1419 let mut out = String::with_capacity(argument.len() + 2);
1420 out.push('"');
1421 let mut backslashes = 0usize;
1422 for c in argument.chars() {
1423 match c {
1424 '\\' => {
1425 backslashes += 1;
1426 out.push('\\');
1427 }
1428 '"' => {
1429 for _ in 0..=backslashes {
1432 out.push('\\');
1433 }
1434 out.push('"');
1435 backslashes = 0;
1436 }
1437 other => {
1438 backslashes = 0;
1439 out.push(other);
1440 }
1441 }
1442 }
1443 for _ in 0..backslashes {
1445 out.push('\\');
1446 }
1447 out.push('"');
1448 out
1449}
1450
1451#[must_use]
1462pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1463 split_command_line(command_line)
1464 .into_iter()
1465 .next()
1466 .filter(|executable| !executable.is_empty())
1467 .map(PathBuf::from)
1468}
1469
1470#[must_use]
1476pub fn split_command_line(command_line: &str) -> Vec<String> {
1477 let mut arguments = Vec::new();
1478 let mut current = String::new();
1479 let mut started = false;
1480 let mut quoted = false;
1481 let mut backslashes = 0usize;
1482 for c in command_line.chars() {
1483 match c {
1484 '\\' => {
1485 backslashes += 1;
1486 started = true;
1487 }
1488 '"' => {
1489 current.extend(std::iter::repeat_n('\\', backslashes / 2));
1490 if backslashes.is_multiple_of(2) {
1491 quoted = !quoted;
1492 } else {
1493 current.push('"');
1494 }
1495 backslashes = 0;
1496 started = true;
1497 }
1498 ' ' | '\t' if !quoted => {
1499 current.extend(std::iter::repeat_n('\\', backslashes));
1500 backslashes = 0;
1501 if started {
1502 arguments.push(std::mem::take(&mut current));
1503 started = false;
1504 }
1505 }
1506 other => {
1507 current.extend(std::iter::repeat_n('\\', backslashes));
1508 backslashes = 0;
1509 current.push(other);
1510 started = true;
1511 }
1512 }
1513 }
1514 current.extend(std::iter::repeat_n('\\', backslashes));
1515 if started {
1516 arguments.push(current);
1517 }
1518 arguments
1519}
1520
1521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1527pub enum DefinitionKind {
1528 WindowsService,
1532 WindowsScheduledTask,
1534 LaunchdPlist,
1536 SystemdUnit,
1538}
1539
1540impl DefinitionKind {
1541 #[must_use]
1543 pub const fn manager(self) -> &'static str {
1544 match self {
1545 Self::WindowsService => "the Windows Service Control Manager",
1546 Self::WindowsScheduledTask => "Windows Task Scheduler",
1547 Self::LaunchdPlist => "launchd",
1548 Self::SystemdUnit => "systemd",
1549 }
1550 }
1551}
1552
1553impl fmt::Display for DefinitionKind {
1554 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1555 f.write_str(self.manager())
1556 }
1557}
1558
1559#[derive(Debug, Clone, PartialEq, Eq)]
1566pub struct ServiceDefinition {
1567 kind: DefinitionKind,
1568 text: String,
1569 install_path: Option<PathBuf>,
1570}
1571
1572impl ServiceDefinition {
1573 #[must_use]
1575 pub const fn kind(&self) -> DefinitionKind {
1576 self.kind
1577 }
1578
1579 #[must_use]
1581 pub fn text(&self) -> &str {
1582 &self.text
1583 }
1584
1585 #[must_use]
1592 pub fn install_path(&self) -> Option<&Path> {
1593 self.install_path.as_deref()
1594 }
1595
1596 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1609 Ok(match host_definition_kind(plan.start_mode()) {
1610 DefinitionKind::WindowsService => Self::windows_service(plan),
1611 DefinitionKind::WindowsScheduledTask => {
1612 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1613 }
1614 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1615 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1616 })
1617 }
1618
1619 #[must_use]
1621 pub fn windows_service(plan: &InstallPlan) -> Self {
1622 Self {
1623 kind: DefinitionKind::WindowsService,
1624 text: windows_service_descriptor(plan),
1625 install_path: None,
1626 }
1627 }
1628
1629 #[must_use]
1631 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1632 Self {
1633 kind: DefinitionKind::WindowsScheduledTask,
1634 text: windows_scheduled_task_xml(plan, principal),
1635 install_path: None,
1636 }
1637 }
1638
1639 #[must_use]
1644 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1645 let file = format!("{}.plist", plan.identity().launchd_label());
1646 let install_path = match plan.start_mode() {
1647 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1648 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1649 };
1650 Self {
1651 kind: DefinitionKind::LaunchdPlist,
1652 text: launchd_plist(plan),
1653 install_path,
1654 }
1655 }
1656
1657 #[must_use]
1665 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1666 Self {
1667 kind,
1668 text: text.into(),
1669 install_path: None,
1670 }
1671 }
1672
1673 #[must_use]
1678 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1679 let file = plan.identity().systemd_unit();
1680 let install_path = match plan.start_mode() {
1681 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1682 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1683 };
1684 Self {
1685 kind: DefinitionKind::SystemdUnit,
1686 text: systemd_unit(plan),
1687 install_path,
1688 }
1689 }
1690}
1691
1692pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1694pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1696pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1698pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1700
1701const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1704
1705pub const START_LIMIT_BURST: u32 = 5;
1713
1714#[must_use]
1735pub fn systemd_unit(plan: &InstallPlan) -> String {
1736 let identity = plan.identity();
1737 let restart = plan.restart();
1738 let directories = plan.directories();
1739
1740 let mut out = String::new();
1741 out.push_str("[Unit]\n");
1742 out.push_str(&format!("Description={}\n", identity.display_name()));
1743 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1744 out.push_str("After=network-online.target\n");
1745 out.push_str("Wants=network-online.target\n");
1746 out.push_str(&format!(
1749 "StartLimitIntervalSec={}\n",
1750 restart.reset_after().as_secs()
1751 ));
1752 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1753
1754 out.push_str("\n[Service]\n");
1755 out.push_str("Type=simple\n");
1756 out.push_str("KillMode=process\n");
1760 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1761 out.push_str(&format!(
1762 "WorkingDirectory={}\n",
1763 directories.state.display()
1764 ));
1765 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1766 out.push_str("Restart=on-failure\n");
1767 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1768
1769 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1770 for directive in systemd_hardening(plan.start_mode()) {
1771 out.push_str(directive);
1772 out.push('\n');
1773 }
1774 let quoted = |paths: &[String]| {
1775 paths
1776 .iter()
1777 .map(|path| quote_argument(path))
1778 .collect::<Vec<_>>()
1779 .join(" ")
1780 };
1781 let permitted = permitted_paths(DefinitionKind::SystemdUnit, plan);
1782 out.push_str(&format!("ReadWritePaths={}\n", quoted(&permitted)));
1783 if plan.start_mode() == StartMode::Boot {
1788 let under_home: Vec<String> = permitted
1789 .iter()
1790 .filter(|path| home_root_of(Path::new(path.as_str())).is_some())
1791 .cloned()
1792 .collect();
1793 if !under_home.is_empty() {
1794 out.push_str(&format!("BindPaths={}\n", quoted(&under_home)));
1795 }
1796 }
1797 if !plan.protected_paths().is_empty() {
1798 out.push_str(&format!(
1799 "InaccessiblePaths={}\n",
1800 inaccessible_entries(plan).join(" ")
1801 ));
1802 }
1803
1804 out.push_str("\n[Install]\n");
1805 out.push_str(match plan.start_mode() {
1806 StartMode::Boot => "WantedBy=multi-user.target\n",
1807 StartMode::Login => "WantedBy=default.target\n",
1808 });
1809 out
1810}
1811
1812const SYSTEMD_HARDENING: [&str; 12] = [
1835 "NoNewPrivileges=yes",
1836 "CapabilityBoundingSet=",
1837 "AmbientCapabilities=",
1838 "PrivateTmp=yes",
1839 "PrivateDevices=yes",
1840 "ProtectSystem=strict",
1841 "ProtectKernelTunables=yes",
1842 "ProtectKernelModules=yes",
1843 "ProtectControlGroups=yes",
1844 "RestrictNamespaces=yes",
1845 "RestrictRealtime=yes",
1846 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1847];
1848
1849pub const BOOT_CAPABILITIES: &str = boot_capabilities!();
1863
1864macro_rules! boot_capabilities {
1867 () => {
1868 "CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_SETGID CAP_SETUID"
1869 };
1870}
1871use boot_capabilities;
1872
1873fn systemd_hardening(mode: StartMode) -> Vec<&'static str> {
1882 let mut directives = SYSTEMD_HARDENING.to_vec();
1883 if mode == StartMode::Boot {
1884 if let Some(directive) = directives
1885 .iter_mut()
1886 .find(|directive| **directive == "CapabilityBoundingSet=")
1887 {
1888 *directive = concat!("CapabilityBoundingSet=", boot_capabilities!());
1889 }
1890 directives.push("ProtectHome=tmpfs");
1891 }
1892 directives
1893}
1894
1895fn inaccessible_entries(plan: &InstallPlan) -> Vec<String> {
1898 plan.protected_paths()
1899 .iter()
1900 .map(|path| quote_argument(&format!("-{}", path.to_string_lossy())))
1901 .collect()
1902}
1903
1904#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1916#[serde(deny_unknown_fields)]
1917pub struct ProtectedPaths {
1918 #[serde(default)]
1919 pub paths: Vec<PathBuf>,
1920}
1921
1922impl ProtectedPaths {
1923 pub const FILE: &'static str = "protected-paths.json";
1925
1926 #[must_use]
1928 pub const fn supported() -> bool {
1929 cfg!(all(unix, not(target_os = "macos")))
1930 }
1931
1932 pub fn load(config_dir: &Path) -> std::io::Result<Self> {
1938 crate::settings_file::load(&config_dir.join(Self::FILE))
1939 }
1940
1941 pub fn save(&self, config_dir: &Path) -> std::io::Result<()> {
1947 crate::settings_file::save(&config_dir.join(Self::FILE), self)
1948 }
1949
1950 #[must_use]
1956 pub fn refusal(path: &Path, own: &[PathBuf]) -> Option<String> {
1957 const SYSTEM: [&str; 16] = [
1958 "/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/opt", "/proc",
1959 "/run", "/sbin", "/sys", "/tmp", "/usr", "/var",
1960 ];
1961 if !path.is_absolute()
1962 || path
1963 .components()
1964 .any(|component| matches!(component, std::path::Component::ParentDir))
1965 {
1966 return Some(format!(
1967 "{} is not an absolute path without `..`",
1968 path.display()
1969 ));
1970 }
1971 if SYSTEM.iter().any(|system| path == Path::new(system)) {
1972 return Some(format!(
1973 "{} is one every job needs to run; protect a directory inside it instead",
1974 path.display()
1975 ));
1976 }
1977 if let Some(needed) = own
1978 .iter()
1979 .find(|needed| needed.starts_with(path) || path.starts_with(needed))
1980 {
1981 return Some(format!(
1982 "{} holds or lies inside {}, which the service itself uses",
1983 path.display(),
1984 needed.display()
1985 ));
1986 }
1987 None
1988 }
1989}
1990
1991const SYSTEMD_HOME_ROOTS: [&str; 3] = ["/home", "/root", "/run/user"];
1994
1995fn home_root_of(path: &Path) -> Option<&'static str> {
1997 SYSTEMD_HOME_ROOTS
1998 .iter()
1999 .copied()
2000 .find(|root| path.starts_with(root))
2001}
2002
2003#[cfg(any(test, target_os = "linux"))]
2007fn operator_drop_in_naming(directory: &Path, trees: &[&PathBuf]) -> Option<PathBuf> {
2008 let mut drop_ins: Vec<PathBuf> = std::fs::read_dir(directory)
2009 .ok()?
2010 .flatten()
2011 .map(|entry| entry.path())
2012 .filter(|path| path.extension() == Some("conf".as_ref()))
2013 .collect();
2014 drop_ins.sort();
2015 drop_ins.into_iter().find(|path| {
2016 std::fs::read_to_string(path).is_ok_and(|text| {
2017 !text.contains("Managed by runner-manager")
2018 && trees
2019 .iter()
2020 .any(|tree| text.contains(tree.to_string_lossy().as_ref()))
2021 })
2022 })
2023}
2024
2025#[cfg(any(all(test, unix), target_os = "linux"))]
2028fn set_aside(path: &Path, stamp: &str) -> std::io::Result<()> {
2029 let occupied = match std::fs::read_dir(path) {
2030 Ok(mut entries) => entries.next().is_some(),
2031 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
2032 Err(error) => return Err(error),
2033 };
2034 if occupied {
2035 let name = path.file_name().map_or_else(
2036 || std::borrow::Cow::Borrowed("directory"),
2037 |name| name.to_string_lossy(),
2038 );
2039 std::fs::rename(
2040 path,
2041 path.with_file_name(format!("{name}.before-move-{stamp}")),
2042 )?;
2043 }
2044 Ok(())
2045}
2046
2047#[cfg(any(all(test, unix), target_os = "linux"))]
2051fn copy_tree(from: &Path, to: &Path, skip: Option<&str>) -> std::io::Result<()> {
2052 use std::os::unix::fs::DirBuilderExt as _;
2053
2054 std::fs::DirBuilder::new()
2055 .recursive(true)
2056 .mode(0o700)
2057 .create(to)?;
2058 for entry in std::fs::read_dir(from)? {
2059 let entry = entry?;
2060 if skip.is_some_and(|skip| entry.file_name() == skip) {
2061 continue;
2062 }
2063 let source = entry.path();
2064 let target = to.join(entry.file_name());
2065 let kind = entry.file_type()?;
2066 if kind.is_symlink() {
2067 std::os::unix::fs::symlink(std::fs::read_link(&source)?, &target)?;
2068 } else if kind.is_dir() {
2069 copy_tree(&source, &target, None)?;
2070 } else {
2071 std::fs::copy(&source, &target)?;
2072 }
2073 }
2074 Ok(())
2075}
2076
2077#[cfg(any(test, all(unix, not(target_os = "macos"))))]
2084fn boot_directory_refusal(path: &Path, owner: Option<u32>) -> Option<String> {
2085 if let Some(root) = home_root_of(path) {
2086 return Some(format!(
2087 "{} is under {root}, which a boot service cannot see",
2088 path.display()
2089 ));
2090 }
2091 match owner {
2092 Some(0) => None,
2093 Some(uid) => Some(format!(
2094 "{} belongs to uid {uid}, and a boot service runs as root with no capability \
2095 to open another account's directory",
2096 path.display()
2097 )),
2098 None => Some(format!("{} cannot be inspected", path.display())),
2099 }
2100}
2101
2102pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
2126
2127#[must_use]
2134pub fn launchd_plist(plan: &InstallPlan) -> String {
2135 let identity = plan.identity();
2136 let directories = plan.directories();
2137 let mut out = String::new();
2138 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
2139 out.push_str(
2140 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
2141 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
2142 );
2143 out.push_str("<plist version=\"1.0\">\n<dict>\n");
2144 out.push_str(&plist_string("Label", &identity.launchd_label()));
2145
2146 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
2147 out.push_str(&format!(
2148 " <string>{}</string>\n",
2149 xml_escape(&plan.binary().to_string_lossy())
2150 ));
2151 for argument in plan.arguments() {
2152 out.push_str(&format!(
2153 " <string>{}</string>\n",
2154 xml_escape(&argument.to_string_lossy())
2155 ));
2156 }
2157 out.push_str(" </array>\n");
2158
2159 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
2160 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
2161 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
2162 out.push_str(" </dict>\n");
2163 out.push_str(&format!(
2164 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
2165 plan.restart().delay().as_secs()
2166 ));
2167 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
2168 out.push_str(&plist_string(
2169 "WorkingDirectory",
2170 &directories.state.to_string_lossy(),
2171 ));
2172 out.push_str(&plist_string(
2173 "StandardOutPath",
2174 &directories
2175 .logs
2176 .join("runner-manager.launchd.out.log")
2177 .to_string_lossy(),
2178 ));
2179 out.push_str(&plist_string(
2180 "StandardErrorPath",
2181 &directories
2182 .logs
2183 .join("runner-manager.launchd.err.log")
2184 .to_string_lossy(),
2185 ));
2186
2187 match plan.start_mode() {
2188 StartMode::Boot => {
2189 out.push_str(&plist_string(
2192 "UserName",
2193 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
2194 .as_str(),
2195 ));
2196 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
2198 }
2199 StartMode::Login => {
2200 }
2204 }
2205
2206 out.push_str("</dict>\n</plist>\n");
2207 out
2208}
2209
2210fn plist_string(key: &str, value: &str) -> String {
2212 format!(
2213 " <key>{}</key>\n <string>{}</string>\n",
2214 xml_escape(key),
2215 xml_escape(value)
2216 )
2217}
2218
2219#[derive(Debug, Clone, PartialEq, Eq)]
2227pub struct TaskPrincipal {
2228 user_id: String,
2229}
2230
2231impl TaskPrincipal {
2232 pub fn current() -> Result<Self, ServiceError> {
2240 let user = std::env::var("USERNAME")
2241 .ok()
2242 .filter(|value| !value.trim().is_empty());
2243 let Some(user) = user else {
2244 return Err(ServiceError::Control {
2245 operation: "identify the account for",
2246 name: SERVICE_NAME.to_string(),
2247 manager: "Windows Task Scheduler",
2248 detail: "this session reports no %USERNAME%, so there is no principal to \
2249 register a logon-triggered task for"
2250 .to_string(),
2251 });
2252 };
2253 let domain = std::env::var("USERDOMAIN")
2254 .ok()
2255 .filter(|value| !value.trim().is_empty());
2256 Ok(Self {
2257 user_id: match domain {
2258 Some(domain) => format!("{domain}\\{user}"),
2259 None => user,
2260 },
2261 })
2262 }
2263
2264 #[must_use]
2266 pub fn named(user_id: impl Into<String>) -> Self {
2267 Self {
2268 user_id: user_id.into(),
2269 }
2270 }
2271
2272 #[must_use]
2274 pub fn user_id(&self) -> &str {
2275 &self.user_id
2276 }
2277}
2278
2279#[must_use]
2286pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
2287 let identity = plan.identity();
2288 let user = xml_escape(principal.user_id());
2289 let arguments = plan
2290 .arguments()
2291 .iter()
2292 .map(|argument| quote_argument(&argument.to_string_lossy()))
2293 .collect::<Vec<_>>()
2294 .join(" ");
2295 let mut out = String::new();
2296 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
2297 out.push_str(
2298 "<Task version=\"1.4\" \
2299 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
2300 );
2301 out.push_str(" <RegistrationInfo>\n");
2302 out.push_str(&format!(
2303 " <Description>{}</Description>\n",
2304 xml_escape(identity.description())
2305 ));
2306 out.push_str(&format!(
2307 " <URI>\\{}</URI>\n",
2308 xml_escape(identity.name())
2309 ));
2310 out.push_str(" </RegistrationInfo>\n");
2311
2312 out.push_str(" <Triggers>\n <LogonTrigger>\n");
2313 out.push_str(" <Enabled>true</Enabled>\n");
2314 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2315 out.push_str(" </LogonTrigger>\n </Triggers>\n");
2316
2317 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
2318 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
2319 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
2320 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
2321 out.push_str(" </Principal>\n </Principals>\n");
2322
2323 out.push_str(" <Settings>\n");
2324 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
2328 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
2329 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
2330 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
2331 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
2332 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
2333 out.push_str(" <IdleSettings>\n");
2334 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
2335 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
2336 out.push_str(" </IdleSettings>\n");
2337 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
2338 out.push_str(" <Enabled>true</Enabled>\n");
2339 out.push_str(" <Hidden>false</Hidden>\n");
2340 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
2341 out.push_str(" <WakeToRun>false</WakeToRun>\n");
2342 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
2344 out.push_str(" <Priority>7</Priority>\n");
2345 out.push_str(" <RestartOnFailure>\n");
2346 out.push_str(&format!(
2347 " <Interval>{}</Interval>\n",
2348 iso8601_minutes(
2349 plan.restart()
2350 .effective_delay(DefinitionKind::WindowsScheduledTask)
2351 )
2352 ));
2353 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
2354 out.push_str(" </RestartOnFailure>\n");
2355 out.push_str(" </Settings>\n");
2356
2357 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
2358 out.push_str(&format!(
2359 " <Command>{}</Command>\n",
2360 xml_escape(&plan.binary().to_string_lossy())
2361 ));
2362 if !arguments.is_empty() {
2363 out.push_str(&format!(
2364 " <Arguments>{}</Arguments>\n",
2365 xml_escape(&arguments)
2366 ));
2367 }
2368 out.push_str(&format!(
2369 " <WorkingDirectory>{}</WorkingDirectory>\n",
2370 xml_escape(&plan.directories().state.to_string_lossy())
2371 ));
2372 out.push_str(" </Exec>\n </Actions>\n");
2373 out.push_str("</Task>\n");
2374 out
2375}
2376
2377fn iso8601_minutes(duration: Duration) -> String {
2380 format!("PT{}M", duration.as_secs() / 60)
2381}
2382
2383pub(crate) fn xml_escape(value: &str) -> String {
2391 let mut out = String::with_capacity(value.len());
2392 for c in value.chars() {
2393 match c {
2394 '&' => out.push_str("&"),
2395 '<' => out.push_str("<"),
2396 '>' => out.push_str(">"),
2397 '"' => out.push_str("""),
2398 '\'' => out.push_str("'"),
2399 other => out.push(other),
2400 }
2401 }
2402 out
2403}
2404
2405fn xml_unescape(value: &str) -> String {
2411 value
2412 .replace("<", "<")
2413 .replace(">", ">")
2414 .replace(""", "\"")
2415 .replace("'", "'")
2416 .replace("&", "&")
2417}
2418
2419#[derive(Debug, Clone, PartialEq, Eq)]
2428pub struct WindowsServiceSpec {
2429 pub name: String,
2431 pub display_name: String,
2433 pub description: String,
2435 pub automatic_start: bool,
2437 pub account: Option<String>,
2440 pub command_line: String,
2442 pub restart: RestartPolicy,
2444}
2445
2446#[must_use]
2448pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
2449 WindowsServiceSpec {
2450 name: plan.identity().name().to_string(),
2451 display_name: plan.identity().display_name().to_string(),
2452 description: plan.identity().description().to_string(),
2453 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
2458 account: match ServiceAccount::for_definition(
2459 DefinitionKind::WindowsService,
2460 plan.start_mode(),
2461 ) {
2462 ServiceAccount::LocalSystem => None,
2465 other => Some(other.as_str().to_string()),
2466 },
2467 command_line: plan.command_line(),
2468 restart: plan.restart(),
2469 }
2470}
2471
2472#[must_use]
2475fn windows_service_descriptor(plan: &InstallPlan) -> String {
2476 let spec = windows_service_spec(plan);
2477 let mut out = String::new();
2478 out.push_str("[windows-service]\n");
2479 out.push_str(&format!("Name={}\n", spec.name));
2480 out.push_str(&format!("DisplayName={}\n", spec.display_name));
2481 out.push_str(&format!("Description={}\n", spec.description));
2482 out.push_str("ServiceType=OWN_PROCESS\n");
2486 out.push_str(&format!(
2487 "StartType={}\n",
2488 if spec.automatic_start {
2489 "AutoStart"
2490 } else {
2491 "OnDemand"
2492 }
2493 ));
2494 out.push_str("ErrorControl=Normal\n");
2495 out.push_str(&format!(
2496 "Account={}\n",
2497 spec.account
2498 .as_deref()
2499 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2500 ));
2501 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2502 out.push_str(&format!(
2503 "FailureActionRestartDelaySecs={}\n",
2504 spec.restart.delay().as_secs()
2505 ));
2506 out.push_str(&format!(
2507 "FailureActionsResetPeriodSecs={}\n",
2508 spec.restart.reset_after().as_secs()
2509 ));
2510 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2514 out.push_str(&format!(
2515 "ReadWritePaths={}\n",
2516 plan.directories()
2517 .all()
2518 .iter()
2519 .map(|path| quote_argument(&path.to_string_lossy()))
2520 .collect::<Vec<_>>()
2521 .join(" ")
2522 ));
2523 out
2524}
2525
2526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2532pub enum FindingKind {
2533 Excess,
2536 Shortfall,
2539}
2540
2541impl fmt::Display for FindingKind {
2542 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2543 f.write_str(match self {
2544 Self::Excess => "excess",
2545 Self::Shortfall => "shortfall",
2546 })
2547 }
2548}
2549
2550#[derive(Debug, Clone, PartialEq, Eq)]
2552pub struct PrivilegeFinding {
2553 pub kind: FindingKind,
2555 pub subject: String,
2557 pub detail: String,
2559}
2560
2561impl fmt::Display for PrivilegeFinding {
2562 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2563 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2564 }
2565}
2566
2567#[derive(Debug, Clone, PartialEq, Eq)]
2581pub struct PrivilegeReview {
2582 kind: DefinitionKind,
2583 account: ServiceAccount,
2584 controls: Vec<String>,
2585 findings: Vec<PrivilegeFinding>,
2586}
2587
2588impl PrivilegeReview {
2589 #[must_use]
2591 pub fn is_least_privilege(&self) -> bool {
2592 !self
2593 .findings
2594 .iter()
2595 .any(|finding| finding.kind == FindingKind::Excess)
2596 }
2597
2598 #[must_use]
2600 pub fn findings(&self) -> &[PrivilegeFinding] {
2601 &self.findings
2602 }
2603
2604 #[must_use]
2607 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2608 self.findings
2609 .iter()
2610 .filter(|finding| finding.kind == FindingKind::Excess)
2611 .collect()
2612 }
2613
2614 #[must_use]
2620 pub fn controls(&self) -> &[String] {
2621 &self.controls
2622 }
2623
2624 #[must_use]
2626 pub const fn account(&self) -> &ServiceAccount {
2627 &self.account
2628 }
2629
2630 #[must_use]
2632 pub const fn kind(&self) -> DefinitionKind {
2633 self.kind
2634 }
2635}
2636
2637impl fmt::Display for PrivilegeReview {
2638 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2639 writeln!(
2640 f,
2641 "{} runs as {} ({})",
2642 self.kind,
2643 self.account,
2644 self.account.justification()
2645 )?;
2646 for control in &self.controls {
2647 writeln!(f, " confirmed {control}")?;
2648 }
2649 for finding in &self.findings {
2650 writeln!(f, " {finding}")?;
2651 }
2652 if self.is_least_privilege() {
2653 write!(f, " verdict least privilege")
2654 } else {
2655 write!(
2656 f,
2657 " verdict NOT least privilege: {} excess(es)",
2658 self.excesses().len()
2659 )
2660 }
2661 }
2662}
2663
2664#[must_use]
2670pub fn review_least_privilege(
2671 definition: &ServiceDefinition,
2672 plan: &InstallPlan,
2673) -> PrivilegeReview {
2674 let mut controls = Vec::new();
2675 let mut findings = Vec::new();
2676 match definition.kind() {
2677 DefinitionKind::SystemdUnit => {
2678 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2679 }
2680 DefinitionKind::LaunchdPlist => {
2681 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2682 }
2683 DefinitionKind::WindowsScheduledTask => {
2684 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2685 }
2686 DefinitionKind::WindowsService => {
2687 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2688 }
2689 }
2690 PrivilegeReview {
2691 kind: definition.kind(),
2692 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2697 controls,
2698 findings,
2699 }
2700}
2701
2702fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2704 let mut permitted = plan
2705 .directories()
2706 .all()
2707 .iter()
2708 .map(|path| path.to_string_lossy().into_owned())
2709 .collect::<Vec<_>>();
2710 if kind == DefinitionKind::SystemdUnit
2711 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2712 {
2713 permitted.push(secret_directory.to_string_lossy().into_owned());
2714 }
2715 permitted
2716}
2717
2718fn same_path_text(left: &str, right: &str) -> bool {
2725 if cfg!(windows) {
2726 left.eq_ignore_ascii_case(right)
2727 } else {
2728 left == right
2729 }
2730}
2731
2732fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2735 match kind {
2736 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2737 left.eq_ignore_ascii_case(right)
2738 }
2739 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2740 }
2741}
2742
2743fn review_writable_paths(
2745 kind: DefinitionKind,
2746 subject: &str,
2747 listed: &[String],
2748 plan: &InstallPlan,
2749 controls: &mut Vec<String>,
2750 findings: &mut Vec<PrivilegeFinding>,
2751) {
2752 let permitted = permitted_paths(kind, plan);
2753 for entry in listed {
2754 if !permitted
2755 .iter()
2756 .any(|allowed| same_path_for(kind, allowed, entry))
2757 {
2758 findings.push(PrivilegeFinding {
2759 kind: FindingKind::Excess,
2760 subject: subject.to_string(),
2761 detail: format!(
2762 "{entry} is writable but is not one of this registration's required paths"
2763 ),
2764 });
2765 }
2766 }
2767 for allowed in &permitted {
2768 if !listed
2769 .iter()
2770 .any(|entry| same_path_for(kind, allowed, entry))
2771 {
2772 findings.push(PrivilegeFinding {
2773 kind: FindingKind::Shortfall,
2774 subject: subject.to_string(),
2775 detail: format!(
2776 "{allowed} is one of this registration's directories but is not writable, \
2777 so the daemon cannot use it"
2778 ),
2779 });
2780 }
2781 }
2782 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2783 controls.push(format!(
2784 "{subject} names exactly the required application-data and credential paths"
2785 ));
2786 }
2787}
2788
2789fn review_capabilities(
2792 expected: &str,
2793 wanted: &str,
2794 actual: &str,
2795 controls: &mut Vec<String>,
2796 findings: &mut Vec<PrivilegeFinding>,
2797) {
2798 let subject = "CapabilityBoundingSet";
2799 if actual.trim_start().starts_with('~') {
2801 findings.push(PrivilegeFinding {
2802 kind: FindingKind::Excess,
2803 subject: subject.to_string(),
2804 detail: format!("is `{actual}`, which keeps every capability it does not name"),
2805 });
2806 return;
2807 }
2808 let wanted: BTreeSet<&str> = wanted.split_whitespace().collect();
2809 let actual: BTreeSet<&str> = actual.split_whitespace().collect();
2810 let extra = actual.difference(&wanted).copied().collect::<Vec<_>>();
2811 let missing = wanted.difference(&actual).copied().collect::<Vec<_>>();
2812 if !extra.is_empty() {
2813 findings.push(PrivilegeFinding {
2814 kind: FindingKind::Excess,
2815 subject: subject.to_string(),
2816 detail: format!(
2817 "keeps {}, which the requirement does not ask for",
2818 extra.join(" ")
2819 ),
2820 });
2821 }
2822 if !missing.is_empty() {
2823 findings.push(PrivilegeFinding {
2824 kind: FindingKind::Shortfall,
2825 subject: subject.to_string(),
2826 detail: format!("drops {}, which the daemon needs", missing.join(" ")),
2827 });
2828 }
2829 if extra.is_empty() && missing.is_empty() {
2830 controls.push(expected.to_string());
2831 }
2832}
2833
2834fn review_inbound_surface(
2842 text: &str,
2843 markers: &[(&str, &str)],
2844 controls: &mut Vec<String>,
2845 findings: &mut Vec<PrivilegeFinding>,
2846) {
2847 let mut clean = true;
2848 for (marker, detail) in markers {
2849 if text.contains(marker) {
2850 clean = false;
2851 findings.push(PrivilegeFinding {
2852 kind: FindingKind::Excess,
2853 subject: (*marker).to_string(),
2854 detail: (*detail).to_string(),
2855 });
2856 }
2857 }
2858 if clean {
2859 controls.push(
2860 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2861 );
2862 }
2863}
2864
2865fn review_systemd(
2866 text: &str,
2867 plan: &InstallPlan,
2868 controls: &mut Vec<String>,
2869 findings: &mut Vec<PrivilegeFinding>,
2870) {
2871 let directives = ini_directives(text, "Service");
2872 for expected in systemd_hardening(plan.start_mode()) {
2873 let (key, value) = expected
2874 .split_once('=')
2875 .expect("every hardening directive is written as key=value");
2876 match directives.get(key) {
2877 Some(actual) if key == "CapabilityBoundingSet" => {
2878 review_capabilities(expected, value, actual, controls, findings);
2879 }
2880 Some(actual) if actual == value => controls.push((*expected).to_string()),
2881 Some(actual) => findings.push(PrivilegeFinding {
2882 kind: FindingKind::Excess,
2883 subject: key.to_string(),
2884 detail: format!(
2885 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2886 requirement does not ask for"
2887 ),
2888 }),
2889 None => findings.push(PrivilegeFinding {
2890 kind: FindingKind::Excess,
2891 subject: key.to_string(),
2892 detail: format!(
2893 "is absent, so the unit inherits systemd's default rather than `{value}`"
2894 ),
2895 }),
2896 }
2897 }
2898
2899 match directives.get("ReadWritePaths") {
2900 Some(value) => {
2901 let listed = split_quoted(value);
2902 review_writable_paths(
2903 DefinitionKind::SystemdUnit,
2904 "ReadWritePaths",
2905 &listed,
2906 plan,
2907 controls,
2908 findings,
2909 );
2910 }
2911 None => findings.push(PrivilegeFinding {
2912 kind: FindingKind::Shortfall,
2913 subject: "ReadWritePaths".to_string(),
2914 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2915 .to_string(),
2916 }),
2917 }
2918
2919 if let Some(value) = directives.get("BindPaths") {
2923 let permitted = permitted_paths(DefinitionKind::SystemdUnit, plan);
2924 for entry in split_quoted(value) {
2925 if !permitted
2926 .iter()
2927 .any(|allowed| same_path_for(DefinitionKind::SystemdUnit, allowed, &entry))
2928 {
2929 findings.push(PrivilegeFinding {
2930 kind: FindingKind::Excess,
2931 subject: "BindPaths".to_string(),
2932 detail: format!(
2933 "{entry} is bound into the unit but is not one of this registration's \
2934 required paths"
2935 ),
2936 });
2937 }
2938 }
2939 }
2940
2941 if !plan.protected_paths().is_empty() {
2944 let listed = directives
2945 .get("InaccessiblePaths")
2946 .map(|value| split_quoted(value))
2947 .unwrap_or_default();
2948 let missing = plan
2949 .protected_paths()
2950 .iter()
2951 .filter(|path| {
2952 let entry = format!("-{}", path.to_string_lossy());
2953 !listed.contains(&entry)
2954 })
2955 .map(|path| path.display().to_string())
2956 .collect::<Vec<_>>();
2957 if missing.is_empty() {
2958 controls.push("InaccessiblePaths keeps every protected path from every job".into());
2959 } else {
2960 findings.push(PrivilegeFinding {
2961 kind: FindingKind::Excess,
2962 subject: "InaccessiblePaths".to_string(),
2963 detail: format!("no longer hides {}", missing.join(", ")),
2964 });
2965 }
2966 }
2967
2968 if directives.contains_key("PrivateUsers")
2971 && directives.get("PrivateUsers") == Some(&"no".to_string())
2972 {
2973 findings.push(PrivilegeFinding {
2974 kind: FindingKind::Excess,
2975 subject: "PrivateUsers".to_string(),
2976 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2977 .to_string(),
2978 });
2979 }
2980
2981 review_inbound_surface(
2982 text,
2983 &[
2984 (
2985 "ListenStream=",
2986 "asks systemd to open a listening socket for this service, which \
2987 07-security.md rule 2 forbids the product to have",
2988 ),
2989 (
2990 "ListenDatagram=",
2991 "asks systemd to open a listening socket for this service, which \
2992 07-security.md rule 2 forbids the product to have",
2993 ),
2994 ],
2995 controls,
2996 findings,
2997 );
2998}
2999
3000fn review_launchd(
3001 text: &str,
3002 plan: &InstallPlan,
3003 controls: &mut Vec<String>,
3004 findings: &mut Vec<PrivilegeFinding>,
3005) {
3006 match plist_string_value(text, "ProcessType") {
3010 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
3011 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
3012 }
3013 found => findings.push(PrivilegeFinding {
3014 kind: FindingKind::Shortfall,
3015 subject: "ProcessType".to_string(),
3016 detail: format!(
3017 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
3018 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
3019 when it next starts with no runner; `service status` says how to restart it",
3020 found.map_or_else(
3021 || "absent (launchd's `Standard`)".to_string(),
3022 |value| format!("`{value}`")
3023 )
3024 ),
3025 }),
3026 }
3027
3028 match plan.start_mode() {
3029 StartMode::Boot => {
3030 if plist_bool_value(text, "SessionCreate") == Some(true) {
3031 findings.push(PrivilegeFinding {
3032 kind: FindingKind::Excess,
3033 subject: "SessionCreate".to_string(),
3034 detail: "asks launchd to create a security session for a job that runs \
3035 outside every login session and has no use for one"
3036 .to_string(),
3037 });
3038 } else {
3039 controls.push("SessionCreate is not requested".to_string());
3040 }
3041 match plist_string_value(text, "UserName").as_deref() {
3042 Some("root") => {
3043 controls.push("UserName=root, stated rather than inherited".to_string())
3044 }
3045 Some(other) => findings.push(PrivilegeFinding {
3046 kind: FindingKind::Shortfall,
3047 subject: "UserName".to_string(),
3048 detail: format!(
3049 "is `{other}`, which cannot unlock the System Keychain: \
3050 /var/db/SystemKey is root-only, so the daemon would start and then \
3051 find no credential"
3052 ),
3053 }),
3054 None => findings.push(PrivilegeFinding {
3055 kind: FindingKind::Shortfall,
3056 subject: "UserName".to_string(),
3057 detail: "is absent, so the account is launchd's implicit default and this \
3058 review cannot confirm it"
3059 .to_string(),
3060 }),
3061 }
3062 }
3063 StartMode::Login => {
3064 if let Some(named) = plist_string_value(text, "UserName") {
3065 findings.push(PrivilegeFinding {
3066 kind: FindingKind::Excess,
3067 subject: "UserName".to_string(),
3068 detail: format!(
3069 "names `{named}` in a LaunchAgent, which already runs as the operator; \
3070 naming an account here asks launchd for a switch a login-mode \
3071 registration has no reason to want"
3072 ),
3073 });
3074 } else {
3075 controls
3076 .push("no UserName: the agent runs as the operator and no other".to_string());
3077 }
3078 }
3079 }
3080
3081 review_inbound_surface(
3082 text,
3083 &[
3084 (
3085 "<key>Sockets</key>",
3086 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
3087 forbids the product to have",
3088 ),
3089 (
3090 "<key>MachServices</key>",
3091 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
3092 forbids the product to have",
3093 ),
3094 ],
3095 controls,
3096 findings,
3097 );
3098}
3099
3100fn review_scheduled_task(
3101 text: &str,
3102 controls: &mut Vec<String>,
3103 findings: &mut Vec<PrivilegeFinding>,
3104) {
3105 match xml_value(text, "RunLevel").as_deref() {
3106 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
3107 Some(other) => findings.push(PrivilegeFinding {
3108 kind: FindingKind::Excess,
3109 subject: "RunLevel".to_string(),
3110 detail: format!(
3111 "is `{other}`, so the task runs with an elevated token whenever the operator is \
3112 an administrator"
3113 ),
3114 }),
3115 None => findings.push(PrivilegeFinding {
3116 kind: FindingKind::Excess,
3117 subject: "RunLevel".to_string(),
3118 detail: "is absent, so Task Scheduler decides the token rather than the definition"
3119 .to_string(),
3120 }),
3121 }
3122
3123 match xml_value(text, "LogonType").as_deref() {
3124 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
3125 Some(other) => findings.push(PrivilegeFinding {
3126 kind: FindingKind::Excess,
3127 subject: "LogonType".to_string(),
3128 detail: format!(
3129 "is `{other}`, which asks Windows to store or synthesise a credential for this \
3130 task; an interactive token needs neither"
3131 ),
3132 }),
3133 None => findings.push(PrivilegeFinding {
3134 kind: FindingKind::Shortfall,
3135 subject: "LogonType".to_string(),
3136 detail: "is absent, so this review cannot confirm that no credential is stored"
3137 .to_string(),
3138 }),
3139 }
3140}
3141
3142fn review_windows_service(
3143 text: &str,
3144 plan: &InstallPlan,
3145 controls: &mut Vec<String>,
3146 findings: &mut Vec<PrivilegeFinding>,
3147) {
3148 let directives = ini_directives(text, "windows-service");
3149
3150 match directives.get("ServiceType").map(String::as_str) {
3151 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
3152 Some(other) => findings.push(PrivilegeFinding {
3153 kind: FindingKind::Excess,
3154 subject: "ServiceType".to_string(),
3155 detail: format!(
3156 "is `{other}`; an interactive or shared-process service reaches further than a \
3157 daemon that only talks to GitHub over HTTPS"
3158 ),
3159 }),
3160 None => findings.push(PrivilegeFinding {
3161 kind: FindingKind::Shortfall,
3162 subject: "ServiceType".to_string(),
3163 detail: "is absent, so this review cannot confirm the service is not interactive"
3164 .to_string(),
3165 }),
3166 }
3167
3168 match directives.get("Account").map(String::as_str) {
3172 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
3173 controls.push(format!(
3174 "Account={account}: the only stock account the machine-scoped store's DACL \
3175 (SY, BA, OW) admits"
3176 ));
3177 }
3178 Some(other) => findings.push(PrivilegeFinding {
3179 kind: FindingKind::Shortfall,
3180 subject: "Account".to_string(),
3181 detail: format!(
3182 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
3183 daemon would start and then find no credential. Widening that DACL is not this \
3184 registration's to do: an ACE reaching `{other}` would also reach every other \
3185 service running under it"
3186 ),
3187 }),
3188 None => findings.push(PrivilegeFinding {
3189 kind: FindingKind::Shortfall,
3190 subject: "Account".to_string(),
3191 detail: "is absent, so this review cannot confirm which account was registered"
3192 .to_string(),
3193 }),
3194 }
3195
3196 match directives.get("ReadWritePaths") {
3197 Some(value) => {
3198 let listed = split_quoted(value);
3199 review_writable_paths(
3200 DefinitionKind::WindowsService,
3201 "ReadWritePaths",
3202 &listed,
3203 plan,
3204 controls,
3205 findings,
3206 );
3207 }
3208 None => findings.push(PrivilegeFinding {
3209 kind: FindingKind::Shortfall,
3210 subject: "ReadWritePaths".to_string(),
3211 detail: "is absent, so the directories the service was installed against are not \
3212 recorded"
3213 .to_string(),
3214 }),
3215 }
3216}
3217
3218fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
3226 let mut out = BTreeMap::new();
3227 let mut inside = false;
3228 for line in text.lines() {
3229 let line = line.trim();
3230 if line.starts_with('[') && line.ends_with(']') {
3231 inside = &line[1..line.len() - 1] == section;
3232 continue;
3233 }
3234 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
3235 continue;
3236 }
3237 if let Some((key, value)) = line.split_once('=') {
3238 out.insert(key.trim().to_string(), value.trim().to_string());
3239 }
3240 }
3241 out
3242}
3243
3244#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3251fn systemd_exec_command_line(value: &str) -> String {
3252 let value = value.trim();
3253 let command = value.trim_start_matches(['-', '@', ':', '+', '!']);
3254 if !value[..value.len() - command.len()].contains('@') {
3255 return command.to_string();
3256 }
3257 let mut words = split_command_line(command);
3258 if words.len() > 1 {
3259 words.remove(1);
3260 }
3261 words
3262 .iter()
3263 .map(|word| quote_argument(word))
3264 .collect::<Vec<_>>()
3265 .join(" ")
3266}
3267
3268#[cfg(any(test, all(unix, not(target_os = "macos"))))]
3272fn systemd_timespan(value: &str) -> Option<Duration> {
3273 let mut rest = value.trim();
3274 if rest.is_empty() || rest == "infinity" {
3275 return None;
3276 }
3277 let mut seconds = 0f64;
3278 while !rest.is_empty() {
3279 let digits = rest
3280 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
3281 .unwrap_or(rest.len());
3282 if digits == 0 {
3283 return None;
3284 }
3285 let number: f64 = rest[..digits].parse().ok()?;
3286 rest = rest[digits..].trim_start();
3287 let letters = rest
3288 .find(|c: char| !c.is_alphabetic())
3289 .unwrap_or(rest.len());
3290 let scale = match &rest[..letters] {
3291 "" | "s" | "sec" | "second" | "seconds" => 1.0,
3292 "us" | "usec" | "\u{3bc}s" | "\u{b5}s" => 1e-6,
3293 "ms" | "msec" => 1e-3,
3294 "m" | "min" | "minute" | "minutes" => 60.0,
3295 "h" | "hr" | "hour" | "hours" => 3_600.0,
3296 "d" | "day" | "days" => 86_400.0,
3297 "w" | "week" | "weeks" => 604_800.0,
3298 "M" | "month" | "months" => 2_629_800.0,
3299 "y" | "year" | "years" => 31_557_600.0,
3300 _ => return None,
3301 };
3302 seconds += number * scale;
3303 rest = rest[letters..].trim_start();
3304 }
3305 Duration::try_from_secs_f64(seconds).ok()
3306}
3307
3308fn split_quoted(value: &str) -> Vec<String> {
3311 let mut out = Vec::new();
3312 let mut rest = value.trim();
3313 while !rest.is_empty() {
3314 if rest.starts_with('"') {
3315 if let Some(parsed) = executable_from_command_line(rest) {
3318 out.push(parsed.to_string_lossy().into_owned());
3319 }
3320 let mut depth = 0usize;
3322 let mut end = rest.len();
3323 for (index, c) in rest.char_indices() {
3324 match c {
3325 '\\' => depth += 1,
3326 '"' => {
3327 if depth.is_multiple_of(2) && index > 0 {
3328 end = index + 1;
3329 break;
3330 }
3331 depth = 0;
3332 }
3333 _ => depth = 0,
3334 }
3335 }
3336 rest = rest[end..].trim_start();
3337 } else {
3338 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
3339 out.push(rest[..end].to_string());
3340 rest = rest[end..].trim_start();
3341 }
3342 }
3343 out
3344}
3345
3346pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
3358 let open = format!("<{tag}>");
3359 let close = format!("</{tag}>");
3360 let start = text.find(&open)? + open.len();
3361 let end = text[start..].find(&close)? + start;
3362 Some(xml_unescape(text[start..end].trim()))
3363}
3364
3365#[cfg(any(windows, test))]
3371fn windows_login_task_starts_automatically(document: &str) -> bool {
3372 document.contains("<LogonTrigger>")
3373 && xml_value(document, "Enabled").as_deref() != Some("false")
3374}
3375
3376fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
3378 let marker = format!("<key>{key}</key>");
3379 let start = text.find(&marker)? + marker.len();
3380 Some(text[start..].trim_start())
3381}
3382
3383pub fn plist_string_value(text: &str, key: &str) -> Option<String> {
3384 let rest = plist_value_after_key(text, key)?;
3385 if !rest.starts_with("<string>") {
3386 return None;
3387 }
3388 xml_value(rest, "string")
3389}
3390
3391fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
3392 let rest = plist_value_after_key(text, key)?;
3393 if rest.starts_with("<true/>") {
3394 Some(true)
3395 } else if rest.starts_with("<false/>") {
3396 Some(false)
3397 } else {
3398 None
3399 }
3400}
3401
3402pub const RECORD_SCHEMA_VERSION: u32 = 1;
3413
3414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3426pub struct InstallRecord {
3427 pub schema_version: u32,
3429 pub service_name: String,
3431 pub manager: String,
3433 pub start_mode: StartMode,
3435 pub account: ServiceAccount,
3437 pub binary: PathBuf,
3439 #[serde(default)]
3447 pub source_binary: Option<PathBuf>,
3448 pub arguments: Vec<String>,
3450 pub restart_delay_secs: u64,
3452 pub restart_reset_secs: u64,
3454 pub log_file: PathBuf,
3456 #[serde(default)]
3464 pub starts_on_demand: bool,
3465 pub definition_path: Option<PathBuf>,
3467 #[serde(default)]
3470 pub protected_paths: Vec<PathBuf>,
3471 pub installed_at: DateTime<Utc>,
3473 pub installed_by_version: String,
3475 pub directories: ServiceDirectories,
3480}
3481
3482impl InstallRecord {
3483 #[must_use]
3485 pub fn path(paths: &AppPaths) -> PathBuf {
3486 paths.config_dir().join(RECORD_FILE)
3487 }
3488
3489 #[must_use]
3491 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
3492 Self {
3493 schema_version: RECORD_SCHEMA_VERSION,
3494 service_name: plan.identity().name().to_string(),
3495 manager: definition.kind().manager().to_string(),
3496 start_mode: plan.start_mode(),
3497 account: plan.account().clone(),
3498 binary: plan.binary().to_path_buf(),
3499 arguments: plan
3500 .arguments()
3501 .iter()
3502 .map(|argument| argument.to_string_lossy().into_owned())
3503 .collect(),
3504 restart_delay_secs: plan.restart().delay().as_secs(),
3505 restart_reset_secs: plan.restart().reset_after().as_secs(),
3506 starts_on_demand: plan.is_on_demand(),
3507 source_binary: plan.source_binary().map(Path::to_path_buf),
3508 log_file: plan.directories().log_file(),
3509 definition_path: definition.install_path().map(Path::to_path_buf),
3510 protected_paths: plan.protected_paths().to_vec(),
3511 installed_at: at,
3512 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
3513 directories: plan.directories().clone(),
3514 }
3515 }
3516
3517 #[must_use]
3525 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
3526 let plan = InstallPlan::unchecked(
3527 identity,
3528 self.start_mode,
3529 self.binary.clone(),
3530 self.directories.clone(),
3531 )
3532 .with_arguments(self.arguments.clone())
3533 .with_restart(self.restart())
3534 .with_protected_paths(self.protected_paths.clone());
3535 let plan = if self.starts_on_demand {
3536 plan.started_on_demand()
3537 } else {
3538 plan
3539 };
3540 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
3541 Ok(store) => plan.with_secret_guard(store.guard()),
3542 Err(_) => plan,
3543 }
3544 }
3545
3546 #[must_use]
3550 pub fn restart(&self) -> RestartPolicy {
3551 RestartPolicy::new(
3552 Duration::from_secs(self.restart_delay_secs),
3553 Duration::from_secs(self.restart_reset_secs),
3554 )
3555 .unwrap_or_default()
3556 }
3557
3558 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
3569 let path = Self::path(paths);
3570 let text = match std::fs::read_to_string(&path) {
3571 Ok(text) => text,
3572 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3573 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
3576 return Err(ServiceError::RecordNotPermitted {
3577 path,
3578 detail: error.to_string(),
3579 });
3580 }
3581 Err(error) => {
3582 return Err(ServiceError::Record {
3583 operation: "read",
3584 path,
3585 detail: error.to_string(),
3586 });
3587 }
3588 };
3589 let record: Self =
3590 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
3591 path: path.clone(),
3592 detail: error.to_string(),
3593 })?;
3594 if record.schema_version != RECORD_SCHEMA_VERSION {
3595 return Err(ServiceError::RecordUnreadable {
3596 path,
3597 detail: format!(
3598 "it declares schema version {} and this build reads version {}",
3599 record.schema_version, RECORD_SCHEMA_VERSION
3600 ),
3601 });
3602 }
3603 Ok(Some(record))
3604 }
3605
3606 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
3612 use std::io::Write as _;
3613
3614 let path = Self::path(paths);
3615 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
3616 operation: "encode",
3617 path: path.clone(),
3618 detail: error.to_string(),
3619 })?;
3620 if let Some(parent) = path.parent() {
3621 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
3622 operation: "write",
3623 path: path.clone(),
3624 detail: error.to_string(),
3625 })?;
3626 }
3627 let parent = path.parent().ok_or_else(|| ServiceError::Record {
3628 operation: "write",
3629 path: path.clone(),
3630 detail: "the record path has no parent directory".to_string(),
3631 })?;
3632 let mut temporary =
3633 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
3634 operation: "write",
3635 path: path.clone(),
3636 detail: error.to_string(),
3637 })?;
3638 temporary
3639 .write_all(text.as_bytes())
3640 .and_then(|()| temporary.as_file().sync_all())
3641 .map_err(|error| ServiceError::Record {
3642 operation: "write",
3643 path: path.clone(),
3644 detail: error.to_string(),
3645 })?;
3646 #[cfg(unix)]
3662 {
3663 use std::os::unix::fs::PermissionsExt as _;
3664
3665 temporary
3666 .as_file()
3667 .set_permissions(std::fs::Permissions::from_mode(0o644))
3668 .map_err(|error| ServiceError::Record {
3669 operation: "write",
3670 path: path.clone(),
3671 detail: error.to_string(),
3672 })?;
3673 }
3674 temporary
3675 .persist(&path)
3676 .map(|_| ())
3677 .map_err(|error| ServiceError::Record {
3678 operation: "write",
3679 path,
3680 detail: error.error.to_string(),
3681 })
3682 }
3683
3684 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3690 let path = Self::path(paths);
3691 match std::fs::remove_file(&path) {
3692 Ok(()) => Ok(true),
3693 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3694 Err(error) => Err(ServiceError::Record {
3695 operation: "remove",
3696 path,
3697 detail: error.to_string(),
3698 }),
3699 }
3700 }
3701}
3702
3703const CONTACT_SCHEMA_VERSION: u32 = 1;
3709
3710#[derive(Debug, Clone, Serialize, Deserialize)]
3711struct ContactRecord {
3712 schema_version: u32,
3713 last_success: DateTime<Utc>,
3714}
3715
3716pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3737 write_state_record(
3738 &contact_path(paths),
3739 &ContactRecord {
3740 schema_version: CONTACT_SCHEMA_VERSION,
3741 last_success: at,
3742 },
3743 )
3744}
3745
3746pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3758 Ok(read_state_record::<ContactRecord>(&contact_path(paths))?.map(|record| record.last_success))
3759}
3760
3761#[must_use]
3763pub fn contact_path(paths: &AppPaths) -> PathBuf {
3764 paths.state_dir().join(CONTACT_FILE)
3765}
3766
3767pub(crate) fn write_state_record(path: &Path, record: &impl Serialize) -> Result<(), ServiceError> {
3770 let failed = |detail: String| ServiceError::Record {
3771 operation: "write",
3772 path: path.to_path_buf(),
3773 detail,
3774 };
3775 let text = toml::to_string_pretty(record).map_err(|error| failed(error.to_string()))?;
3776 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3777 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3778 let temporary = path.with_extension("toml.new");
3779 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3780 std::fs::rename(&temporary, path).map_err(|error| failed(error.to_string()))
3781}
3782
3783pub(crate) fn read_state_record<T: serde::de::DeserializeOwned>(
3786 path: &Path,
3787) -> Result<Option<T>, ServiceError> {
3788 let failed = |detail: String| ServiceError::Record {
3789 operation: "read",
3790 path: path.to_path_buf(),
3791 detail,
3792 };
3793 let text = match std::fs::read_to_string(path) {
3794 Ok(text) => text,
3795 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3796 Err(error) => return Err(failed(error.to_string())),
3797 };
3798 toml::from_str(&text)
3799 .map(Some)
3800 .map_err(|error| failed(error.to_string()))
3801}
3802
3803pub(crate) fn remove_state_record(path: &Path) -> Result<(), ServiceError> {
3805 match std::fs::remove_file(path) {
3806 Ok(()) => Ok(()),
3807 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3808 Err(error) => Err(ServiceError::Record {
3809 operation: "remove",
3810 path: path.to_path_buf(),
3811 detail: error.to_string(),
3812 }),
3813 }
3814}
3815
3816#[derive(Debug, Serialize, Deserialize)]
3821#[serde(deny_unknown_fields)]
3822struct CredentialRejectionRecord {
3823 schema_version: u32,
3824 rejected_since: DateTime<Utc>,
3825}
3826
3827pub fn record_github_credential_rejected(
3843 paths: &AppPaths,
3844 at: DateTime<Utc>,
3845) -> Result<(), ServiceError> {
3846 if github_credential_rejected_since(paths)
3847 .ok()
3848 .flatten()
3849 .is_some()
3850 {
3851 return Ok(());
3852 }
3853 write_state_record(
3854 &credential_rejection_path(paths),
3855 &CredentialRejectionRecord {
3856 schema_version: CONTACT_SCHEMA_VERSION,
3857 rejected_since: at,
3858 },
3859 )
3860}
3861
3862pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3868 remove_state_record(&credential_rejection_path(paths))
3869}
3870
3871pub fn github_credential_rejected_since(
3877 paths: &AppPaths,
3878) -> Result<Option<DateTime<Utc>>, ServiceError> {
3879 Ok(
3880 read_state_record::<CredentialRejectionRecord>(&credential_rejection_path(paths))?
3881 .map(|record| record.rejected_since),
3882 )
3883}
3884
3885#[must_use]
3887pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3888 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3889}
3890
3891#[derive(Debug, Serialize, Deserialize)]
3892#[serde(deny_unknown_fields)]
3893struct CredentialUnreadableRecord {
3894 schema_version: u32,
3895 unreadable_since: DateTime<Utc>,
3896}
3897
3898pub fn record_credential_unreadable(
3917 paths: &AppPaths,
3918 at: DateTime<Utc>,
3919) -> Result<(), ServiceError> {
3920 if credential_unreadable_since(paths).ok().flatten().is_some() {
3921 return Ok(());
3922 }
3923 write_state_record(
3924 &credential_unreadable_path(paths),
3925 &CredentialUnreadableRecord {
3926 schema_version: CONTACT_SCHEMA_VERSION,
3927 unreadable_since: at,
3928 },
3929 )
3930}
3931
3932pub fn clear_credential_unreadable(paths: &AppPaths) -> Result<(), ServiceError> {
3938 remove_state_record(&credential_unreadable_path(paths))
3939}
3940
3941pub fn credential_unreadable_since(
3948 paths: &AppPaths,
3949) -> Result<Option<DateTime<Utc>>, ServiceError> {
3950 Ok(
3951 read_state_record::<CredentialUnreadableRecord>(&credential_unreadable_path(paths))?
3952 .map(|record| record.unreadable_since),
3953 )
3954}
3955
3956#[must_use]
3958pub fn credential_unreadable_path(paths: &AppPaths) -> PathBuf {
3959 paths.state_dir().join(CREDENTIAL_UNREADABLE_FILE)
3960}
3961
3962#[must_use]
3970pub fn sign_in_instruction(mode: StartMode) -> String {
3971 let (command, place) = match mode {
3972 StartMode::Boot if cfg!(unix) => (sudo_command("auth login --start-at boot"), ""),
3973 StartMode::Boot => (
3974 "runner-manager auth login --start-at boot".to_owned(),
3975 " from an elevated terminal",
3976 ),
3977 StartMode::Login => (
3978 "runner-manager auth login --start-at login".to_owned(),
3979 MACOS_LOGIN_PLACE,
3980 ),
3981 };
3982 format!("`{command}`{place}")
3983}
3984
3985#[must_use]
3994pub fn sudo_command(arguments: &str) -> String {
3995 let executable = if cfg!(test) {
3998 None
3999 } else {
4000 std::env::current_exe().ok()
4001 };
4002 format!("sudo {} {arguments}", sudo_program(executable.as_deref()))
4003}
4004
4005fn sudo_program(executable: Option<&Path>) -> String {
4007 const SECURE_PATH: [&str; 7] = [
4010 "/usr/local/sbin",
4011 "/usr/local/bin",
4012 "/usr/sbin",
4013 "/usr/bin",
4014 "/sbin",
4015 "/bin",
4016 "/snap/bin",
4017 ];
4018 let Some(executable) = executable else {
4019 return "runner-manager".to_owned();
4020 };
4021 if executable
4022 .parent()
4023 .is_some_and(|directory| SECURE_PATH.iter().any(|path| directory == Path::new(path)))
4024 {
4025 return "runner-manager".to_owned();
4026 }
4027 let text = executable.to_string_lossy();
4028 if text
4029 .chars()
4030 .all(|c| c.is_ascii_alphanumeric() || "/._-+@".contains(c))
4031 {
4032 text.into_owned()
4033 } else {
4034 format!("'{}'", text.replace('\'', "'\\''"))
4035 }
4036}
4037
4038pub const MACOS_LOGIN_PLACE: &str = if cfg!(target_os = "macos") {
4041 " in a Terminal in this Mac's desktop session (an SSH session cannot unlock the login \
4042 keychain)"
4043} else {
4044 ""
4045};
4046
4047#[derive(Debug, Clone, PartialEq, Eq)]
4064pub struct DefinitionDrift {
4065 pub path: PathBuf,
4067 pub installed_by_version: String,
4069 pub rendered: String,
4071 pub start_mode: StartMode,
4073 pub manager: DefinitionKind,
4075 pub writable: Vec<PathBuf>,
4082}
4083
4084impl DefinitionDrift {
4085 pub fn create_writable(&self) -> std::io::Result<()> {
4093 create_writable_directories(&self.writable)
4094 }
4095}
4096
4097pub fn create_writable_directories(directories: &[PathBuf]) -> std::io::Result<()> {
4106 for directory in directories {
4107 if let Some(parent) = directory.parent() {
4108 std::fs::create_dir_all(parent)?;
4109 }
4110 match std::fs::create_dir(directory) {
4111 Ok(()) => {
4112 #[cfg(unix)]
4113 {
4114 use std::os::unix::fs::PermissionsExt as _;
4115 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
4116 }
4117 }
4118 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4119 if !directory.is_dir() {
4120 return Err(std::io::Error::new(
4121 std::io::ErrorKind::AlreadyExists,
4122 format!("{} exists and is not a directory", directory.display()),
4123 ));
4124 }
4125 }
4126 Err(error) => return Err(error),
4127 }
4128 }
4129 Ok(())
4130}
4131
4132fn definition_drift(
4140 controls: &dyn ControlFactory,
4141 identity: &ServiceIdentity,
4142 record: &InstallRecord,
4143) -> Option<DefinitionDrift> {
4144 let compare = match host_definition_kind(record.start_mode) {
4145 DefinitionKind::SystemdUnit => systemd_definition_drift,
4146 DefinitionKind::LaunchdPlist => launchd_definition_drift,
4147 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
4148 };
4149 let path = record.definition_path.clone()?;
4150 let installed = controls.installed_definition(&path)?;
4151 compare(identity, record, path, &installed)
4152}
4153
4154fn launchd_definition_drift(
4163 identity: &ServiceIdentity,
4164 record: &InstallRecord,
4165 path: PathBuf,
4166 installed: &str,
4167) -> Option<DefinitionDrift> {
4168 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
4169 || DefinitionDrift {
4170 path,
4171 installed_by_version: record.installed_by_version.clone(),
4172 rendered: launchd_plist(&record.plan(identity.clone())),
4173 start_mode: record.start_mode,
4174 manager: DefinitionKind::LaunchdPlist,
4175 writable: Vec::new(),
4176 },
4177 )
4178}
4179
4180fn systemd_definition_drift(
4181 identity: &ServiceIdentity,
4182 record: &InstallRecord,
4183 path: PathBuf,
4184 installed: &str,
4185) -> Option<DefinitionDrift> {
4186 let plan = record.plan(identity.clone());
4187 let rendered = systemd_unit(&plan);
4188 (installed != rendered).then(|| DefinitionDrift {
4189 path,
4190 installed_by_version: record.installed_by_version.clone(),
4191 rendered,
4192 start_mode: record.start_mode,
4193 manager: DefinitionKind::SystemdUnit,
4194 writable: permitted_paths(DefinitionKind::SystemdUnit, &plan)
4195 .into_iter()
4196 .map(PathBuf::from)
4197 .collect(),
4198 })
4199}
4200
4201const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
4206
4207#[derive(Debug, Clone, Serialize, Deserialize)]
4208struct RootRefusalFile {
4209 schema_version: u32,
4210 #[serde(default)]
4212 refusals: BTreeMap<String, RootRefusalEntry>,
4213}
4214
4215#[derive(Debug, Clone, Serialize, Deserialize)]
4216struct RootRefusalEntry {
4217 at: DateTime<Utc>,
4218 kind: String,
4219 root: String,
4220 detail: String,
4221}
4222
4223#[derive(Debug, Clone, PartialEq, Eq)]
4225pub struct RunnerRootRefusal {
4226 pub policy: String,
4228 pub at: DateTime<Utc>,
4230 pub kind: String,
4232 pub root: String,
4234 pub detail: String,
4237}
4238
4239pub fn record_runner_root_refusal(
4273 paths: &AppPaths,
4274 policy: &str,
4275 at: DateTime<Utc>,
4276 kind: &str,
4277 root: &str,
4278 detail: &str,
4279) -> Result<(), ServiceError> {
4280 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
4281 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
4282 refusals: BTreeMap::new(),
4283 });
4284 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
4285 file.refusals.insert(
4286 policy.to_owned(),
4287 RootRefusalEntry {
4288 at,
4289 kind: kind.to_owned(),
4290 root: root.to_owned(),
4291 detail: detail.to_owned(),
4292 },
4293 );
4294 write_refusal_file(paths, &file)
4295}
4296
4297pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
4308 let Some(mut file) = read_refusal_file(paths)? else {
4309 return Ok(());
4310 };
4311 if file.refusals.remove(policy).is_none() {
4312 return Ok(());
4313 }
4314 if file.refusals.is_empty() {
4315 let path = root_refusal_path(paths);
4316 return match std::fs::remove_file(&path) {
4317 Ok(()) => Ok(()),
4318 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4319 Err(error) => Err(ServiceError::Record {
4320 operation: "remove",
4321 path,
4322 detail: error.to_string(),
4323 }),
4324 };
4325 }
4326 write_refusal_file(paths, &file)
4327}
4328
4329pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
4341 Ok(read_refusal_file(paths)?
4342 .map(|file| {
4343 file.refusals
4344 .into_iter()
4345 .map(|(policy, entry)| RunnerRootRefusal {
4346 policy,
4347 at: entry.at,
4348 kind: entry.kind,
4349 root: entry.root,
4350 detail: entry.detail,
4351 })
4352 .collect()
4353 })
4354 .unwrap_or_default())
4355}
4356
4357fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
4358 read_state_record(&root_refusal_path(paths))
4359}
4360
4361fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
4362 write_state_record(&root_refusal_path(paths), file)
4363}
4364
4365#[must_use]
4367pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
4368 paths.state_dir().join(ROOT_REFUSAL_FILE)
4369}
4370
4371#[derive(Debug, Clone, PartialEq, Eq)]
4382pub enum BinaryPath {
4383 Current {
4385 path: PathBuf,
4387 },
4388 Missing {
4395 recorded: PathBuf,
4397 },
4398 NotExecutable {
4401 recorded: PathBuf,
4403 detail: String,
4405 },
4406 Diverged {
4408 recorded: PathBuf,
4410 registered: PathBuf,
4412 },
4413}
4414
4415impl BinaryPath {
4416 #[must_use]
4418 pub const fn is_error(&self) -> bool {
4419 !matches!(self, Self::Current { .. })
4420 }
4421
4422 #[must_use]
4424 pub fn recorded(&self) -> &Path {
4425 match self {
4426 Self::Current { path } => path,
4427 Self::Missing { recorded }
4428 | Self::NotExecutable { recorded, .. }
4429 | Self::Diverged { recorded, .. } => recorded,
4430 }
4431 }
4432}
4433
4434impl fmt::Display for BinaryPath {
4435 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4436 match self {
4437 Self::Current { path } => write!(f, "{}", path.display()),
4438 Self::Missing { recorded } => write!(
4439 f,
4440 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
4441 package manager that moved the binary is the usual cause; an `npm i -g` \
4442 installation moves with the active Node version. Run `service install` again \
4443 from the binary that is now installed.",
4444 recorded.display()
4445 ),
4446 Self::NotExecutable { recorded, detail } => write!(
4447 f,
4448 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
4449 from the installed binary.",
4450 recorded.display()
4451 ),
4452 Self::Diverged {
4453 recorded,
4454 registered,
4455 } => write!(
4456 f,
4457 "{} -- STALE: the service manager is registered to start {} instead. Something \
4458 has edited the registration since it was installed. Run `service uninstall` and \
4459 `service install`; neither touches configuration, secrets, or the cache.",
4460 recorded.display(),
4461 registered.display()
4462 ),
4463 }
4464 }
4465}
4466
4467#[must_use]
4475pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
4476 match std::fs::metadata(recorded) {
4477 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4478 return BinaryPath::Missing {
4479 recorded: recorded.to_path_buf(),
4480 };
4481 }
4482 Err(error) => {
4483 return BinaryPath::NotExecutable {
4484 recorded: recorded.to_path_buf(),
4485 detail: format!("it cannot be inspected ({error})"),
4486 };
4487 }
4488 Ok(metadata) if !metadata.is_file() => {
4489 return BinaryPath::NotExecutable {
4490 recorded: recorded.to_path_buf(),
4491 detail: "what is there is not a file".to_string(),
4492 };
4493 }
4494 Ok(_) => {}
4495 }
4496 if let Some(registered) = registered
4497 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
4498 {
4499 return BinaryPath::Diverged {
4500 recorded: recorded.to_path_buf(),
4501 registered: registered.to_path_buf(),
4502 };
4503 }
4504 BinaryPath::Current {
4505 path: recorded.to_path_buf(),
4506 }
4507}
4508
4509#[derive(Debug, Clone, PartialEq, Eq)]
4515pub struct Registration {
4516 pub manager: DefinitionKind,
4521 pub start_mode: StartMode,
4523 pub command_line: String,
4525 pub account: Option<String>,
4532 pub running: bool,
4534 pub starts_automatically: bool,
4541 pub restart_delay: Option<Duration>,
4550}
4551
4552impl Registration {
4553 #[must_use]
4555 pub fn binary(&self) -> Option<PathBuf> {
4556 executable_from_command_line(&self.command_line)
4557 }
4558}
4559
4560pub trait ServiceControl: fmt::Debug {
4566 fn manager(&self) -> DefinitionKind;
4568
4569 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
4575
4576 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4587
4588 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
4594
4595 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
4602
4603 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
4609}
4610
4611pub trait ControlFactory: fmt::Debug + Send + Sync {
4617 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
4623
4624 fn installed_definition(&self, _path: &Path) -> Option<String> {
4628 None
4629 }
4630
4631 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
4636 None
4637 }
4638}
4639
4640#[derive(Debug, Clone, Copy, Default)]
4642pub struct HostControls;
4643
4644#[derive(Debug, Clone)]
4650pub struct Installed {
4651 pub plan: InstallPlan,
4653 pub definition: ServiceDefinition,
4655 pub record: InstallRecord,
4657 pub review: PrivilegeReview,
4659 pub runner_root: RootAccessSummary,
4664 pub replaced_existing: bool,
4672}
4673
4674#[derive(Debug, Clone, PartialEq, Eq)]
4676pub struct Uninstalled {
4677 pub removed_registration: bool,
4679 pub removed_record: bool,
4681 pub removed_definition: Option<PathBuf>,
4683 pub preserved: Vec<PathBuf>,
4690}
4691
4692impl fmt::Display for Uninstalled {
4693 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4694 if self.removed_registration {
4695 writeln!(f, "The service registration was removed.")?;
4696 } else {
4697 writeln!(f, "There was no service registration to remove.")?;
4698 }
4699 writeln!(f, "Nothing else was deleted. These are untouched:")?;
4700 for path in &self.preserved {
4701 writeln!(f, " {}", path.display())?;
4702 }
4703 write!(
4704 f,
4705 "The stored GitHub token is untouched too; `auth logout` is what purges it."
4706 )
4707 }
4708}
4709
4710#[derive(Debug, Clone, PartialEq, Eq)]
4712pub struct StartModeChange {
4713 pub from: StartMode,
4715 pub to: StartMode,
4717 pub changed: bool,
4719 pub store_scope: crate::secrets::SecretScope,
4722 pub runner_root: RootAccessSummary,
4728}
4729
4730impl fmt::Display for StartModeChange {
4731 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4732 if !self.changed {
4733 return write!(f, "The service already starts at {}.", self.to);
4734 }
4735 write!(
4736 f,
4737 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
4738 if the token was stored under the other scope, run `auth login` again. {}",
4739 self.to, self.from, self.store_scope, self.runner_root
4740 )
4741 }
4742}
4743
4744#[derive(Debug, Clone)]
4752pub struct ServiceOperations {
4753 paths: AppPaths,
4754 identity: ServiceIdentity,
4755 controls: std::sync::Arc<dyn ControlFactory>,
4756 runner_root: Option<LocalAbsolutePath>,
4757}
4758
4759impl ServiceOperations {
4760 #[must_use]
4762 pub fn on_this_host(paths: AppPaths) -> Self {
4763 Self::with_controls(
4764 paths,
4765 ServiceIdentity::product(),
4766 std::sync::Arc::new(HostControls),
4767 )
4768 }
4769
4770 #[must_use]
4778 pub fn with_controls(
4779 paths: AppPaths,
4780 identity: ServiceIdentity,
4781 controls: std::sync::Arc<dyn ControlFactory>,
4782 ) -> Self {
4783 Self {
4784 paths,
4785 identity,
4786 controls,
4787 runner_root: None,
4788 }
4789 }
4790
4791 #[must_use]
4810 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
4811 self.runner_root = Some(root);
4812 self
4813 }
4814
4815 #[must_use]
4817 pub const fn paths(&self) -> &AppPaths {
4818 &self.paths
4819 }
4820
4821 #[must_use]
4823 pub const fn identity(&self) -> &ServiceIdentity {
4824 &self.identity
4825 }
4826
4827 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
4847 self.paths
4848 .create_all()
4849 .map_err(|source| ServiceError::Paths {
4850 source: Box::new(source),
4851 })?;
4852
4853 let _guard = self.refuse_while_an_agent_runs()?;
4856 let adopted = self.adopt_boot_installation(request.start_mode(), &|_| Ok(()))?;
4859 if !adopted {
4863 self.refuse_foreign("replace")?;
4864 }
4865
4866 let (replacing, previous_was_running) = match self.find_registration()? {
4895 Some((existing, registration)) if existing == request.start_mode() => {
4896 (true, registration.running)
4897 }
4898 Some((existing, _)) => {
4899 return Err(ServiceError::AlreadyInstalled {
4900 name: self.identity.name().to_string(),
4901 existing,
4902 requested: request.start_mode(),
4903 });
4904 }
4905 None => (false, false),
4906 };
4907
4908 let plan = InstallPlan::resolve(
4909 self.identity.clone(),
4910 request,
4911 ServiceDirectories::of(&self.paths),
4912 )?;
4913
4914 let control = self.controls.control(plan.start_mode())?;
4919
4920 let root = self.prepare_runner_root(plan.start_mode())?;
4926
4927 let previous = if replacing {
4932 InstallRecord::read(&self.paths).ok().flatten()
4933 } else {
4934 None
4935 };
4936 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4937 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4938 }
4939
4940 let definition = match control.install(&plan) {
4941 Ok(definition) => definition,
4942 Err(cause) => {
4943 let restored =
4946 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4947 return Err(rolled_back(
4948 retained_runner_root(&root),
4949 restored,
4950 "install",
4951 &self.identity,
4952 cause,
4953 ));
4954 }
4955 };
4956 let review = review_least_privilege(&definition, &plan);
4957 let record = InstallRecord::of(&plan, &definition, Utc::now());
4958 if let Err(cause) = record.write(&self.paths) {
4959 let rollback = control
4960 .uninstall(&self.identity)
4961 .map(|_| ())
4962 .and_then(|()| {
4963 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4964 });
4965 return Err(rolled_back(
4966 retained_runner_root(&root),
4967 rollback,
4968 "install",
4969 &self.identity,
4970 cause,
4971 ));
4972 }
4973
4974 if let Err(cause) = control.start(&self.identity) {
4981 let remove_new = control.uninstall(&self.identity).map(|_| ());
4982 let restore_registration = remove_new.and_then(|()| {
4983 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4984 });
4985 let restore_record = restore_registration.and_then(|()| match &previous {
4986 Some(previous) => previous.write(&self.paths),
4987 None => InstallRecord::remove(&self.paths).map(|_| ()),
4988 });
4989 return Err(rolled_back(
4990 retained_runner_root(&root),
4991 restore_record,
4992 "install and start",
4993 &self.identity,
4994 cause,
4995 ));
4996 }
4997 Ok(Installed {
4998 plan,
4999 definition,
5000 record,
5001 review,
5002 runner_root: root.summary().clone(),
5003 replaced_existing: replacing,
5004 })
5005 }
5006
5007 fn reinstate(
5018 &self,
5019 control: &dyn ServiceControl,
5020 previous: Option<&InstallRecord>,
5021 was_running: bool,
5022 ) -> Result<(), ServiceError> {
5023 let Some(record) = previous else {
5024 return Ok(());
5025 };
5026 control.install(&record.plan(self.identity.clone()))?;
5027 if was_running {
5028 control.start(&self.identity)?;
5029 }
5030 Ok(())
5031 }
5032
5033 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
5042 let record = InstallRecord::read(&self.paths).ok().flatten();
5043 let removed_definition = record
5044 .as_ref()
5045 .and_then(|record| record.definition_path.clone());
5046
5047 let mut removed_registration = false;
5048 let mut foreign = None;
5055 for mode in [StartMode::Boot, StartMode::Login] {
5056 let control = self.controls.control(mode)?;
5057 if let Some(directories) = control
5058 .query(&self.identity)
5059 .ok()
5060 .flatten()
5061 .and_then(|registration| self.foreign_directories(®istration))
5062 {
5063 foreign = Some(directories);
5064 continue;
5065 }
5066 if control.uninstall(&self.identity)? {
5067 removed_registration = true;
5068 }
5069 }
5070 if !removed_registration && let Some(directories) = foreign {
5071 return Err(self.foreign_refusal("uninstall", directories));
5072 }
5073 let removed_record = InstallRecord::remove(&self.paths)?;
5074 Ok(Uninstalled {
5075 removed_registration,
5076 removed_record,
5077 removed_definition: removed_definition.filter(|_| removed_registration),
5078 preserved: self
5079 .paths
5080 .all()
5081 .iter()
5082 .map(|(_, path)| (*path).to_path_buf())
5083 .collect(),
5084 })
5085 }
5086
5087 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
5109 let Some(record) = InstallRecord::read(&self.paths)? else {
5110 return Err(ServiceError::NotInstalled {
5111 name: self.identity.name().to_string(),
5112 operation: "switch the start mode of",
5113 });
5114 };
5115 let from = record.start_mode;
5116 if from == to {
5117 return Ok(StartModeChange {
5121 from,
5122 to,
5123 changed: false,
5124 store_scope: crate::secrets::SecretScope::for_start_mode(to),
5125 runner_root: RootAccessSummary::NotApplicable,
5126 });
5127 }
5128
5129 self.refuse_foreign("switch the start mode of")?;
5132
5133 #[cfg(windows)]
5134 let arguments = {
5135 let mut arguments = record.arguments.clone();
5136 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
5137 if to == StartMode::Boot {
5138 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
5139 }
5140 arguments
5141 };
5142 #[cfg(not(windows))]
5143 let arguments = record.arguments.clone();
5144 let plan = InstallPlan::unchecked(
5145 self.identity.clone(),
5146 to,
5147 record.binary.clone(),
5148 record.directories.clone(),
5149 )
5150 .with_arguments(arguments)
5151 .with_restart(record.restart());
5152 let plan = if record.starts_on_demand {
5153 plan.started_on_demand()
5154 } else {
5155 plan
5156 };
5157 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
5158 Ok(store) => plan.with_secret_guard(store.guard()),
5159 Err(_) => plan,
5160 };
5161
5162 let target = self.controls.control(to)?;
5169 let previous = self.controls.control(from)?;
5174
5175 let root = self.prepare_runner_root(to)?;
5183
5184 let definition = match target.install(&plan) {
5185 Ok(definition) => definition,
5186 Err(cause) => {
5187 return Err(undo_runner_root(
5188 &root,
5189 "switch start mode",
5190 &self.identity,
5191 cause,
5192 ));
5193 }
5194 };
5195 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
5196 if let Err(cause) = next_record.write(&self.paths) {
5197 return Err(rolled_back(
5198 retained_runner_root(&root),
5199 target.uninstall(&self.identity),
5200 "switch start mode",
5201 &self.identity,
5202 cause,
5203 ));
5204 }
5205
5206 if let Err(cause) = previous.uninstall(&self.identity) {
5210 let target_rollback = target.uninstall(&self.identity);
5211 let record_rollback = record.write(&self.paths);
5212 return Err(rolled_back(
5213 retained_runner_root(&root),
5214 target_rollback.and(record_rollback),
5215 "switch start mode",
5216 &self.identity,
5217 cause,
5218 ));
5219 }
5220 Ok(StartModeChange {
5221 from,
5222 to,
5223 changed: true,
5224 store_scope: crate::secrets::SecretScope::for_start_mode(to),
5225 runner_root: root.summary().clone(),
5226 })
5227 }
5228
5229 pub fn start(&self) -> Result<(), ServiceError> {
5235 let mode = self.own_registration_mode("start")?;
5236 self.controls.control(mode)?.start(&self.identity)
5237 }
5238
5239 fn own_registration_mode(&self, operation: &'static str) -> Result<StartMode, ServiceError> {
5247 match self.registration_for_status()? {
5248 None => Err(ServiceError::NotInstalled {
5249 name: self.identity.name().to_string(),
5250 operation,
5251 }),
5252 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5253 Some((registration, None)) => Ok(registration.start_mode),
5254 }
5255 }
5256
5257 pub fn stop(&self) -> Result<bool, ServiceError> {
5263 let mode = self.own_registration_mode("stop")?;
5264 self.controls.control(mode)?.stop(&self.identity)
5265 }
5266
5267 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
5298 match self.registration_for_status()? {
5299 Some((registration, Some(directories))) => {
5300 Ok(self.foreign_status(registration, directories))
5301 }
5302 found => {
5303 let (record, record_refused) = match InstallRecord::read(&self.paths) {
5304 Ok(record) => (record, None),
5305 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
5306 Err(error) => return Err(error),
5307 };
5308 self.status_at(
5309 &self.paths,
5310 record,
5311 record_refused.as_ref(),
5312 found.map(|(registration, _)| registration),
5313 )
5314 }
5315 }
5316 }
5317
5318 fn status_at(
5321 &self,
5322 paths: &AppPaths,
5323 record: Option<InstallRecord>,
5324 record_refused: Option<&ServiceError>,
5325 found: Option<Registration>,
5326 ) -> Result<ServiceStatus, ServiceError> {
5327 let last_github_contact = last_github_contact(paths)?;
5328 let drift = record
5329 .as_ref()
5330 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
5331 let credential_rejected_since = github_credential_rejected_since(paths).ok().flatten();
5332 let credential_unreadable_since = credential_unreadable_since(paths).ok().flatten();
5333 let probed = self.controls.unattended_login();
5334 let unattended = record.as_ref().and_then(|record| {
5335 let account = probed.as_ref().map_or_else(String::new, |_| {
5338 found
5339 .as_ref()
5340 .and_then(|registration| registration.account.clone())
5341 .unwrap_or_else(|| service_account_name(&record.account))
5342 });
5343 unattended_gap(record.start_mode, &account, probed.as_ref())
5344 });
5345 let now = Utc::now();
5346 let installed = record.is_some() || found.is_some();
5347 let status = ServiceStatus::compose(
5348 self.identity.clone(),
5349 record,
5350 record_refused,
5351 found,
5352 last_github_contact,
5353 paths,
5354 )
5355 .with_definition_drift(drift)
5356 .with_credential_rejection(credential_rejected_since)
5357 .with_credential_unreadable(credential_unreadable_since)
5358 .with_launches_blocked(crate::launch_health::launches_blocked(paths, now))
5359 .with_unattended_gap(unattended)
5360 .with_host_unfit(
5361 crate::host_fitness::host_unfit_in_force(paths, now).filter(|_| installed),
5362 );
5363 let restart = status.restart_command();
5364 Ok(status.with_stall(
5365 crate::daemon_heartbeat::liveness(paths, now).stall(last_github_contact, &restart),
5366 ))
5367 }
5368
5369 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
5376 Ok(InstallRecord::read(&self.paths)?
5377 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
5378 }
5379
5380 pub fn boot_unit_writable_directories(&self) -> Result<Vec<PathBuf>, ServiceError> {
5387 Ok(InstallRecord::read(&self.paths)?
5388 .filter(|record| {
5389 record.start_mode == StartMode::Boot
5390 && host_definition_kind(record.start_mode) == DefinitionKind::SystemdUnit
5391 })
5392 .map(|record| {
5393 permitted_paths(
5394 DefinitionKind::SystemdUnit,
5395 &record.plan(self.identity.clone()),
5396 )
5397 .into_iter()
5398 .map(PathBuf::from)
5399 .collect()
5400 })
5401 .unwrap_or_default())
5402 }
5403
5404 #[cfg(target_os = "macos")]
5426 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
5427 let root = unsafe { libc::getuid() } == 0;
5433 if root != (drift.start_mode == StartMode::Boot) {
5434 return Err(ServiceError::Control {
5435 operation: "rewrite",
5436 name: self.identity.name().to_string(),
5437 manager: "launchd",
5438 detail: format!(
5439 "the install record says --start-at {}, but this daemon runs as {}; the plist \
5440 is left as it is",
5441 drift.start_mode,
5442 if root { "root" } else { "the operator" }
5443 ),
5444 });
5445 }
5446 replace_definition(self.identity.name(), drift)?;
5447 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
5451 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
5452 let _ = record.write(&self.paths);
5453 }
5454 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
5455 }
5456
5457 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
5467 #[cfg(not(windows))]
5468 {
5469 let _ = (mode, &self.runner_root);
5472 Ok(RootAccessChange::not_applicable())
5473 }
5474 #[cfg(windows)]
5475 {
5476 let wrap = |source| ServiceError::RunnerRoot {
5477 source: Box::new(source),
5478 };
5479 if let Some(root) = self
5497 .runner_root
5498 .as_ref()
5499 .filter(|_| self.identity.is_fixture() || cfg!(test))
5500 {
5501 let admission = RootAdmission::of_this_account().map_err(wrap)?;
5502 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
5503 .map_err(wrap);
5504 }
5505
5506 let admission = match ServiceAccount::for_start_mode(mode) {
5507 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
5510 ServiceAccount::InvokingUser | ServiceAccount::Root => {
5514 RootAdmission::of_this_account().map_err(wrap)?
5515 }
5516 };
5517 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
5518 }
5519 }
5520
5521 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
5523 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
5524 |source| match source {
5525 held @ LockError::Held { .. } => ServiceError::LockHeld {
5526 source: Box::new(held),
5527 },
5528 other => ServiceError::LockUnreadable {
5529 source: Box::new(other),
5530 },
5531 },
5532 )
5533 }
5534
5535 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
5537 for mode in [StartMode::Boot, StartMode::Login] {
5538 let control = self.controls.control(mode)?;
5539 if let Some(registration) = control.query(&self.identity)? {
5540 return Ok(Some((registration.start_mode, registration)));
5541 }
5542 }
5543 Ok(None)
5544 }
5545
5546 fn registration_for_status(
5555 &self,
5556 ) -> Result<Option<(Registration, Option<ServiceDirectories>)>, ServiceError> {
5557 let Some((mode, found)) = self.find_registration()? else {
5558 return Ok(None);
5559 };
5560 let Some(directories) = self.foreign_directories(&found) else {
5561 return Ok(Some((found, None)));
5562 };
5563 let other = match mode {
5564 StartMode::Boot => StartMode::Login,
5565 StartMode::Login => StartMode::Boot,
5566 };
5567 let own = self
5568 .controls
5569 .control(other)
5570 .ok()
5571 .and_then(|control| control.query(&self.identity).ok().flatten())
5572 .filter(|registration| self.foreign_directories(registration).is_none());
5573 Ok(Some(match own {
5574 Some(own) => (own, None),
5575 None => (found, Some(directories)),
5576 }))
5577 }
5578
5579 pub fn adopt_boot_installation(
5597 &self,
5598 mode: StartMode,
5599 idle: &dyn Fn(&ServiceDirectories) -> Result<(), String>,
5600 ) -> Result<bool, ServiceError> {
5601 #[cfg(target_os = "linux")]
5602 return self.adopt_boot_installation_on_linux(mode, idle);
5603 #[cfg(not(target_os = "linux"))]
5604 {
5605 let _ = (mode, idle);
5606 Ok(false)
5607 }
5608 }
5609
5610 #[cfg(target_os = "linux")]
5611 fn adopt_boot_installation_on_linux(
5612 &self,
5613 mode: StartMode,
5614 idle: &dyn Fn(&ServiceDirectories) -> Result<(), String>,
5615 ) -> Result<bool, ServiceError> {
5616 if mode != StartMode::Boot {
5617 return Ok(false);
5618 }
5619 self.refuse_overridden_account()?;
5620 if !crate::host_fitness::is_elevated() {
5621 return Ok(false);
5622 }
5623 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5624 return Ok(false);
5625 };
5626 let Some(old) = self.foreign_directories(&found) else {
5627 return Ok(false);
5628 };
5629 let deferred = |reason: String| ServiceError::MoveDeferred {
5630 from: old.config.clone(),
5631 reason,
5632 };
5633 let unit = self.identity.systemd_unit();
5636 let trees = [&old.config, &old.state, &old.runtime, &old.logs];
5638 if let Some(drop_in) = [
5639 SYSTEMD_SYSTEM_DIR,
5640 "/run/systemd/system",
5641 "/usr/lib/systemd/system",
5642 ]
5643 .iter()
5644 .find_map(|base| {
5645 operator_drop_in_naming(&Path::new(base).join(format!("{unit}.d")), &trees)
5646 }) {
5647 return Err(deferred(format!(
5648 "{} names that tree, and the moved service could not see it; change or remove it, \
5649 run `sudo systemctl daemon-reload`, and run this again",
5650 drop_in.display()
5651 )));
5652 }
5653 let control = self.controls.control(StartMode::Boot)?;
5654 let was_running = control.stop(&self.identity)?;
5655 if let Err(reason) = idle(&old) {
5660 if was_running {
5662 control.start(&self.identity)?;
5663 }
5664 return Err(deferred(reason));
5665 }
5666 if matches!(InstallRecord::read(&self.paths), Ok(None)) {
5671 let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
5672 for (from, to, skip) in [
5673 (&old.config, self.paths.config_dir(), None),
5674 (&old.state, self.paths.state_dir(), Some("bin")),
5676 ] {
5677 set_aside(to, &stamp)
5678 .and_then(|()| copy_tree(from, to, skip))
5679 .map_err(|error| ServiceError::Control {
5680 operation: "move the data of",
5681 name: self.identity.name().to_string(),
5682 manager: "systemd",
5683 detail: format!(
5684 "cannot copy {} to {}: {error}",
5685 from.display(),
5686 to.display()
5687 ),
5688 })?;
5689 }
5690 }
5691 Ok(true)
5692 }
5693
5694 #[cfg(target_os = "linux")]
5701 fn refuse_overridden_account(&self) -> Result<(), ServiceError> {
5702 let Some((StartMode::Boot, found)) = self.find_registration()? else {
5703 return Ok(());
5704 };
5705 match found.account.as_deref() {
5706 Some(account) if account != ServiceAccount::Root.as_str() => {
5707 Err(ServiceError::Control {
5708 operation: "install",
5709 name: self.identity.name().to_string(),
5710 manager: "systemd",
5711 detail: format!(
5712 "a drop-in under {SYSTEMD_SYSTEM_DIR}/{}.d/ makes it run as {account}, and \
5713 a boot service must run as root to use {}. Remove `User=` and `Group=` \
5714 from that drop-in, run `sudo systemctl daemon-reload`, and install again",
5715 self.identity.systemd_unit(),
5716 crate::paths::SYSTEM_DATA_ROOT,
5717 ),
5718 })
5719 }
5720 _ => Ok(()),
5721 }
5722 }
5723
5724 fn refuse_foreign(&self, operation: &'static str) -> Result<(), ServiceError> {
5727 match self.registration_for_status()? {
5728 Some((_, Some(directories))) => Err(self.foreign_refusal(operation, directories)),
5729 _ => Ok(()),
5730 }
5731 }
5732
5733 fn foreign_refusal(
5736 &self,
5737 operation: &'static str,
5738 directories: ServiceDirectories,
5739 ) -> ServiceError {
5740 let foreign = self.foreign_service_at(directories);
5741 ServiceError::ForeignInstallation {
5742 name: self.identity.name().to_string(),
5743 operation,
5744 installed_by: foreign.installed_by(),
5745 act_as: foreign.act_as(),
5746 }
5747 }
5748
5749 fn foreign_directories(&self, registration: &Registration) -> Option<ServiceDirectories> {
5752 ServiceDirectories::from_command_line(®istration.command_line)
5753 .filter(|directories| !same_directory(&directories.config, self.paths.config_dir()))
5754 }
5755
5756 #[must_use]
5761 pub fn foreign_service(&self) -> Option<ForeignService> {
5762 let (_, directories) = self.registration_for_status().ok()??;
5763 Some(self.foreign_service_at(directories?))
5764 }
5765
5766 fn foreign_service_at(&self, directories: ServiceDirectories) -> ForeignService {
5769 let root = directories.root();
5770 let home = host_home();
5771 let this_account = home
5774 .as_deref()
5775 .filter(|home| contains_directory(home, &root))
5776 .and_then(|_| {
5777 if directories == ServiceDirectories::of(&AppPaths::rooted_at(&root)) {
5778 Some(format!(
5779 "with `--data-dir {}`",
5780 quote_argument(&root.to_string_lossy())
5781 ))
5782 } else {
5783 AppPaths::discover()
5784 .ok()
5785 .filter(|standard| directories == ServiceDirectories::of(standard))
5786 .map(|_| "without `--data-dir`".to_owned())
5787 }
5788 });
5789 ForeignService {
5790 owner: data_root_owner(&root, home.as_deref()),
5791 this_account,
5792 root,
5793 directories,
5794 local_root: ServiceDirectories::of(&self.paths).root(),
5795 local_record: InstallRecord::path(&self.paths),
5796 unused_local_record: InstallRecord::read(&self.paths).ok().flatten(),
5797 unreadable: None,
5798 }
5799 }
5800
5801 fn foreign_status(
5808 &self,
5809 registration: Registration,
5810 directories: ServiceDirectories,
5811 ) -> ServiceStatus {
5812 let service_paths = directories.paths();
5813 let mut foreign = self.foreign_service_at(directories);
5814 let unreadable = match InstallRecord::read(&service_paths) {
5815 Ok(Some(record)) => {
5816 match self.status_at(
5817 &service_paths,
5818 Some(record),
5819 None,
5820 Some(registration.clone()),
5821 ) {
5822 Ok(status) => return status.with_foreign(foreign),
5823 Err(error) => error.to_string(),
5824 }
5825 }
5826 Ok(None) => format!(
5827 "there is no install record at {}",
5828 InstallRecord::path(&service_paths).display()
5829 ),
5830 Err(
5835 ServiceError::RecordNotPermitted { path, detail }
5836 | ServiceError::RecordUnreadable { path, detail }
5837 | ServiceError::Record { path, detail, .. },
5838 ) => format!("this account cannot read {} ({detail})", path.display()),
5839 Err(error) => error.to_string(),
5840 };
5841 foreign.unreadable = Some(unreadable);
5842 ServiceStatus::foreign_unread(self.identity.clone(), registration, foreign)
5843 }
5844
5845 pub fn forget_local(&self, at: DateTime<Utc>) -> Result<ForgottenLocal, ServiceError> {
5869 let config = self.paths.config_dir();
5870 let in_use = |detail: String| ServiceError::LocalConfigurationInUse { detail };
5871 let mut service_root = None;
5872 for mode in [StartMode::Boot, StartMode::Login] {
5873 let registration = self
5874 .controls
5875 .control(mode)
5876 .and_then(|control| control.query(&self.identity))
5877 .map_err(|error| {
5878 in_use(format!(
5879 "whether a {mode} registration runs against it could not be read ({error})"
5880 ))
5881 })?;
5882 let Some(registration) = registration else {
5883 continue;
5884 };
5885 let Some(directories) = self.foreign_directories(®istration) else {
5886 return Err(in_use(format!(
5887 "{} holds a registration that runs against {}. `service status` describes it",
5888 registration.manager,
5889 config.display()
5890 )));
5891 };
5892 if let Some(overlap) = directories.all().into_iter().find(|directory| {
5893 contains_directory(directory, config) || contains_directory(config, directory)
5894 }) {
5895 return Err(in_use(format!(
5896 "the machine's service runs against {}, which overlaps {}",
5897 overlap.display(),
5898 config.display()
5899 )));
5900 }
5901 service_root.get_or_insert_with(|| directories.root());
5902 }
5903 let _lock =
5904 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(|source| {
5905 in_use(format!(
5906 "an agent may be running against these directories: {source}"
5907 ))
5908 })?;
5909 let failed = |operation: &'static str, path: &Path| {
5910 let path = path.to_path_buf();
5911 move |error: std::io::Error| ServiceError::Record {
5912 operation,
5913 path,
5914 detail: error.to_string(),
5915 }
5916 };
5917 let kept = [
5919 self.paths.state_dir(),
5920 self.paths.runtime_dir(),
5921 self.paths.logs_dir(),
5922 ];
5923 let mut entries = Vec::new();
5924 for entry in std::fs::read_dir(config).map_err(failed("list", config))? {
5925 let entry = entry.map_err(failed("list", config))?.path();
5926 let archived = entry
5927 .file_name()
5928 .is_some_and(|name| name.to_string_lossy().starts_with(FORGOTTEN_PREFIX));
5929 if !archived && !kept.iter().any(|kept| contains_directory(&entry, kept)) {
5930 entries.push(entry);
5931 }
5932 }
5933 if entries.is_empty() {
5934 return Ok(ForgottenLocal {
5935 archive: None,
5936 moved: entries,
5937 service_root,
5938 });
5939 }
5940 entries.sort();
5941 let archive = config.join(format!("{FORGOTTEN_PREFIX}{}", at.format("%Y%m%dT%H%M%SZ")));
5942 std::fs::create_dir(&archive).map_err(failed("create", &archive))?;
5943 let inside = |entry: &Path| archive.join(entry.file_name().unwrap_or_default());
5944 for (index, entry) in entries.iter().enumerate() {
5945 if let Err(error) = std::fs::rename(entry, inside(entry)) {
5946 let stranded: Vec<String> = entries[..index]
5947 .iter()
5948 .rev()
5949 .filter(|done| std::fs::rename(inside(done), done).is_err())
5950 .map(|done| inside(done).display().to_string())
5951 .collect();
5952 let _ = std::fs::remove_dir(&archive);
5953 let mut detail = error.to_string();
5954 if !stranded.is_empty() {
5955 detail.push_str(&format!(
5956 "; these could not be put back and are still in the archive: {}",
5957 stranded.join(", ")
5958 ));
5959 }
5960 return Err(ServiceError::Record {
5961 operation: "archive",
5962 path: entry.clone(),
5963 detail,
5964 });
5965 }
5966 }
5967 Ok(ForgottenLocal {
5968 archive: Some(archive),
5969 moved: entries,
5970 service_root,
5971 })
5972 }
5973}
5974
5975pub const FORGOTTEN_PREFIX: &str = "forgotten-";
5978
5979#[derive(Debug, Clone, PartialEq, Eq)]
5981pub struct ForgottenLocal {
5982 pub archive: Option<PathBuf>,
5984 pub moved: Vec<PathBuf>,
5986 pub service_root: Option<PathBuf>,
5989}
5990
5991impl fmt::Display for ForgottenLocal {
5992 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5993 let Some(archive) = &self.archive else {
5994 return write!(
5995 f,
5996 "Nothing to archive: this account's configuration directory is empty."
5997 );
5998 };
5999 writeln!(f, "Archived this account's unused local configuration")?;
6000 writeln!(f, " archive {}", archive.display())?;
6001 for path in &self.moved {
6002 writeln!(f, " moved {}", path.display())?;
6003 }
6004 if let Some(root) = &self.service_root {
6005 writeln!(
6006 f,
6007 " service data root {} (untouched)",
6008 root.display()
6009 )?;
6010 }
6011 write!(
6012 f,
6013 " restore move everything in the archive back into its parent \
6014 directory"
6015 )
6016 }
6017}
6018
6019fn same_directory(left: &Path, right: &Path) -> bool {
6022 let text = |path: &Path| {
6023 path.to_string_lossy()
6024 .trim_end_matches(['/', '\\'])
6025 .to_owned()
6026 };
6027 same_path_text(&text(left), &text(right))
6028 || matches!(
6029 (std::fs::canonicalize(left), std::fs::canonicalize(right)),
6030 (Ok(left), Ok(right)) if left == right
6031 )
6032}
6033
6034const REMEDY_OPENINGS: [&str; 8] = [
6037 ". Run ",
6038 ". If nothing is waiting",
6039 ". Re-register",
6040 ". Switch the start mode",
6041 " Sign in again",
6042 ". Fix: ",
6043 "; to do it now",
6044 "; if the service manager denies",
6045];
6046
6047fn diagnosis_only(detail: &str) -> String {
6056 let cut = REMEDY_OPENINGS
6057 .iter()
6058 .filter_map(|opening| detail.find(opening))
6059 .min()
6060 .unwrap_or(detail.len());
6061 let diagnosis = detail[..cut].trim_end_matches(['.', ' ']);
6062 let names_a_change = |text: &str| {
6063 [
6064 "service install",
6065 "service uninstall",
6066 "auth login",
6067 "launchctl",
6068 "systemctl",
6069 ]
6070 .iter()
6071 .any(|command| text.contains(command))
6072 };
6073 if !names_a_change(diagnosis) {
6074 return diagnosis.to_owned();
6075 }
6076 let end = [", ", "; ", ". "]
6078 .iter()
6079 .filter_map(|separator| diagnosis.find(separator))
6080 .min()
6081 .unwrap_or(diagnosis.len());
6082 let first = &diagnosis[..end];
6083 if names_a_change(first) {
6084 "the installation's own records report this".to_owned()
6085 } else {
6086 first.to_owned()
6087 }
6088}
6089
6090fn path_key(path: &Path) -> Vec<String> {
6093 path.components()
6094 .map(|component| {
6095 let text = component.as_os_str().to_string_lossy();
6096 if cfg!(windows) {
6097 text.to_lowercase()
6098 } else {
6099 text.into_owned()
6100 }
6101 })
6102 .collect()
6103}
6104
6105fn contains_directory(outer: &Path, inner: &Path) -> bool {
6108 path_key(inner).starts_with(&path_key(outer))
6109 || matches!(
6110 (std::fs::canonicalize(outer), std::fs::canonicalize(inner)),
6111 (Ok(outer), Ok(inner)) if inner.starts_with(&outer)
6112 )
6113}
6114
6115#[must_use]
6120pub fn data_root_owner(root: &Path, home: Option<&Path>) -> Option<String> {
6121 let target = path_key(root);
6122 if cfg!(windows) {
6123 if target
6124 .windows(3)
6125 .any(|run| run == ["system32", "config", "systemprofile"])
6126 {
6127 return Some("LocalSystem".to_owned());
6128 }
6129 } else if [
6130 "/root",
6131 "/var/root",
6132 "/private/var/root",
6133 crate::paths::SYSTEM_DATA_ROOT,
6134 ]
6135 .iter()
6136 .any(|home| root.starts_with(home))
6137 {
6138 return Some("root".to_owned());
6139 }
6140 let users = path_key(home?.parent()?);
6141 if users.len() < 2 {
6144 return None;
6145 }
6146 if target.len() > users.len() && target[..users.len()] == users[..] {
6147 root.components()
6148 .nth(users.len())
6149 .map(|name| name.as_os_str().to_string_lossy().into_owned())
6150 } else {
6151 None
6152 }
6153}
6154
6155#[derive(Debug, Clone, PartialEq, Eq)]
6169pub struct ForeignService {
6170 pub directories: ServiceDirectories,
6172 pub root: PathBuf,
6174 pub owner: Option<String>,
6176 pub this_account: Option<String>,
6181 pub local_root: PathBuf,
6183 pub local_record: PathBuf,
6185 pub unused_local_record: Option<InstallRecord>,
6187 pub unreadable: Option<String>,
6190}
6191
6192impl ForeignService {
6193 #[must_use]
6195 pub fn where_to_look(&self) -> String {
6196 if let Some(reach) = &self.this_account {
6197 return reach.clone();
6198 }
6199 let elevated = if cfg!(windows) {
6200 "from an elevated terminal (Run as administrator)"
6201 } else {
6202 "with sudo"
6203 };
6204 match &self.owner {
6205 Some(owner) if owner != "LocalSystem" && owner != "root" => {
6206 format!("{elevated}, or signed in as {owner}")
6207 }
6208 _ => elevated.to_owned(),
6209 }
6210 }
6211
6212 #[must_use]
6217 pub fn act_as(&self) -> String {
6218 if let Some(reach) = &self.this_account {
6219 return reach.clone();
6220 }
6221 match &self.owner {
6222 Some(owner) if owner != "LocalSystem" && owner != "root" => {
6223 format!("signed in as {owner}")
6224 }
6225 _ => format!(
6226 "from the account whose data root {} is",
6227 self.root.display()
6228 ),
6229 }
6230 }
6231
6232 #[must_use]
6235 pub const fn is_unread(&self) -> bool {
6236 self.unreadable.is_some()
6237 }
6238
6239 #[must_use]
6241 pub fn installed_by(&self) -> String {
6242 match &self.owner {
6243 Some(owner) => format!("{owner}'s data root {}", self.root.display()),
6244 None => format!("the data root {}", self.root.display()),
6245 }
6246 }
6247
6248 #[must_use]
6250 pub fn label(&self) -> String {
6251 match &self.owner {
6252 Some(owner) => format!("{} ({owner})", self.root.display()),
6253 None => self.root.display().to_string(),
6254 }
6255 }
6256
6257 #[must_use]
6259 pub fn unused_local_note(&self) -> Option<String> {
6260 let record = self.unused_local_record.as_ref()?;
6261 Some(format!(
6262 "unused local configuration from an earlier install: {} records a {} registration \
6263 made by runner-manager {} on {}, and no service on this machine runs against it. \
6264 `runner-manager host forget-local` moves it aside; it never touches the service's \
6265 own directories or any secret.",
6266 self.local_record.display(),
6267 record.start_mode,
6268 record.installed_by_version,
6269 record.installed_at.format("%Y-%m-%d"),
6270 ))
6271 }
6272}
6273
6274#[derive(Debug, Clone, PartialEq, Eq)]
6280pub struct StatusProblem {
6281 pub subject: &'static str,
6283 pub detail: String,
6285}
6286
6287impl fmt::Display for StatusProblem {
6288 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6289 write!(f, "{}: {}", self.subject, self.detail)
6290 }
6291}
6292
6293#[derive(Debug, Clone)]
6302pub struct ServiceStatus {
6303 identity: ServiceIdentity,
6304 record: Option<InstallRecord>,
6305 registration: Option<Registration>,
6306 binary: Option<BinaryPath>,
6307 log_file: PathBuf,
6308 store: Option<crate::secrets::ActiveStore>,
6309 last_github_contact: Option<DateTime<Utc>>,
6310 runner_root: Option<(PathBuf, RootAccessReport)>,
6311 credential_rejected_since: Option<DateTime<Utc>>,
6312 launches_blocked: Option<crate::launch_health::LaunchesBlocked>,
6313 unattended_gap: Option<UnattendedGap>,
6314 foreign: Option<ForeignService>,
6315 problems: Vec<StatusProblem>,
6316 notes: Vec<String>,
6317}
6318
6319#[derive(Debug, Clone, PartialEq, Eq)]
6322pub struct UnattendedGap {
6323 pub reason: String,
6325 pub remedy: String,
6327}
6328
6329#[must_use]
6332pub fn service_account_name(account: &ServiceAccount) -> String {
6333 match account {
6334 ServiceAccount::InvokingUser => std::env::var("SUDO_USER")
6336 .ok()
6337 .filter(|user| !user.is_empty() && crate::host_fitness::is_elevated())
6338 .or_else(crate::unattended_login::current_account)
6339 .unwrap_or_else(|| "the operator".to_owned()),
6340 other => other.as_str().to_owned(),
6341 }
6342}
6343
6344fn registered_account<'a>(registration: Option<&'a Registration>, recorded: &'a str) -> &'a str {
6347 registration
6348 .and_then(|registration| registration.account.as_deref())
6349 .unwrap_or(recorded)
6350}
6351
6352#[cfg(any(windows, test))]
6356fn windows_service_account(name: &str) -> String {
6357 if name.eq_ignore_ascii_case("LocalSystem") || name.eq_ignore_ascii_case(".\\LocalSystem") {
6358 ServiceAccount::LocalSystem.as_str().to_string()
6359 } else {
6360 name.to_string()
6361 }
6362}
6363
6364#[cfg(any(windows, test))]
6367fn scheduled_task_account(user_id: Option<String>) -> Option<String> {
6368 user_id.filter(|id| !id.to_ascii_uppercase().starts_with("S-1-"))
6369}
6370
6371#[must_use]
6380pub fn unattended_gap(
6381 mode: StartMode,
6382 account: &str,
6383 found: Option<&crate::unattended_login::UnattendedLogin>,
6384) -> Option<UnattendedGap> {
6385 let Some(found) = found else {
6386 return (mode == StartMode::Login).then(|| UnattendedGap {
6387 reason: "This registration starts at login, so the agent does not run until the \
6388 operator signs in; this host does not resume work after an unattended \
6389 reboot."
6390 .to_owned(),
6391 remedy: "For machine-on operation, move the service and credential to `--start-at \
6392 boot` from an elevated terminal."
6393 .to_owned(),
6394 });
6395 };
6396 let resume = crate::unattended_login::resume(found, mode, account);
6397 if matches!(resume, crate::unattended_login::Resume::Unknown(_)) {
6398 return None;
6401 }
6402 Some(UnattendedGap {
6403 reason: resume.explain(account)?,
6404 remedy: resume.remedy().unwrap_or_else(|| {
6405 "Run `runner-manager host doctor` on the Mac's desktop, where automatic login and \
6406 FileVault can be read."
6407 .to_owned()
6408 }),
6409 })
6410}
6411
6412impl ServiceStatus {
6413 fn compose(
6418 identity: ServiceIdentity,
6419 record: Option<InstallRecord>,
6420 record_refused: Option<&ServiceError>,
6421 registration: Option<Registration>,
6422 last_github_contact: Option<DateTime<Utc>>,
6423 paths: &AppPaths,
6424 ) -> Self {
6425 let mut problems = Vec::new();
6426 let mut notes = Vec::new();
6427
6428 if let Some(refusal) = record_refused {
6429 problems.push(StatusProblem {
6434 subject: "install record",
6435 detail: refusal.to_string(),
6436 });
6437 }
6438
6439 let log_file = record.as_ref().map_or_else(
6440 || ServiceDirectories::of(paths).log_file(),
6441 |record| record.log_file.clone(),
6442 );
6443
6444 let binary = record.as_ref().map(|record| {
6445 inspect_binary(
6446 &record.binary,
6447 registration
6448 .as_ref()
6449 .and_then(Registration::binary)
6450 .as_deref(),
6451 )
6452 });
6453 if let Some(state) = &binary
6454 && state.is_error()
6455 {
6456 problems.push(StatusProblem {
6457 subject: "binary",
6458 detail: state.to_string(),
6459 });
6460 }
6461
6462 match (&record, ®istration) {
6463 (Some(_), None) => problems.push(StatusProblem {
6464 subject: "registration",
6465 detail: "this host has a service record but no service manager knows the \
6466 registration. Run `service install` again; it deletes nothing."
6467 .to_string(),
6468 }),
6469 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
6474 subject: "record",
6475 detail: format!(
6476 "{} holds a registration for this service but there is no install record, so \
6477 the path it was installed from and the directories it was installed against \
6478 are unknown. Run `service uninstall` and `service install`.",
6479 found.manager
6480 ),
6481 }),
6482 (None, Some(_)) => {}
6483 (Some(record), Some(found)) => {
6484 if record.start_mode != found.start_mode {
6485 problems.push(StatusProblem {
6486 subject: "start mode",
6487 detail: format!(
6488 "the record says {} and {} holds a {} registration. Switch the start \
6489 mode again to make them agree.",
6490 record.start_mode, found.manager, found.start_mode
6491 ),
6492 });
6493 }
6494 if record.start_mode == StartMode::Boot && !found.starts_automatically {
6495 problems.push(StatusProblem {
6496 subject: "start mode",
6497 detail: format!(
6498 "{} holds the registration but will not start it by itself, so this \
6499 host does not resume work after a reboot.",
6500 found.manager
6501 ),
6502 });
6503 }
6504 if let Some(actual) = found.restart_delay {
6505 let expected = record.restart().effective_delay(found.manager);
6506 if actual != expected {
6507 problems.push(StatusProblem {
6508 subject: "restart policy",
6509 detail: format!(
6510 "the record says the service restarts after {}s and {} reports \
6511 {}s. Something has edited the registration since it was \
6512 installed.",
6513 expected.as_secs(),
6514 found.manager,
6515 actual.as_secs()
6516 ),
6517 });
6518 } else if expected != record.restart().delay() {
6519 notes.push(format!(
6524 "{} expresses the restart delay in whole minutes, so the {}s asked \
6525 for is enforced as {}s. The service therefore never restarts faster \
6526 than the configured bound.",
6527 found.manager,
6528 record.restart().delay().as_secs(),
6529 expected.as_secs()
6530 ));
6531 }
6532 }
6533 if found.starts_automatically && !found.running {
6534 problems.push(StatusProblem {
6535 subject: "runtime",
6536 detail: format!(
6537 "{} holds an automatic registration, but the daemon is stopped. Run \
6538 `runner-manager service install --start-at {}`; if the service \
6539 manager denies replacement, retry from an elevated terminal. If it \
6540 stops again, inspect {}.",
6541 found.manager,
6542 found.start_mode,
6543 log_file.display()
6544 ),
6545 });
6546 }
6547 }
6548 (None, None) => {}
6549 }
6550
6551 let store = record.as_ref().and_then(|record| {
6556 let registered_mode = registration
6557 .as_ref()
6558 .map_or(record.start_mode, |found| found.start_mode);
6559 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
6560 .ok()
6561 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
6562 });
6563 if let Some(store) = &store
6564 && !store.agrees_with_start_mode()
6565 {
6566 problems.push(StatusProblem {
6567 subject: "secret store",
6568 detail: format!(
6569 "{store}. Run `auth login` again so the token is stored where the registered \
6570 start mode can read it."
6571 ),
6572 });
6573 }
6574
6575 if last_github_contact.is_none() {
6576 notes.push(
6577 "GitHub has not been reached successfully since this host's state directory was \
6578 created."
6579 .to_string(),
6580 );
6581 }
6582
6583 let runner_root = crate::runner_root::default_runner_root(paths)
6590 .ok()
6591 .map(|root| {
6592 let path = root.as_path().to_path_buf();
6593 let report = crate::runner_root_access::report(&path);
6594 (path, report)
6595 });
6596 if let Some((
6606 path,
6607 RootAccessReport::Present {
6608 broad_write: true, ..
6609 },
6610 )) = &runner_root
6611 {
6612 notes.push(format!(
6613 "the platform default runner root {} can be written by ordinary local users, so \
6614 it is not a safe place to run jobs. `service install` refuses it rather than \
6615 tightening it, because the contents of a directory anybody could write cannot \
6616 be trusted: remove or empty it, or choose another root with `runner-manager \
6617 host set-runtime-root --path <PATH>`.",
6618 path.display()
6619 ));
6620 }
6621
6622 match runner_root_refusals(paths) {
6645 Ok(refusals) => {
6646 for refusal in refusals {
6647 notes.push(format!(
6648 "policy {} started no runner: its runner root {} refused the launch \
6649 ({}), last at {}. {} This clears when that policy next places a \
6650 runner.",
6651 refusal.policy,
6652 refusal.root,
6653 refusal.kind,
6654 refusal.at.to_rfc3339(),
6655 refusal.detail,
6656 ));
6657 }
6658 }
6659 Err(error) => notes.push(format!(
6660 "whether the agent could use its runner roots could not be read: {error}"
6661 )),
6662 }
6663
6664 Self {
6665 identity,
6666 record,
6667 registration,
6668 binary,
6669 log_file,
6670 store,
6671 last_github_contact,
6672 runner_root,
6673 credential_rejected_since: None,
6674 launches_blocked: None,
6675 unattended_gap: None,
6676 foreign: None,
6677 problems,
6678 notes,
6679 }
6680 }
6681
6682 fn with_foreign(mut self, foreign: ForeignService) -> Self {
6693 let act_as = foreign.act_as();
6694 for problem in &mut self.problems {
6695 problem.detail = format!(
6696 "{}. Act on it {act_as}: a remedy run from this account acts on this account's \
6697 own directories, not the service's.",
6698 diagnosis_only(&problem.detail)
6699 );
6700 }
6701 if let Some(note) = foreign.unused_local_note() {
6702 self.notes.push(note);
6703 }
6704 self.foreign = Some(foreign);
6705 self
6706 }
6707
6708 fn foreign_unread(
6716 identity: ServiceIdentity,
6717 registration: Registration,
6718 foreign: ForeignService,
6719 ) -> Self {
6720 let mut problems = Vec::new();
6721 let look = foreign.where_to_look();
6722 if registration.starts_automatically && !registration.running {
6723 problems.push(StatusProblem {
6724 subject: "runtime",
6725 detail: format!(
6726 "{} holds an automatic registration for the service installed from {}, but \
6727 it is stopped. Run `runner-manager service status` {look} to see why; do not \
6728 reinstall it from this account.",
6729 registration.manager,
6730 foreign.installed_by(),
6731 ),
6732 });
6733 }
6734 if registration.start_mode == StartMode::Boot && !registration.starts_automatically {
6735 problems.push(StatusProblem {
6736 subject: "start mode",
6737 detail: format!(
6738 "{} holds the boot registration installed from {} but will not start it by \
6739 itself. Run `runner-manager service status` {look}.",
6740 registration.manager,
6741 foreign.installed_by(),
6742 ),
6743 });
6744 }
6745 let mut notes = Vec::new();
6746 if let Some(reason) = &foreign.unreadable {
6747 notes.push(format!(
6748 "its health is not known here: {reason}. Run `runner-manager service status` \
6749 {look} to see it."
6750 ));
6751 }
6752 notes.extend(foreign.unused_local_note());
6753 Self {
6754 identity,
6755 record: None,
6756 log_file: foreign.directories.log_file(),
6757 registration: Some(registration),
6758 binary: None,
6759 store: None,
6760 last_github_contact: None,
6761 runner_root: None,
6762 credential_rejected_since: None,
6763 launches_blocked: None,
6764 unattended_gap: None,
6765 foreign: Some(foreign),
6766 problems,
6767 notes,
6768 }
6769 }
6770
6771 #[must_use]
6774 pub const fn foreign(&self) -> Option<&ForeignService> {
6775 self.foreign.as_ref()
6776 }
6777
6778 #[must_use]
6781 pub fn is_unread(&self) -> bool {
6782 self.foreign.as_ref().is_some_and(ForeignService::is_unread)
6783 }
6784
6785 #[must_use]
6787 pub fn verdict(&self) -> String {
6788 if !self.is_healthy() {
6789 return "NOT healthy".to_owned();
6790 }
6791 match &self.foreign {
6792 Some(foreign) if foreign.is_unread() => format!(
6793 "{}; installed from {}, so its health is read {}",
6794 if self.is_running() {
6795 "running"
6796 } else {
6797 "not running"
6798 },
6799 foreign.installed_by(),
6800 foreign.where_to_look()
6801 ),
6802 _ => "healthy".to_owned(),
6803 }
6804 }
6805
6806 #[must_use]
6808 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
6809 if let Some(drift) = drift {
6810 if drift.manager == DefinitionKind::LaunchdPlist {
6811 let restart = match drift.start_mode {
6815 StartMode::Boot => format!(
6816 "sudo launchctl kickstart -k system/{}",
6817 self.identity.launchd_label()
6818 ),
6819 StartMode::Login => format!(
6820 "launchctl kickstart -k gui/$(id -u)/{}",
6821 self.identity.launchd_label()
6822 ),
6823 };
6824 self.problems.push(StatusProblem {
6825 subject: "definition",
6826 detail: format!(
6827 "{} was written by runner-manager {} with a `ProcessType` other than \
6828 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
6829 daemon starts, so their jobs run below normal priority. The daemon \
6830 rewrites it and reloads itself when it starts with no runner, which it \
6831 does after every upgrade; to do it now, restart the service while no \
6832 job is running: `{restart}`. Configuration and credentials are kept.",
6833 drift.path.display(),
6834 drift.installed_by_version,
6835 ),
6836 });
6837 return self;
6838 }
6839 let repair = match drift.start_mode {
6840 StartMode::Boot => format!(
6841 "`sudo systemctl stop runner-manager && {}` (for a managed WSL host, run \
6842 `runner-manager wsl install --distribution <name>` from Windows)",
6843 sudo_command("service install --start-at boot")
6844 ),
6845 StartMode::Login => "`systemctl --user stop runner-manager && runner-manager \
6846 service install --start-at login`"
6847 .to_owned(),
6848 };
6849 self.problems.push(StatusProblem {
6850 subject: "definition",
6851 detail: format!(
6852 "{} was written by runner-manager {} and differs from what {} renders, so \
6853 the daemon runs under outdated settings (for example a credential frozen \
6854 at service start). Re-register it with {repair}; configuration and \
6855 credentials are kept.",
6856 drift.path.display(),
6857 drift.installed_by_version,
6858 env!("CARGO_PKG_VERSION"),
6859 ),
6860 });
6861 }
6862 self
6863 }
6864
6865 #[must_use]
6867 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
6868 if let Some(since) = since {
6869 self.credential_rejected_since = Some(since);
6870 self.problems.push(StatusProblem {
6871 subject: "github credential",
6872 detail: format!(
6873 "GitHub has rejected the service's credential since {}, so the service \
6874 cannot see queued jobs or start runners. Run `runner-manager auth login` \
6875 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
6876 from Windows).",
6877 since.to_rfc3339()
6878 ),
6879 });
6880 }
6881 self
6882 }
6883
6884 #[must_use]
6886 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
6887 self.credential_rejected_since
6888 }
6889
6890 #[must_use]
6893 pub fn with_launches_blocked(
6894 mut self,
6895 blocked: Option<crate::launch_health::LaunchesBlocked>,
6896 ) -> Self {
6897 if let Some(blocked) = &blocked {
6898 self.problems.push(StatusProblem {
6899 subject: LAUNCHES_BLOCKED_SUBJECT,
6900 detail: format!(
6901 "the service has started no runner {blocked}. Fix: {}.",
6902 blocked.remedy
6903 ),
6904 });
6905 }
6906 self.launches_blocked = blocked;
6907 self
6908 }
6909
6910 #[must_use]
6912 pub const fn launches_blocked(&self) -> Option<&crate::launch_health::LaunchesBlocked> {
6913 self.launches_blocked.as_ref()
6914 }
6915
6916 #[must_use]
6919 pub fn with_credential_unreadable(mut self, since: Option<DateTime<Utc>>) -> Self {
6920 if let Some(since) = since {
6921 let mode = self.start_mode().unwrap_or_default();
6922 self.problems.push(StatusProblem {
6923 subject: CREDENTIAL_UNREADABLE_SUBJECT,
6924 detail: format!(
6925 "the service has been unable to read its stored GitHub credential since {}, \
6926 so it starts no runner; the diagnostic log has the store's own answer.{} \
6927 Sign in again: {}.",
6928 since.to_rfc3339(),
6929 if cfg!(target_os = "macos") {
6930 " That is what a keychain item written by a different build looks like."
6931 } else {
6932 ""
6933 },
6934 sign_in_instruction(mode)
6935 ),
6936 });
6937 }
6938 self
6939 }
6940
6941 #[must_use]
6949 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
6950 self.runner_root
6951 .as_ref()
6952 .map(|(path, report)| (path.as_path(), report))
6953 }
6954
6955 #[must_use]
6957 pub const fn is_installed(&self) -> bool {
6958 self.registration.is_some() || self.record.is_some()
6959 }
6960
6961 #[must_use]
6963 pub fn is_running(&self) -> bool {
6964 self.registration
6965 .as_ref()
6966 .is_some_and(|registration| registration.running)
6967 }
6968
6969 #[must_use]
6971 pub fn is_healthy(&self) -> bool {
6972 self.problems.is_empty()
6973 }
6974
6975 #[must_use]
6977 pub fn problems(&self) -> &[StatusProblem] {
6978 &self.problems
6979 }
6980
6981 #[must_use]
6984 pub fn notes(&self) -> &[String] {
6985 &self.notes
6986 }
6987
6988 #[must_use]
6993 pub fn with_host_unfit(mut self, unfit: Option<crate::host_fitness::HostUnfitRecord>) -> Self {
6994 if let Some(unfit) = unfit {
6995 self.problems.push(StatusProblem {
6996 subject: HOST_UNFIT_SUBJECT,
6997 detail: format!(
6998 "the service has started no runner since {}, because a required host check \
6999 fails from its own session: {}",
7000 unfit.since.to_rfc3339(),
7001 unfit.describe()
7002 ),
7003 });
7004 }
7005 self
7006 }
7007
7008 #[must_use]
7012 pub fn with_stall(mut self, stall: Option<String>) -> Self {
7013 if let Some(detail) = stall {
7014 self.problems.push(StatusProblem {
7015 subject: DAEMON_STALLED_SUBJECT,
7016 detail,
7017 });
7018 }
7019 self
7020 }
7021
7022 #[must_use]
7025 pub fn with_unattended_gap(mut self, gap: Option<UnattendedGap>) -> Self {
7026 if let Some(gap) = &gap {
7027 self.notes.push(gap.reason.clone());
7028 }
7029 self.unattended_gap = gap;
7030 self
7031 }
7032
7033 #[must_use]
7036 pub const fn unattended_gap(&self) -> Option<&UnattendedGap> {
7037 self.unattended_gap.as_ref()
7038 }
7039
7040 #[must_use]
7043 pub fn restart_command(&self) -> String {
7044 self.identity
7045 .restart_command(self.start_mode().unwrap_or(StartMode::Boot))
7046 }
7047
7048 #[must_use]
7052 pub fn start_mode(&self) -> Option<StartMode> {
7053 self.record
7054 .as_ref()
7055 .map(|record| record.start_mode)
7056 .or_else(|| {
7057 self.registration
7058 .as_ref()
7059 .filter(|_| self.is_unread())
7060 .map(|registration| registration.start_mode)
7061 })
7062 }
7063
7064 #[must_use]
7068 pub fn service_binary(&self) -> Option<PathBuf> {
7069 self.record
7070 .as_ref()
7071 .map(|record| record.binary.clone())
7072 .or_else(|| {
7073 self.registration
7074 .as_ref()
7075 .filter(|_| self.is_unread())
7076 .and_then(Registration::binary)
7077 })
7078 }
7079
7080 #[must_use]
7083 pub const fn binary(&self) -> Option<&BinaryPath> {
7084 self.binary.as_ref()
7085 }
7086
7087 #[must_use]
7089 pub fn log_file(&self) -> &Path {
7090 &self.log_file
7091 }
7092
7093 #[must_use]
7095 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
7096 self.last_github_contact
7097 }
7098
7099 #[must_use]
7102 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
7103 self.store.as_ref()
7104 }
7105
7106 #[must_use]
7108 pub const fn record(&self) -> Option<&InstallRecord> {
7109 self.record.as_ref()
7110 }
7111
7112 #[must_use]
7114 pub const fn registration(&self) -> Option<&Registration> {
7115 self.registration.as_ref()
7116 }
7117}
7118
7119impl fmt::Display for ServiceStatus {
7120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7121 writeln!(f, "Service: {}", self.identity)?;
7122 match (&self.record, &self.registration) {
7123 (None, None) => {
7124 writeln!(
7125 f,
7126 " installed no. `service install` registers `{} {}`.",
7127 SERVICE_NAME,
7128 DAEMON_ARGUMENTS.join(" ")
7129 )?;
7130 }
7131 _ => {
7132 let manager = self
7133 .registration
7134 .as_ref()
7135 .map(|registration| registration.manager.manager());
7136 writeln!(
7137 f,
7138 " installed {}",
7139 manager.unwrap_or("yes, but no service manager knows it")
7140 )?;
7141 writeln!(
7142 f,
7143 " state {}",
7144 if self.is_running() {
7145 "running"
7146 } else {
7147 "not running"
7148 }
7149 )?;
7150 }
7151 }
7152 if let Some(foreign) = &self.foreign {
7153 writeln!(
7154 f,
7155 " installed from {} -- another installation's, not this account's ({})",
7156 foreign.installed_by(),
7157 foreign.local_root.display()
7158 )?;
7159 if foreign.is_unread()
7161 && let Some(registration) = &self.registration
7162 {
7163 writeln!(f, " start mode {}", registration.start_mode)?;
7164 if let Some(account) = ®istration.account {
7165 writeln!(f, " account {account}")?;
7166 }
7167 if let Some(binary) = self.service_binary() {
7168 writeln!(f, " binary {}", binary.display())?;
7169 }
7170 }
7171 }
7172 if let Some(record) = &self.record {
7173 writeln!(f, " start mode {}", record.start_mode)?;
7174 let recorded = record.account.to_string();
7175 let account = registered_account(self.registration.as_ref(), &recorded);
7176 writeln!(f, " account {account}")?;
7177 writeln!(f, " restart on failure {}", record.restart())?;
7178 writeln!(
7179 f,
7180 " arguments {}",
7181 record.arguments.join(" ")
7182 )?;
7183 }
7184 if let Some(binary) = &self.binary {
7185 writeln!(f, " binary {binary}")?;
7186 }
7187 writeln!(f, " diagnostic log {}", self.log_file.display())?;
7188 if let Some((path, report)) = &self.runner_root {
7189 writeln!(f, " default runner root {}", path.display())?;
7196 if *report != RootAccessReport::NotApplicable {
7197 writeln!(f, " default root access {report}")?;
7198 }
7199 }
7200 if let Some(store) = &self.store {
7201 writeln!(f, " secret store {store}")?;
7202 }
7203 if !self.is_unread() {
7206 writeln!(
7207 f,
7208 " last GitHub contact {}",
7209 match self.last_github_contact {
7210 Some(at) => at.to_rfc3339(),
7211 None => "never".to_string(),
7212 }
7213 )?;
7214 }
7215 for note in &self.notes {
7216 writeln!(f, " note {note}")?;
7217 }
7218 for problem in &self.problems {
7219 writeln!(f, " ERROR {problem}")?;
7220 }
7221 write!(f, " verdict {}", self.verdict())
7222 }
7223}
7224
7225#[derive(Debug, Clone, Default)]
7242pub struct RecordingControls {
7243 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7244}
7245
7246#[derive(Debug, Default)]
7247struct RecordingState {
7248 registrations: BTreeMap<(StartMode, String), Registration>,
7249 definitions: BTreeMap<String, ServiceDefinition>,
7250 calls: Vec<String>,
7251 #[cfg(test)]
7252 install_failures: BTreeMap<StartMode, String>,
7253 #[cfg(test)]
7254 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
7255}
7256
7257#[cfg(test)]
7258#[derive(Debug, Clone)]
7259enum TestInstallSideEffect {
7260 HideDirectory { directory: PathBuf, hidden: PathBuf },
7261}
7262
7263impl RecordingControls {
7264 #[must_use]
7266 pub fn new() -> Self {
7267 Self::default()
7268 }
7269
7270 #[must_use]
7273 pub fn calls(&self) -> Vec<String> {
7274 self.state.lock().expect("not poisoned").calls.clone()
7275 }
7276
7277 #[must_use]
7279 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
7280 self.state
7281 .lock()
7282 .expect("not poisoned")
7283 .registrations
7284 .iter()
7285 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
7286 .collect()
7287 }
7288
7289 #[must_use]
7291 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
7292 self.state
7293 .lock()
7294 .expect("not poisoned")
7295 .definitions
7296 .get(name)
7297 .cloned()
7298 }
7299
7300 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
7310 let mut state = self.state.lock().expect("not poisoned");
7311 if let Some((_, registration)) = state
7312 .registrations
7313 .iter_mut()
7314 .find(|((_, held), _)| held == name)
7315 {
7316 edit(registration);
7317 }
7318 }
7319
7320 #[cfg(test)]
7321 fn fail_next_install(&self, mode: StartMode, detail: &str) {
7322 self.state
7323 .lock()
7324 .expect("not poisoned")
7325 .install_failures
7326 .insert(mode, detail.to_string());
7327 }
7328
7329 #[cfg(test)]
7330 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
7331 self.state
7332 .lock()
7333 .expect("not poisoned")
7334 .after_install
7335 .insert(
7336 mode,
7337 TestInstallSideEffect::HideDirectory { directory, hidden },
7338 );
7339 }
7340}
7341
7342impl ControlFactory for RecordingControls {
7343 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7344 Ok(Box::new(RecordingControl {
7345 mode,
7346 state: std::sync::Arc::clone(&self.state),
7347 }))
7348 }
7349}
7350
7351#[derive(Debug)]
7352struct RecordingControl {
7353 mode: StartMode,
7354 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
7355}
7356
7357impl RecordingControl {
7358 fn note(&self, operation: &str, name: &str) {
7359 self.state
7360 .lock()
7361 .expect("not poisoned")
7362 .calls
7363 .push(format!("{operation} {name} ({})", self.mode));
7364 }
7365}
7366
7367impl ServiceControl for RecordingControl {
7368 fn manager(&self) -> DefinitionKind {
7369 host_definition_kind(self.mode)
7373 }
7374
7375 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7376 self.note("install", plan.identity().name());
7377 #[cfg(test)]
7378 if let Some(detail) = self
7379 .state
7380 .lock()
7381 .expect("not poisoned")
7382 .install_failures
7383 .remove(&self.mode)
7384 {
7385 return Err(ServiceError::Control {
7386 operation: "install",
7387 name: plan.identity().name().to_string(),
7388 manager: "recording control",
7389 detail,
7390 });
7391 }
7392 let definition = ServiceDefinition::for_host(plan)?;
7397 let mut state = self.state.lock().expect("not poisoned");
7398 state.registrations.insert(
7399 (self.mode, plan.identity().name().to_string()),
7400 Registration {
7401 manager: host_definition_kind(self.mode),
7402 start_mode: self.mode,
7403 command_line: plan.command_line(),
7404 account: (*plan.account() != ServiceAccount::InvokingUser)
7405 .then(|| plan.account().as_str().to_string()),
7406 running: false,
7407 starts_automatically: true,
7408 restart_delay: Some(plan.restart().delay()),
7409 },
7410 );
7411 state
7412 .definitions
7413 .insert(plan.identity().name().to_string(), definition.clone());
7414 #[cfg(test)]
7415 let side_effect = state.after_install.remove(&self.mode);
7416 drop(state);
7417 #[cfg(test)]
7418 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
7419 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
7420 std::fs::write(&directory, b"blocks recreation")
7421 .expect("test fault can block record directory recreation");
7422 }
7423 Ok(definition)
7424 }
7425
7426 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7427 self.note("uninstall", identity.name());
7428 let mut state = self.state.lock().expect("not poisoned");
7429 state.definitions.remove(identity.name());
7430 Ok(state
7431 .registrations
7432 .remove(&(self.mode, identity.name().to_string()))
7433 .is_some())
7434 }
7435
7436 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
7437 self.note("query", identity.name());
7438 Ok(self
7439 .state
7440 .lock()
7441 .expect("not poisoned")
7442 .registrations
7443 .get(&(self.mode, identity.name().to_string()))
7444 .cloned())
7445 }
7446
7447 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
7448 self.note("start", identity.name());
7449 let mut state = self.state.lock().expect("not poisoned");
7450 match state
7451 .registrations
7452 .get_mut(&(self.mode, identity.name().to_string()))
7453 {
7454 Some(registration) => {
7455 registration.running = true;
7456 Ok(())
7457 }
7458 None => Err(ServiceError::NotInstalled {
7459 name: identity.name().to_string(),
7460 operation: "start",
7461 }),
7462 }
7463 }
7464
7465 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7466 self.note("stop", identity.name());
7467 let mut state = self.state.lock().expect("not poisoned");
7468 match state
7469 .registrations
7470 .get_mut(&(self.mode, identity.name().to_string()))
7471 {
7472 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
7473 None => Err(ServiceError::NotInstalled {
7474 name: identity.name().to_string(),
7475 operation: "stop",
7476 }),
7477 }
7478 }
7479}
7480
7481impl ControlFactory for HostControls {
7482 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7483 sys::control(mode)
7484 }
7485
7486 fn installed_definition(&self, path: &Path) -> Option<String> {
7487 std::fs::read_to_string(path).ok()
7488 }
7489
7490 fn unattended_login(&self) -> Option<crate::unattended_login::UnattendedLogin> {
7491 crate::unattended_login::probe()
7492 }
7493}
7494
7495#[must_use]
7502pub fn host_home() -> Option<PathBuf> {
7503 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
7504}
7505
7506#[cfg(unix)]
7508fn home_directory() -> Option<PathBuf> {
7509 host_home()
7510}
7511
7512fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
7521 let output = std::process::Command::new(program)
7522 .args(arguments)
7523 .output()?;
7524 Ok((
7525 output.status.success(),
7526 String::from_utf8_lossy(&output.stdout).into_owned(),
7527 String::from_utf8_lossy(&output.stderr).into_owned(),
7528 ))
7529}
7530
7531#[must_use]
7534pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
7535 if cfg!(windows) {
7536 match mode {
7537 StartMode::Boot => DefinitionKind::WindowsService,
7538 StartMode::Login => DefinitionKind::WindowsScheduledTask,
7539 }
7540 } else if cfg!(target_os = "macos") {
7541 DefinitionKind::LaunchdPlist
7542 } else {
7543 DefinitionKind::SystemdUnit
7544 }
7545}
7546
7547#[cfg(any(target_os = "macos", test))]
7554fn enable_launchd_registration(
7555 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
7556 domain: &str,
7557 service_target: &str,
7558 plist: &Path,
7559 name: &str,
7560 elevation_remedy: &'static str,
7561) -> Result<(), ServiceError> {
7562 let (enabled, cause) = launchctl(&[
7563 std::ffi::OsStr::new("enable"),
7564 std::ffi::OsStr::new(service_target),
7565 ]);
7566 if enabled {
7567 return Ok(());
7568 }
7569
7570 let (booted_out, bootout_detail) = launchctl(&[
7571 std::ffi::OsStr::new("bootout"),
7572 std::ffi::OsStr::new(service_target),
7573 ]);
7574 let removed = std::fs::remove_file(plist);
7575 if !booted_out || removed.is_err() {
7576 return Err(ServiceError::Rollback {
7577 operation: "enable launchd registration",
7578 name: name.to_string(),
7579 cause,
7580 rollback: format!(
7581 "launchctl bootout {domain}: {}; remove {}: {}",
7582 if booted_out {
7583 "succeeded".to_string()
7584 } else {
7585 bootout_detail
7586 },
7587 plist.display(),
7588 removed
7589 .err()
7590 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
7591 ),
7592 });
7593 }
7594
7595 if cause.to_ascii_lowercase().contains("permission denied") {
7596 Err(ServiceError::NeedsElevation {
7597 operation: "enable",
7598 name: name.to_string(),
7599 detail: cause,
7600 remedy: elevation_remedy,
7601 })
7602 } else {
7603 Err(ServiceError::Control {
7604 operation: "enable",
7605 name: name.to_string(),
7606 manager: "launchd",
7607 detail: cause,
7608 })
7609 }
7610}
7611
7612#[derive(Debug, Clone)]
7621pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
7622
7623impl ServiceShutdown {
7624 pub async fn wait(mut self) {
7626 if !*self.0.borrow() {
7627 let _ = self.0.changed().await;
7628 }
7629 }
7630}
7631
7632#[cfg(windows)]
7639pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
7640where
7641 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
7642{
7643 windows_host::run(Box::new(run))
7644}
7645
7646#[cfg(windows)]
7647mod windows_host {
7648 use std::ffi::OsString;
7649 use std::sync::{Arc, Mutex, OnceLock, mpsc};
7650 use std::time::Duration;
7651
7652 use windows_service::service::{
7653 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
7654 ServiceType,
7655 };
7656 use windows_service::service_control_handler::{
7657 self, ServiceControlHandlerResult, ServiceStatusHandle,
7658 };
7659
7660 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
7661
7662 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
7663
7664 struct Invocation {
7665 run: Runner,
7666 result: mpsc::SyncSender<Result<u8, String>>,
7667 }
7668
7669 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
7670
7671 windows_service::define_windows_service!(ffi_service_main, service_main);
7672
7673 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
7674 let (result_tx, result_rx) = mpsc::sync_channel(1);
7675 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
7676 let mut invocation = slot
7677 .lock()
7678 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
7679 if invocation.is_some() {
7680 return Err(host_error(
7681 "prepare",
7682 "the service-host slot was already used",
7683 ));
7684 }
7685 *invocation = Some(Invocation {
7686 run,
7687 result: result_tx,
7688 });
7689 drop(invocation);
7690
7691 windows_service::service_dispatcher::start("", ffi_service_main)
7692 .map_err(|error| host_error("connect", &error.to_string()))?;
7693 result_rx
7694 .recv()
7695 .map_err(|error| host_error("finish", &error.to_string()))?
7696 .map_err(|detail| host_error("run", &detail))
7697 }
7698
7699 fn service_main(_arguments: Vec<OsString>) {
7700 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
7701 return;
7702 };
7703 let result = run_service(invocation.run);
7704 let _ = invocation.result.send(result);
7705 }
7706
7707 fn run_service(run: Runner) -> Result<u8, String> {
7708 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
7709 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
7710 let handler_status = Arc::clone(&status);
7711 let handler = move |control| match control {
7712 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
7713 ServiceControl::Stop | ServiceControl::Shutdown => {
7714 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
7715 let _ = handle.set_service_status(service_status(
7716 ServiceState::StopPending,
7717 ServiceControlAccept::empty(),
7718 1,
7719 Duration::from_secs(300),
7720 0,
7721 ));
7722 }
7723 let _ = stop_tx.send(true);
7724 ServiceControlHandlerResult::NoError
7725 }
7726 _ => ServiceControlHandlerResult::NotImplemented,
7727 };
7728 let handle = service_control_handler::register("", handler)
7729 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
7730 *status
7731 .lock()
7732 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
7733 handle
7734 .set_service_status(service_status(
7735 ServiceState::Running,
7736 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
7737 0,
7738 Duration::default(),
7739 0,
7740 ))
7741 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
7742
7743 let exit = run(ServiceShutdown(stop_rx));
7744 handle
7745 .set_service_status(service_status(
7746 ServiceState::Stopped,
7747 ServiceControlAccept::empty(),
7748 0,
7749 Duration::default(),
7750 u32::from(exit),
7751 ))
7752 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
7753 Ok(exit)
7754 }
7755
7756 fn service_status(
7757 state: ServiceState,
7758 accepted: ServiceControlAccept,
7759 checkpoint: u32,
7760 wait_hint: Duration,
7761 exit: u32,
7762 ) -> ServiceStatus {
7763 ServiceStatus {
7764 service_type: ServiceType::OWN_PROCESS,
7765 current_state: state,
7766 controls_accepted: accepted,
7767 exit_code: if exit == 0 {
7768 ServiceExitCode::Win32(0)
7769 } else {
7770 ServiceExitCode::ServiceSpecific(exit)
7771 },
7772 checkpoint,
7773 wait_hint,
7774 process_id: None,
7775 }
7776 }
7777
7778 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
7779 ServiceError::Control {
7780 operation,
7781 name: SERVICE_NAME.to_string(),
7782 manager: "the Windows Service Control Manager",
7783 detail: detail.to_string(),
7784 }
7785 }
7786}
7787
7788#[cfg(windows)]
7789mod sys {
7790 use std::ffi::{OsStr, OsString};
7800 use std::time::{Duration, Instant};
7801
7802 use runner_manager_domain::model::StartMode;
7803 use windows_service::service::{
7804 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
7805 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
7806 ServiceState, ServiceType,
7807 };
7808 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
7809
7810 use super::{
7811 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
7812 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
7813 };
7814
7815 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
7818 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
7822 const ACCESS_DENIED: i32 = 5;
7824 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
7825 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
7826
7827 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
7828 choose \"Run as administrator\".";
7829
7830 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
7831 Ok(match mode {
7832 StartMode::Boot => Box::new(ScmControl),
7833 StartMode::Login => Box::new(TaskControl),
7834 })
7835 }
7836
7837 #[derive(Debug)]
7840 struct ScmControl;
7841
7842 fn scm_error(
7846 operation: &'static str,
7847 name: &str,
7848 error: &windows_service::Error,
7849 ) -> ServiceError {
7850 let raw = match error {
7851 windows_service::Error::Winapi(io) => io.raw_os_error(),
7852 _ => None,
7853 };
7854 let detail = match error {
7855 windows_service::Error::Winapi(io) => io.to_string(),
7856 other => other.to_string(),
7857 };
7858 if raw == Some(ACCESS_DENIED) {
7859 return ServiceError::NeedsElevation {
7860 operation,
7861 name: name.to_string(),
7862 detail,
7863 remedy: ELEVATION_REMEDY,
7864 };
7865 }
7866 ServiceError::Control {
7867 operation,
7868 name: name.to_string(),
7869 manager: "the Windows Service Control Manager",
7870 detail,
7871 }
7872 }
7873
7874 fn open_manager(
7875 access: ServiceManagerAccess,
7876 operation: &'static str,
7877 name: &str,
7878 ) -> Result<ServiceManager, ServiceError> {
7879 ServiceManager::local_computer(None::<&OsStr>, access)
7880 .map_err(|error| scm_error(operation, name, &error))
7881 }
7882
7883 impl ServiceControl for ScmControl {
7884 fn manager(&self) -> DefinitionKind {
7885 DefinitionKind::WindowsService
7886 }
7887
7888 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
7889 let spec = windows_service_spec(plan);
7890 let manager = open_manager(
7891 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
7892 "install",
7893 &spec.name,
7894 )?;
7895 let info = ServiceInfo {
7896 name: OsString::from(&spec.name),
7897 display_name: OsString::from(&spec.display_name),
7898 service_type: ServiceType::OWN_PROCESS,
7900 start_type: if spec.automatic_start {
7901 ServiceStartType::AutoStart
7902 } else {
7903 ServiceStartType::OnDemand
7904 },
7905 error_control: ServiceErrorControl::Normal,
7906 executable_path: plan.binary().to_path_buf(),
7907 launch_arguments: plan.arguments().to_vec(),
7908 dependencies: Vec::new(),
7909 account_name: spec.account.as_ref().map(OsString::from),
7911 account_password: None,
7914 };
7915 let service = manager
7916 .create_service(
7917 &info,
7918 ServiceAccess::CHANGE_CONFIG
7919 | ServiceAccess::QUERY_CONFIG
7920 | ServiceAccess::QUERY_STATUS
7921 | ServiceAccess::START
7922 | ServiceAccess::STOP
7923 | ServiceAccess::DELETE,
7924 )
7925 .map_err(|error| scm_error("install", &spec.name, &error))?;
7926 service
7927 .set_description(&spec.description)
7928 .map_err(|error| scm_error("describe", &spec.name, &error))?;
7929 service
7930 .update_failure_actions(ServiceFailureActions {
7931 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
7932 reboot_msg: None,
7933 command: None,
7934 actions: Some(vec![
7941 ServiceAction {
7942 action_type: ServiceActionType::Restart,
7943 delay: spec.restart.delay(),
7944 };
7945 3
7946 ]),
7947 })
7948 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7949 service
7952 .set_failure_actions_on_non_crash_failures(true)
7953 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
7954 Ok(ServiceDefinition::windows_service(plan))
7955 }
7956
7957 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
7958 let manager =
7959 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
7960 let service = match manager.open_service(
7961 identity.name(),
7962 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
7963 ) {
7964 Ok(service) => Some(service),
7965 Err(error) if is_missing(&error) => return Ok(false),
7966 Err(error) if is_marked_for_delete(&error) => None,
7971 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
7972 };
7973 if let Some(service) = service {
7974 if let Ok(status) = service.query_status()
7978 && status.current_state != ServiceState::Stopped
7979 {
7980 let _ = service.stop();
7981 }
7982 match service.delete() {
7983 Ok(()) => {}
7984 Err(error) if is_marked_for_delete(&error) => {}
7988 Err(error) => {
7989 return Err(scm_error("uninstall", identity.name(), &error));
7990 }
7991 }
7992 drop(service);
7993 }
7994
7995 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
8002 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
8003 Ok(service) => {
8004 drop(service);
8005 Ok(false)
8006 }
8007 Err(error) if is_missing(&error) => Ok(true),
8008 Err(error) if is_marked_for_delete(&error) => Ok(false),
8009 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
8010 }
8011 })?;
8012 if !absent {
8013 return Err(ServiceError::Control {
8014 operation: "verify uninstall of",
8015 name: identity.name().to_string(),
8016 manager: "the Windows Service Control Manager",
8017 detail: format!(
8018 "the registration was still visible {} seconds after DeleteService; \
8019 retry `service uninstall` from an elevated prompt",
8020 DELETE_TIMEOUT.as_secs()
8021 ),
8022 });
8023 }
8024 Ok(true)
8025 }
8026
8027 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8028 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
8029 let service = match manager.open_service(
8030 identity.name(),
8031 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
8032 ) {
8033 Ok(service) => service,
8034 Err(error) if is_missing(&error) => return Ok(None),
8035 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
8036 };
8037 let config = service
8038 .query_config()
8039 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
8040 let status = service
8041 .query_status()
8042 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
8043 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
8044 actions
8045 .actions
8046 .and_then(|actions| actions.into_iter().next())
8047 .filter(|action| action.action_type == ServiceActionType::Restart)
8048 .map(|action| action.delay)
8049 });
8050 Ok(Some(Registration {
8051 manager: DefinitionKind::WindowsService,
8052 start_mode: StartMode::Boot,
8053 command_line: config.executable_path.to_string_lossy().into_owned(),
8057 account: config
8058 .account_name
8059 .map(|account| super::windows_service_account(&account.to_string_lossy())),
8060 running: status.current_state == ServiceState::Running,
8061 starts_automatically: config.start_type == ServiceStartType::AutoStart,
8062 restart_delay,
8063 }))
8064 }
8065
8066 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8067 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
8068 let service = manager
8069 .open_service(identity.name(), ServiceAccess::START)
8070 .map_err(|error| scm_error("start", identity.name(), &error))?;
8071 service
8072 .start::<&OsStr>(&[])
8073 .map_err(|error| scm_error("start", identity.name(), &error))
8074 }
8075
8076 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8077 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
8078 let service = manager
8079 .open_service(
8080 identity.name(),
8081 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
8082 )
8083 .map_err(|error| scm_error("stop", identity.name(), &error))?;
8084 let status = service
8085 .query_status()
8086 .map_err(|error| scm_error("stop", identity.name(), &error))?;
8087 if status.current_state == ServiceState::Stopped {
8088 return Ok(false);
8089 }
8090 service
8091 .stop()
8092 .map_err(|error| scm_error("stop", identity.name(), &error))?;
8093 Ok(true)
8094 }
8095 }
8096
8097 fn is_missing(error: &windows_service::Error) -> bool {
8098 matches!(error, windows_service::Error::Winapi(io)
8099 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
8100 }
8101
8102 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
8103 matches!(error, windows_service::Error::Winapi(io)
8104 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
8105 }
8106
8107 pub(super) fn wait_until_scm_absent(
8108 timeout: Duration,
8109 poll_interval: Duration,
8110 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
8111 ) -> Result<bool, ServiceError> {
8112 let deadline = Instant::now() + timeout;
8113 loop {
8114 if probe_absent()? {
8115 return Ok(true);
8116 }
8117 if Instant::now() >= deadline {
8118 return Ok(false);
8119 }
8120 std::thread::sleep(poll_interval);
8121 }
8122 }
8123
8124 #[derive(Debug)]
8127 struct TaskControl;
8128
8129 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
8130 if detail.to_ascii_lowercase().contains("access is denied") {
8131 return ServiceError::NeedsElevation {
8132 operation,
8133 name: name.to_string(),
8134 detail,
8135 remedy: ELEVATION_REMEDY,
8136 };
8137 }
8138 ServiceError::Control {
8139 operation,
8140 name: name.to_string(),
8141 manager: "Windows Task Scheduler",
8142 detail,
8143 }
8144 }
8145
8146 fn schtasks(
8147 operation: &'static str,
8148 name: &str,
8149 arguments: &[&OsStr],
8150 ) -> Result<(bool, String), ServiceError> {
8151 match run("schtasks.exe", arguments) {
8152 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
8153 Err(error) => Err(task_error(operation, name, error.to_string())),
8154 }
8155 }
8156
8157 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
8160 let mut bytes = vec![0xFF, 0xFE];
8161 for unit in text.encode_utf16() {
8162 bytes.extend_from_slice(&unit.to_le_bytes());
8163 }
8164 std::fs::write(path, bytes)
8165 }
8166
8167 impl ServiceControl for TaskControl {
8168 fn manager(&self) -> DefinitionKind {
8169 DefinitionKind::WindowsScheduledTask
8170 }
8171
8172 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8173 let name = plan.identity().name().to_string();
8174 let principal = TaskPrincipal::current()?;
8175 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
8176 let directory = tempfile::tempdir()
8177 .map_err(|error| task_error("install", &name, error.to_string()))?;
8178 let document = directory.path().join("task.xml");
8179 write_utf16(&document, definition.text())
8180 .map_err(|error| task_error("install", &name, error.to_string()))?;
8181 let (ok, message) = schtasks(
8182 "install",
8183 &name,
8184 &[
8185 OsStr::new("/Create"),
8186 OsStr::new("/TN"),
8187 OsStr::new(&name),
8188 OsStr::new("/XML"),
8189 document.as_os_str(),
8190 ],
8191 )?;
8192 if !ok {
8193 return Err(task_error("install", &name, message));
8194 }
8195 Ok(definition)
8196 }
8197
8198 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8199 if self.query(identity)?.is_none() {
8200 return Ok(false);
8201 }
8202 let name = identity.name().to_string();
8203 let (ok, message) = schtasks(
8204 "uninstall",
8205 &name,
8206 &[
8207 OsStr::new("/Delete"),
8208 OsStr::new("/TN"),
8209 OsStr::new(&name),
8210 OsStr::new("/F"),
8211 ],
8212 )?;
8213 if !ok {
8214 return Err(task_error("uninstall", &name, message));
8215 }
8216 Ok(true)
8217 }
8218
8219 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8220 let name = identity.name().to_string();
8221 let (ok, document) = schtasks(
8222 "inspect",
8223 &name,
8224 &[
8225 OsStr::new("/Query"),
8226 OsStr::new("/TN"),
8227 OsStr::new(&name),
8228 OsStr::new("/XML"),
8229 OsStr::new("ONE"),
8230 ],
8231 )?;
8232 if !ok {
8233 return Ok(None);
8239 }
8240 let command = xml_value(&document, "Command").unwrap_or_default();
8241 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
8242 let command_line = if arguments.is_empty() {
8243 super::quote_argument(&command)
8244 } else {
8245 format!("{} {arguments}", super::quote_argument(&command))
8246 };
8247 Ok(Some(Registration {
8248 manager: DefinitionKind::WindowsScheduledTask,
8249 start_mode: StartMode::Login,
8250 command_line,
8251 account: super::scheduled_task_account(xml_value(&document, "UserId")),
8252 running: task_is_running(&name),
8253 starts_automatically: super::windows_login_task_starts_automatically(&document),
8254 restart_delay: xml_value(&document, "Interval")
8255 .as_deref()
8256 .and_then(parse_iso8601),
8257 }))
8258 }
8259
8260 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8261 let name = identity.name().to_string();
8262 let (ok, message) = schtasks(
8263 "start",
8264 &name,
8265 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
8266 )?;
8267 if ok {
8268 Ok(())
8269 } else {
8270 Err(task_error("start", &name, message))
8271 }
8272 }
8273
8274 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8275 let running = self
8276 .query(identity)?
8277 .is_some_and(|registration| registration.running);
8278 if !running {
8279 return Ok(false);
8280 }
8281 let name = identity.name().to_string();
8282 let (ok, message) = schtasks(
8283 "stop",
8284 &name,
8285 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
8286 )?;
8287 if ok {
8288 Ok(true)
8289 } else {
8290 Err(task_error("stop", &name, message))
8291 }
8292 }
8293 }
8294
8295 fn task_is_running(name: &str) -> bool {
8309 let Ok((true, stdout, _)) = run(
8310 "schtasks.exe",
8311 &[
8312 OsStr::new("/Query"),
8313 OsStr::new("/TN"),
8314 OsStr::new(name),
8315 OsStr::new("/FO"),
8316 OsStr::new("CSV"),
8317 OsStr::new("/NH"),
8318 ],
8319 ) else {
8320 return false;
8321 };
8322 stdout
8323 .lines()
8324 .filter_map(|line| line.rsplit(',').next())
8325 .any(|status| {
8326 status
8327 .trim()
8328 .trim_matches('"')
8329 .eq_ignore_ascii_case("running")
8330 })
8331 }
8332
8333 fn parse_iso8601(value: &str) -> Option<Duration> {
8337 let rest = value.strip_prefix("PT")?;
8338 if let Some(minutes) = rest.strip_suffix('M') {
8339 return minutes
8340 .parse::<u64>()
8341 .ok()
8342 .map(|minutes| Duration::from_secs(minutes * 60));
8343 }
8344 rest.strip_suffix('S')?
8345 .parse::<u64>()
8346 .ok()
8347 .map(Duration::from_secs)
8348 }
8349}
8350
8351#[cfg(unix)]
8363fn write_definition(
8364 operation: &'static str,
8365 name: &str,
8366 path: &Path,
8367 text: &str,
8368 remedy: &'static str,
8369) -> Result<(), ServiceError> {
8370 if let Some(parent) = path.parent()
8371 && let Err(error) = std::fs::create_dir_all(parent)
8372 && error.kind() != std::io::ErrorKind::AlreadyExists
8373 {
8374 return Err(definition_error(operation, name, error, remedy, parent));
8375 }
8376 std::fs::write(path, text)
8377 .map_err(|error| definition_error(operation, name, error, remedy, path))
8378}
8379
8380#[cfg(all(unix, any(target_os = "macos", test)))]
8387fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
8388 use std::io::Write as _;
8389
8390 let path = drift.path.as_path();
8391 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
8392 let failed = |error: std::io::Error, at: &Path| {
8393 definition_error("rewrite", name, error, SUDO_REMEDY, at)
8394 };
8395 let mut staged =
8396 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
8397 staged
8398 .write_all(drift.rendered.as_bytes())
8399 .and_then(|()| staged.as_file().sync_all())
8400 .map_err(|error| failed(error, staged.path()))?;
8401 std::fs::set_permissions(
8402 staged.path(),
8403 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
8404 )
8405 .map_err(|error| failed(error, path))?;
8406 staged
8407 .persist(path)
8408 .map(drop)
8409 .map_err(|error| failed(error.error, path))
8410}
8411
8412#[cfg(unix)]
8413fn definition_error(
8414 operation: &'static str,
8415 name: &str,
8416 error: std::io::Error,
8417 remedy: &'static str,
8418 path: &Path,
8419) -> ServiceError {
8420 let detail = format!("{}: {error}", path.display());
8421 if error.kind() == std::io::ErrorKind::PermissionDenied {
8422 ServiceError::NeedsElevation {
8423 operation,
8424 name: name.to_string(),
8425 detail,
8426 remedy,
8427 }
8428 } else {
8429 ServiceError::Control {
8430 operation,
8431 name: name.to_string(),
8432 manager: "the local service manager",
8433 detail,
8434 }
8435 }
8436}
8437
8438#[cfg(unix)]
8440const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
8441 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
8442 agent not running until you sign in.";
8443
8444#[cfg(target_os = "macos")]
8449mod sys {
8450 use std::ffi::OsStr;
8463 use std::path::PathBuf;
8464 use std::time::Duration;
8465
8466 use runner_manager_domain::model::StartMode;
8467
8468 use super::{
8469 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
8470 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8471 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
8472 write_definition, xml_value,
8473 };
8474
8475 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8476 Ok(Box::new(LaunchdControl { mode }))
8477 }
8478
8479 #[derive(Debug)]
8480 struct LaunchdControl {
8481 mode: StartMode,
8482 }
8483
8484 impl LaunchdControl {
8485 fn domain(&self) -> String {
8487 match self.mode {
8488 StartMode::Boot => "system".to_string(),
8489 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
8492 }
8493 }
8494
8495 fn service_target(&self, identity: &ServiceIdentity) -> String {
8496 format!("{}/{}", self.domain(), identity.launchd_label())
8497 }
8498
8499 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8502 let file = format!("{}.plist", identity.launchd_label());
8503 match self.mode {
8504 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
8505 StartMode::Login => {
8506 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
8507 }
8508 }
8509 }
8510
8511 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8512 ServiceError::Control {
8513 operation,
8514 name: name.to_string(),
8515 manager: "launchd",
8516 detail,
8517 }
8518 }
8519
8520 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
8521 match run("launchctl", arguments) {
8522 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
8523 Err(error) => (false, error.to_string()),
8524 }
8525 }
8526 }
8527
8528 impl ServiceControl for LaunchdControl {
8529 fn manager(&self) -> DefinitionKind {
8530 DefinitionKind::LaunchdPlist
8531 }
8532
8533 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8534 let name = plan.identity().name().to_string();
8535 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
8536 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8537 return Err(self.failed(
8538 "install",
8539 &name,
8540 "this account has no home directory, so there is nowhere to put a \
8541 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
8542 /Library/LaunchDaemons."
8543 .to_string(),
8544 ));
8545 };
8546 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8547 let target = self.domain();
8548 let (ok, message) = self.launchctl(&[
8549 OsStr::new("bootstrap"),
8550 OsStr::new(&target),
8551 path.as_os_str(),
8552 ]);
8553 if !ok {
8554 let _ = std::fs::remove_file(&path);
8558 if message.to_ascii_lowercase().contains("permission denied") {
8559 return Err(ServiceError::NeedsElevation {
8560 operation: "install",
8561 name,
8562 detail: message,
8563 remedy: SUDO_REMEDY,
8564 });
8565 }
8566 return Err(self.failed("install", &name, message));
8567 }
8568 let service_target = self.service_target(plan.identity());
8571 enable_launchd_registration(
8572 |arguments| self.launchctl(arguments),
8573 &target,
8574 &service_target,
8575 &path,
8576 &name,
8577 SUDO_REMEDY,
8578 )?;
8579 Ok(definition)
8580 }
8581
8582 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8583 let Some(path) = self.plist_path(identity) else {
8584 return Ok(false);
8585 };
8586 if !path.exists() {
8587 return Ok(false);
8588 }
8589 let target = self.service_target(identity);
8590 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
8591 if !ok
8592 && !message.to_ascii_lowercase().contains("no such process")
8593 && !message.contains("113")
8594 {
8595 if message.to_ascii_lowercase().contains("permission denied") {
8596 return Err(ServiceError::NeedsElevation {
8597 operation: "uninstall",
8598 name: identity.name().to_string(),
8599 detail: message,
8600 remedy: SUDO_REMEDY,
8601 });
8602 }
8603 return Err(self.failed("uninstall", identity.name(), message));
8604 }
8605 std::fs::remove_file(&path).map_err(|error| {
8608 super::definition_error(
8609 "uninstall",
8610 identity.name(),
8611 error,
8612 SUDO_REMEDY,
8613 path.as_path(),
8614 )
8615 })?;
8616 Ok(true)
8617 }
8618
8619 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8620 let Some(path) = self.plist_path(identity) else {
8621 return Ok(None);
8622 };
8623 let Ok(document) = std::fs::read_to_string(&path) else {
8624 return Ok(None);
8625 };
8626 let target = self.service_target(identity);
8627 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
8628 Ok(Some(Registration {
8629 manager: DefinitionKind::LaunchdPlist,
8630 start_mode: self.mode,
8631 command_line: program_arguments(&document),
8632 account: plist_string_value(&document, "UserName").or_else(|| {
8635 (self.mode == StartMode::Boot)
8636 .then(|| super::ServiceAccount::Root.as_str().to_string())
8637 }),
8638 running: loaded && printed.contains("state = running"),
8639 starts_automatically: document.contains("<key>RunAtLoad</key>")
8640 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
8641 restart_delay: xml_value(
8642 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
8643 "integer",
8644 )
8645 .and_then(|value| value.parse::<u64>().ok())
8646 .map(Duration::from_secs),
8647 }))
8648 }
8649
8650 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
8651 let target = self.service_target(identity);
8652 let (ok, message) = self.launchctl(&[
8653 OsStr::new("kickstart"),
8654 OsStr::new("-k"),
8655 OsStr::new(&target),
8656 ]);
8657 if ok {
8658 Ok(())
8659 } else {
8660 Err(self.failed("start", identity.name(), message))
8661 }
8662 }
8663
8664 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8665 let running = self
8666 .query(identity)?
8667 .is_some_and(|registration| registration.running);
8668 if !running {
8669 return Ok(false);
8670 }
8671 let target = self.service_target(identity);
8672 let (ok, message) = self.launchctl(&[
8673 OsStr::new("kill"),
8674 OsStr::new("SIGTERM"),
8675 OsStr::new(&target),
8676 ]);
8677 if ok {
8678 Ok(true)
8679 } else {
8680 Err(self.failed("stop", identity.name(), message))
8681 }
8682 }
8683 }
8684
8685 pub(super) fn reload_detached(
8700 identity: &ServiceIdentity,
8701 mode: StartMode,
8702 plist: &std::path::Path,
8703 ) -> Result<(), ServiceError> {
8704 use std::os::unix::process::CommandExt;
8705
8706 const SCRIPT: &str = r#"(
8707 sleep 1
8708 /bin/launchctl bootout "$1"
8709 tries=0
8710 until /bin/launchctl bootstrap "$2" "$3"; do
8711 tries=$((tries + 1))
8712 if [ "$tries" -ge 60 ]; then
8713 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
8714 exit 1
8715 fi
8716 sleep 1
8717 done
8718) </dev/null &"#;
8719
8720 let control = LaunchdControl { mode };
8721 let reinstall = match mode {
8724 StartMode::Boot => "sudo runner-manager service install --start-at boot",
8725 StartMode::Login => "runner-manager service install --start-at login",
8726 };
8727 let mut command = std::process::Command::new("/bin/sh");
8728 command
8729 .args(["-c", SCRIPT, "runner-manager-reload"])
8730 .arg(control.service_target(identity))
8731 .arg(control.domain())
8732 .arg(plist)
8733 .arg(reinstall)
8734 .stdin(std::process::Stdio::null());
8735 unsafe {
8738 command.pre_exec(|| {
8739 if libc::setsid() == -1 {
8740 return Err(std::io::Error::last_os_error());
8741 }
8742 Ok(())
8743 });
8744 }
8745 let status = command.status().map_err(|error| {
8746 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
8747 })?;
8748 if status.success() {
8749 Ok(())
8750 } else {
8751 Err(control.failed(
8752 "reload",
8753 identity.name(),
8754 format!("the reload helper exited with {status}"),
8755 ))
8756 }
8757 }
8758
8759 fn program_arguments(document: &str) -> String {
8761 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
8762 return String::new();
8763 };
8764 let Some(end) = rest.find("</array>") else {
8765 return String::new();
8766 };
8767 let mut out = Vec::new();
8768 let mut cursor = &rest[..end];
8769 while let Some(open) = cursor.find("<string>") {
8770 let after = &cursor[open + "<string>".len()..];
8771 let Some(close) = after.find("</string>") else {
8772 break;
8773 };
8774 out.push(quote_argument(&super::xml_unescape(&after[..close])));
8775 cursor = &after[close..];
8776 }
8777 out.join(" ")
8778 }
8779}
8780
8781#[cfg(all(unix, not(target_os = "macos")))]
8786mod sys {
8787 use std::ffi::OsStr;
8797 use std::path::PathBuf;
8798
8799 use runner_manager_domain::model::StartMode;
8800
8801 use super::{
8802 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
8803 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
8804 home_directory, ini_directives, run, systemd_exec_command_line, systemd_timespan,
8805 write_definition,
8806 };
8807
8808 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
8809 Ok(Box::new(SystemdControl { mode }))
8810 }
8811
8812 #[derive(Debug)]
8813 struct SystemdControl {
8814 mode: StartMode,
8815 }
8816
8817 impl SystemdControl {
8818 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
8819 let file = identity.systemd_unit();
8820 match self.mode {
8821 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
8822 StartMode::Login => {
8823 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
8824 }
8825 }
8826 }
8827
8828 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
8830 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
8831 if self.mode == StartMode::Login {
8832 all.push(OsStr::new("--user"));
8833 }
8834 all.extend(arguments.iter().map(OsStr::new));
8835 match run("systemctl", &all) {
8836 Ok((ok, stdout, stderr)) => (
8837 ok,
8838 if stdout.trim().is_empty() {
8839 stderr
8840 } else {
8841 stdout
8842 },
8843 ),
8844 Err(error) => (false, error.to_string()),
8845 }
8846 }
8847
8848 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
8849 ServiceError::Control {
8850 operation,
8851 name: name.to_string(),
8852 manager: "systemd",
8853 detail,
8854 }
8855 }
8856 }
8857
8858 impl ServiceControl for SystemdControl {
8859 fn manager(&self) -> DefinitionKind {
8860 DefinitionKind::SystemdUnit
8861 }
8862
8863 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
8864 use std::os::unix::fs::MetadataExt as _;
8865
8866 let name = plan.identity().name().to_string();
8867 if self.mode == StartMode::Boot {
8868 let refusals = super::permitted_paths(DefinitionKind::SystemdUnit, plan)
8871 .iter()
8872 .filter_map(|path| {
8873 let path = std::path::Path::new(path);
8874 let owner = std::fs::metadata(path).ok().map(|metadata| metadata.uid());
8875 super::boot_directory_refusal(path, owner)
8876 })
8877 .collect::<Vec<_>>();
8878 if !refusals.is_empty() {
8879 return Err(self.failed(
8880 "install",
8881 &name,
8882 format!(
8883 "{}. Run `{}` without --data-dir, which uses {} and {}; a \
8884 directory there that root does not own is handed back with \
8885 `sudo chown -R root:root <directory>`",
8886 refusals.join("; "),
8887 super::sudo_command("service install --start-at boot"),
8888 crate::paths::SYSTEM_DATA_ROOT,
8889 crate::paths::SYSTEM_LOGS_DIR,
8890 ),
8891 ));
8892 }
8893 }
8894 if self.mode == StartMode::Boot
8898 && let Err(detail) = crate::runner_account::RunnerPool::ensure()
8899 {
8900 return Err(self.failed("install", &name, detail));
8901 }
8902 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
8903 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
8904 return Err(self.failed(
8905 "install",
8906 &name,
8907 "this account has no home directory, so there is nowhere to put a systemd \
8908 user unit. Use --start-at boot, which installs a system unit under \
8909 /etc/systemd/system."
8910 .to_string(),
8911 ));
8912 };
8913 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
8914 let unit = plan.identity().systemd_unit();
8915 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
8916 if !reloaded {
8917 let _ = std::fs::remove_file(&path);
8918 return Err(self.failed("install", &name, message));
8919 }
8920 let (enabled, message) = self.systemctl(&["enable", &unit]);
8921 if !enabled {
8922 let _ = std::fs::remove_file(&path);
8923 let _ = self.systemctl(&["daemon-reload"]);
8924 return Err(self.failed("install", &name, message));
8925 }
8926 Ok(definition)
8927 }
8928
8929 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
8930 let Some(path) = self.unit_path(identity) else {
8931 return Ok(false);
8932 };
8933 if !path.exists() {
8934 return Ok(false);
8935 }
8936 let unit = identity.systemd_unit();
8937 let _ = self.systemctl(&["disable", "--now", &unit]);
8942 std::fs::remove_file(&path).map_err(|error| {
8943 super::definition_error(
8944 "uninstall",
8945 identity.name(),
8946 error,
8947 SUDO_REMEDY,
8948 path.as_path(),
8949 )
8950 })?;
8951 let _ = self.systemctl(&["daemon-reload"]);
8952 Ok(true)
8953 }
8954
8955 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
8956 let Some(path) = self.unit_path(identity) else {
8957 return Ok(None);
8958 };
8959 if !path.exists() {
8960 return Ok(None);
8961 }
8962 let unit = identity.systemd_unit();
8963 let effective = match self.systemctl(&["cat", &unit]) {
8967 (true, text) => text,
8968 _ => match std::fs::read_to_string(&path) {
8969 Ok(document) => document,
8970 Err(_) => return Ok(None),
8971 },
8972 };
8973 let directives = ini_directives(&effective, "Service");
8974 let (_, shown) = self.systemctl(&[
8976 "show",
8977 "--property=ActiveState",
8978 "--property=UnitFileState",
8979 &unit,
8980 ]);
8981 let state = |key: &str| {
8982 shown
8983 .lines()
8984 .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
8985 .map(str::trim)
8986 };
8987 Ok(Some(Registration {
8988 manager: DefinitionKind::SystemdUnit,
8989 start_mode: self.mode,
8990 command_line: directives
8991 .get("ExecStart")
8992 .map(|value| systemd_exec_command_line(value))
8993 .unwrap_or_default(),
8994 account: directives
8997 .get("User")
8998 .filter(|user| !user.is_empty())
8999 .cloned()
9000 .or_else(|| {
9001 (self.mode == StartMode::Boot)
9002 .then(|| super::ServiceAccount::Root.as_str().to_string())
9003 }),
9004 running: state("ActiveState") == Some("active"),
9005 starts_automatically: state("UnitFileState") == Some("enabled"),
9006 restart_delay: directives
9007 .get("RestartSec")
9008 .and_then(|value| systemd_timespan(value)),
9009 }))
9010 }
9011
9012 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
9013 let unit = identity.systemd_unit();
9014 let (ok, message) = self.systemctl(&["start", &unit]);
9015 if ok {
9016 Ok(())
9017 } else {
9018 Err(self.failed("start", identity.name(), message))
9019 }
9020 }
9021
9022 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
9023 let running = self
9024 .query(identity)?
9025 .is_some_and(|registration| registration.running);
9026 if !running {
9027 return Ok(false);
9028 }
9029 let unit = identity.systemd_unit();
9030 let (ok, message) = self.systemctl(&["stop", &unit]);
9031 if ok {
9032 Ok(true)
9033 } else {
9034 Err(self.failed("stop", identity.name(), message))
9035 }
9036 }
9037 }
9038}
9039
9040#[cfg(all(test, target_os = "macos"))]
9042pub(crate) mod launchd_fixture {
9043 use std::path::{Path, PathBuf};
9044
9045 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
9049
9050 pub(crate) fn enabled() -> bool {
9053 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
9054 if !enabled {
9055 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
9056 }
9057 enabled
9058 }
9059
9060 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
9068 let binary = directory.join("launch-agent-job");
9069 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
9070 .expect("test binary copied");
9071 binary
9072 }
9073
9074 pub(crate) struct LoadedAgent(String);
9076
9077 impl LoadedAgent {
9078 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
9081 let domain = format!("gui/{}", unsafe { libc::getuid() });
9084 let loaded = Self(format!("{domain}/{label}"));
9086 let bootstrap = std::process::Command::new("launchctl")
9087 .arg("bootstrap")
9088 .arg(&domain)
9089 .arg(plist)
9090 .output()
9091 .expect("launchctl runs");
9092 assert!(
9093 bootstrap.status.success(),
9094 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
9095 String::from_utf8_lossy(&bootstrap.stderr)
9096 );
9097 loaded
9098 }
9099
9100 pub(crate) fn target(&self) -> &str {
9102 &self.0
9103 }
9104 }
9105
9106 impl Drop for LoadedAgent {
9107 fn drop(&mut self) {
9108 let _ = std::process::Command::new("launchctl")
9109 .args(["bootout", &self.0])
9110 .output();
9111 }
9112 }
9113}
9114
9115#[cfg(test)]
9116mod tests {
9117 use super::*;
9118
9119 use std::collections::BTreeMap;
9120
9121 #[test]
9122 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
9123 assert!(windows_login_task_starts_automatically(
9124 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
9125 ));
9126 assert!(windows_login_task_starts_automatically(
9127 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
9128 ));
9129 assert!(!windows_login_task_starts_automatically(
9130 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
9131 ));
9132 assert!(!windows_login_task_starts_automatically(
9133 "<Task><BootTrigger></BootTrigger></Task>"
9134 ));
9135 }
9136
9137 fn linux_plan(mode: StartMode) -> InstallPlan {
9144 InstallPlan::unchecked(
9145 ServiceIdentity::product(),
9146 mode,
9147 "/opt/runner-manager/bin/runner-manager",
9148 ServiceDirectories {
9149 config: PathBuf::from("/var/lib/runner-manager/config"),
9150 state: PathBuf::from("/var/lib/runner-manager/state"),
9151 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
9152 logs: PathBuf::from("/var/log/runner-manager"),
9153 },
9154 )
9155 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
9156 }
9157
9158 fn windows_plan(mode: StartMode) -> InstallPlan {
9159 InstallPlan::unchecked(
9160 ServiceIdentity::product(),
9161 mode,
9162 "C:\\Program Files\\runner-manager\\runner-manager.exe",
9163 ServiceDirectories {
9164 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
9165 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
9166 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
9167 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
9168 },
9169 )
9170 }
9171
9172 fn edited(text: &str, from: &str, to: &str) -> String {
9180 assert!(
9181 text.contains(from),
9182 "the rendered definition does not contain `{from}`, so this test would assert \
9183 nothing about a widened one"
9184 );
9185 text.replace(from, to)
9186 }
9187
9188 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
9190 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
9191 let Ok(entries) = std::fs::read_dir(directory) else {
9192 return;
9193 };
9194 for entry in entries.flatten() {
9195 let path = entry.path();
9196 if path.is_dir() {
9197 walk(&path, out);
9198 } else if let Ok(bytes) = std::fs::read(&path) {
9199 out.insert(path, bytes);
9200 }
9201 }
9202 }
9203 let mut out = BTreeMap::new();
9204 for root in roots {
9205 walk(root, &mut out);
9206 }
9207 out
9208 }
9209
9210 struct Host {
9211 _root: tempfile::TempDir,
9212 paths: AppPaths,
9213 binary: PathBuf,
9214 runner_root: LocalAbsolutePath,
9223 controls: RecordingControls,
9224 }
9225
9226 impl Host {
9227 fn new() -> Self {
9228 let root = tempfile::tempdir().expect("a temporary directory");
9229 let paths = AppPaths::rooted_at(root.path());
9230 paths.create_all().expect("the four directories");
9231 let binary = root.path().join(if cfg!(windows) {
9232 "runner-manager.exe"
9233 } else {
9234 "runner-manager"
9235 });
9236 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
9237 let runner_root = LocalAbsolutePath::new(
9238 root.path()
9239 .join("runner-root")
9240 .to_str()
9241 .expect("a unicode temporary path"),
9242 )
9243 .expect("a local absolute path");
9244 Self {
9245 _root: root,
9246 paths,
9247 binary,
9248 runner_root,
9249 controls: RecordingControls::new(),
9250 }
9251 }
9252
9253 fn operations(&self) -> ServiceOperations {
9254 ServiceOperations::with_controls(
9255 self.paths.clone(),
9256 ServiceIdentity::product(),
9257 std::sync::Arc::new(self.controls.clone()),
9258 )
9259 .with_runner_root(self.runner_root.clone())
9260 }
9261
9262 fn request(&self, mode: StartMode) -> InstallRequest {
9263 InstallRequest::new(mode).for_binary(&self.binary)
9264 }
9265 }
9266
9267 #[cfg(windows)]
9268 #[test]
9269 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
9270 let probes = std::cell::Cell::new(0);
9271 let absent =
9272 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
9273 let next = probes.get() + 1;
9274 probes.set(next);
9275 Ok(next == 3)
9276 })
9277 .expect("the simulated SCM probe succeeds");
9278
9279 assert!(absent);
9280 assert_eq!(
9281 probes.get(),
9282 3,
9283 "uninstall must recheck after transient presence instead of treating it as a leak"
9284 );
9285 }
9286
9287 #[cfg(windows)]
9288 #[test]
9289 fn windows_recognises_an_already_pending_service_deletion() {
9290 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
9291
9292 assert!(super::sys::is_marked_for_delete(&error));
9293 }
9294
9295 #[test]
9296 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
9297 let root = tempfile::tempdir().expect("a temporary directory");
9298 let plist = root.path().join("fixture.plist");
9299 std::fs::write(&plist, b"fixture").expect("a plist fixture");
9300 let calls = std::cell::RefCell::new(Vec::new());
9301
9302 let error = enable_launchd_registration(
9303 |arguments| {
9304 let call = arguments
9305 .iter()
9306 .map(|argument| argument.to_string_lossy().into_owned())
9307 .collect::<Vec<_>>();
9308 let operation = call[0].clone();
9309 calls.borrow_mut().push(call);
9310 if operation == "enable" {
9311 (false, "label remains disabled".to_string())
9312 } else {
9313 (true, String::new())
9314 }
9315 },
9316 "system",
9317 "system/com.openai.runner-manager-selftest",
9318 &plist,
9319 "runner-manager-selftest",
9320 "rerun with administrative rights",
9321 )
9322 .expect_err("enable failure must fail the install");
9323
9324 assert!(
9325 matches!(
9326 error,
9327 ServiceError::Control {
9328 operation: "enable",
9329 ..
9330 }
9331 ),
9332 "{error}"
9333 );
9334 assert_eq!(calls.borrow().len(), 2);
9335 assert_eq!(calls.borrow()[0][0], "enable");
9336 assert_eq!(calls.borrow()[1][0], "bootout");
9337 assert!(!plist.exists(), "rollback must remove the plist");
9338 }
9339
9340 #[test]
9345 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
9346 let plan = windows_plan(StartMode::Boot);
9347 let command_line = plan.command_line();
9348 assert!(
9349 command_line.starts_with('"'),
9350 "a path with a space must be quoted, got {command_line}"
9351 );
9352 assert_eq!(
9353 executable_from_command_line(&command_line).as_deref(),
9354 Some(plan.binary())
9355 );
9356 }
9357
9358 #[test]
9359 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
9360 let plan = linux_plan(StartMode::Boot);
9361 let command_line = plan.command_line();
9362 assert!(!command_line.starts_with('"'), "got {command_line}");
9363 assert_eq!(
9364 executable_from_command_line(&command_line).as_deref(),
9365 Some(plan.binary())
9366 );
9367 }
9368
9369 #[test]
9370 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
9371 let awkward = r#"C:\odd "name"\rm.exe"#;
9374 let quoted = quote_argument(awkward);
9375 assert_eq!(
9376 executable_from_command_line(&format!("{quoted} daemon run"))
9377 .as_deref()
9378 .map(Path::to_string_lossy)
9379 .as_deref(),
9380 Some(awkward)
9381 );
9382 }
9383
9384 #[test]
9385 fn an_empty_command_line_has_no_executable() {
9386 assert_eq!(executable_from_command_line(" "), None);
9387 assert_eq!(executable_from_command_line(""), None);
9388 }
9389
9390 #[test]
9391 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
9392 let awkward = r#"DOMAIN\R&D <team> "ops""#;
9393 assert_eq!(
9394 xml_escape(awkward),
9395 "DOMAIN\\R&D <team> "ops""
9396 );
9397 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
9398 }
9399
9400 #[test]
9405 fn a_restart_delay_under_the_floor_is_refused() {
9406 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
9407 .expect_err("half a second is under the one-second floor");
9408 assert!(
9409 matches!(error, ServiceError::RestartDelay { .. }),
9410 "{error}"
9411 );
9412 }
9413
9414 #[test]
9415 fn a_restart_delay_over_the_ceiling_is_refused() {
9416 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
9417 .expect_err("an hour is over the five-minute ceiling");
9418 assert!(
9419 matches!(error, ServiceError::RestartDelay { .. }),
9420 "{error}"
9421 );
9422 }
9423
9424 #[test]
9425 fn a_reset_window_no_longer_than_the_delay_is_refused() {
9426 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
9427 .expect_err("a window equal to the delay can never elapse between restarts");
9428 assert!(
9429 matches!(error, ServiceError::RestartResetWindow { .. }),
9430 "{error}"
9431 );
9432 }
9433
9434 #[test]
9435 fn a_delay_inside_the_bound_is_accepted() {
9436 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
9437 .expect("twenty seconds is inside the bound");
9438 assert_eq!(policy.delay(), Duration::from_secs(20));
9439 assert_eq!(policy.reset_after(), Duration::from_secs(300));
9440 }
9441
9442 #[test]
9447 fn a_fixture_identity_can_never_be_the_product_identity() {
9448 let fixture = ServiceIdentity::fixture("abc123");
9449 assert!(fixture.is_fixture());
9450 assert!(!ServiceIdentity::product().is_fixture());
9451 assert_ne!(fixture.name(), ServiceIdentity::product().name());
9452 assert_ne!(
9453 fixture.launchd_label(),
9454 ServiceIdentity::product().launchd_label()
9455 );
9456 assert_ne!(
9457 fixture.systemd_unit(),
9458 ServiceIdentity::product().systemd_unit()
9459 );
9460 }
9461
9462 #[test]
9463 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
9464 let fixture = ServiceIdentity::fixture("A b/c:\\d");
9465 assert!(
9466 fixture
9467 .name()
9468 .chars()
9469 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
9470 "got {}",
9471 fixture.name()
9472 );
9473 }
9474
9475 #[test]
9480 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
9481 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9482 assert!(unit.contains("KillMode=process\n"), "{unit}");
9483 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
9484 assert!(unit.contains("RestartSec=15\n"), "{unit}");
9485 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
9486 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
9487 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
9488 }
9489
9490 #[test]
9491 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
9492 let unit = systemd_unit(&linux_plan(StartMode::Boot));
9493 assert!(
9494 !unit.contains("LoadCredential="),
9495 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
9496 );
9497 }
9498
9499 #[test]
9500 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
9501 let unit = systemd_unit(&linux_plan(StartMode::Login));
9502 assert!(
9503 !unit.contains("LoadCredential="),
9504 "a user unit must not name a root-owned credential file, got:\n{unit}"
9505 );
9506 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
9507 }
9508
9509 #[test]
9510 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
9511 let plan = linux_plan(StartMode::Boot);
9512 let unit = systemd_unit(&plan);
9513 let directives = ini_directives(&unit, "Service");
9514 let listed = split_quoted(
9515 directives
9516 .get("ReadWritePaths")
9517 .expect("the unit names its writable paths"),
9518 );
9519 assert_eq!(listed.len(), 5, "{listed:?}");
9520 for path in plan.directories().all() {
9521 assert!(
9522 listed.iter().any(|entry| entry == &path.to_string_lossy()),
9523 "{} is missing from {listed:?}",
9524 path.display()
9525 );
9526 }
9527 assert!(
9528 listed
9529 .iter()
9530 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
9531 "atomic credential replacement needs its parent directory in {listed:?}"
9532 );
9533 }
9534
9535 #[test]
9536 fn the_unit_records_the_absolute_binary_path() {
9537 let plan = linux_plan(StartMode::Boot);
9538 let unit = systemd_unit(&plan);
9539 assert!(
9540 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
9541 "{unit}"
9542 );
9543 }
9544
9545 #[test]
9550 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
9551 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9552 assert!(
9555 plist.contains(
9556 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
9557 ),
9558 "{plist}"
9559 );
9560 assert!(
9561 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
9562 "{plist}"
9563 );
9564 }
9565
9566 #[test]
9567 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
9568 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
9569 assert_eq!(
9570 plist_string_value(&daemon, "UserName").as_deref(),
9571 Some("root")
9572 );
9573 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
9574
9575 let agent = launchd_plist(&linux_plan(StartMode::Login));
9576 assert_eq!(
9577 plist_string_value(&agent, "UserName"),
9578 None,
9579 "a LaunchAgent already runs as the operator:\n{agent}"
9580 );
9581 }
9582
9583 #[test]
9584 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
9585 for mode in [StartMode::Boot, StartMode::Login] {
9586 let plan = linux_plan(mode);
9587 let plist = launchd_plist(&plan);
9588 assert_eq!(
9589 plist_string_value(&plist, "ProcessType").as_deref(),
9590 Some("Interactive"),
9591 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
9592 );
9593 let review = review_least_privilege(
9594 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
9595 &plan,
9596 );
9597 assert!(
9598 review
9599 .controls()
9600 .iter()
9601 .any(|control| control == "ProcessType=Interactive"),
9602 "{review}"
9603 );
9604 assert!(
9605 !review
9606 .findings()
9607 .iter()
9608 .any(|finding| finding.subject == "ProcessType"),
9609 "{review}"
9610 );
9611 }
9612 }
9613
9614 #[test]
9618 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
9619 let plan = linux_plan(StartMode::Login);
9620 let rendered = launchd_plist(&plan);
9621 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
9622 for (label, throttled) in [
9623 (
9624 "Background",
9625 edited(
9626 &rendered,
9627 interactive,
9628 "<key>ProcessType</key>\n <string>Background</string>\n",
9629 ),
9630 ),
9631 ("absent", edited(&rendered, interactive, "")),
9632 ] {
9633 let review = review_least_privilege(
9634 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
9635 &plan,
9636 );
9637 assert!(review.is_least_privilege(), "{label}: {review}");
9638 assert!(
9639 review.findings().iter().any(|finding| {
9640 finding.kind == FindingKind::Shortfall
9641 && finding.subject == "ProcessType"
9642 && finding.detail.contains(label)
9643 }),
9644 "{label}: {review}"
9645 );
9646 }
9647 }
9648
9649 #[cfg(unix)]
9650 #[test]
9651 fn converging_a_unit_first_creates_the_directories_it_lets_the_service_write() {
9652 use std::os::unix::fs::PermissionsExt as _;
9653 let root = tempfile::tempdir().expect("dir");
9654 let config = root.path().join(".config").join("runner-manager");
9655 let state = root.path().join("state");
9656 std::fs::create_dir_all(&state).expect("state");
9657 let drift = DefinitionDrift {
9658 path: root.path().join("runner-manager.service"),
9659 installed_by_version: "0.4.44".into(),
9660 rendered: String::new(),
9661 start_mode: StartMode::Boot,
9662 manager: DefinitionKind::SystemdUnit,
9663 writable: vec![config.clone(), state.clone()],
9664 };
9665 drift.create_writable().expect("created");
9666 assert!(config.is_dir(), "the missing one is made");
9667 assert_eq!(
9668 std::fs::metadata(&config).unwrap().permissions().mode() & 0o777,
9669 0o700
9670 );
9671 drift.create_writable().expect("and again changes nothing");
9672
9673 std::fs::remove_dir(&state).expect("state removed");
9674 std::fs::write(&state, "").expect("a file in its place");
9675 assert!(
9676 drift.create_writable().is_err(),
9677 "a file is not a directory"
9678 );
9679 }
9680
9681 #[test]
9682 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
9683 let host = Host::new();
9684 let identity = ServiceIdentity::product();
9685 let plan = InstallPlan::resolve(
9686 identity.clone(),
9687 &host.request(StartMode::Login),
9688 ServiceDirectories::of(&host.paths),
9689 )
9690 .expect("a resolvable plan");
9691 let installed = launchd_plist(&plan);
9692 let definition =
9693 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
9694 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
9695 record.installed_by_version = "0.4.31".into();
9696 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
9697
9698 assert_eq!(
9699 launchd_definition_drift(&identity, &record, path.clone(), &installed),
9700 None
9701 );
9702 let edited_by_operator = edited(
9703 &installed,
9704 "</dict>\n</plist>\n",
9705 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
9706 );
9707 assert_eq!(
9708 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
9709 None,
9710 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
9711 );
9712
9713 let written_by_0_4_31 = edited(
9714 &installed,
9715 "<string>Interactive</string>",
9716 "<string>Background</string>",
9717 );
9718 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
9719 .expect("a Background plist is drift");
9720 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
9721 assert_eq!(drift.start_mode, StartMode::Login);
9722 assert_eq!(
9723 drift.rendered, installed,
9724 "the repair must write exactly what `service install` writes"
9725 );
9726
9727 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
9728 .with_definition_drift(Some(drift));
9729 let problem = status
9730 .problems()
9731 .iter()
9732 .find(|problem| problem.subject == "definition")
9733 .expect("the drift is reported");
9734 assert!(
9737 problem.detail.contains("0.4.31")
9738 && problem.detail.contains(
9739 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
9740 )
9741 && !problem.detail.contains("service install")
9742 && !problem.detail.contains("systemctl")
9743 && !problem.detail.contains("sudo"),
9744 "{}",
9745 problem.detail
9746 );
9747 }
9748
9749 #[cfg(unix)]
9750 #[test]
9751 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
9752 use std::os::unix::fs::PermissionsExt as _;
9753
9754 let root = tempfile::tempdir().expect("a temporary directory");
9755 let path = root
9756 .path()
9757 .join("io.github.IvanMurzak.runner-manager.plist");
9758 std::fs::write(&path, "<string>Background</string>").expect("old plist");
9759 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
9760 let drift = DefinitionDrift {
9761 path: path.clone(),
9762 installed_by_version: "0.4.31".into(),
9763 rendered: launchd_plist(&linux_plan(StartMode::Login)),
9764 start_mode: StartMode::Login,
9765 manager: DefinitionKind::LaunchdPlist,
9766 writable: Vec::new(),
9767 };
9768
9769 replace_definition("runner-manager", &drift).expect("replaced");
9770
9771 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
9772 assert_eq!(
9773 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
9774 0o644
9775 );
9776 assert_eq!(
9777 std::fs::read_dir(root.path()).unwrap().count(),
9778 1,
9779 "no staging file is left beside the plist"
9780 );
9781 }
9782
9783 #[cfg(target_os = "macos")]
9792 #[test]
9793 #[ignore = "run only by launchd, as the job of the detached-reload test"]
9794 fn detached_reload_job() {
9795 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
9796 let tag = label
9797 .split_once("-selftest-")
9798 .map(|(_, tag)| tag.to_string())
9799 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
9800 let identity = ServiceIdentity::fixture(&tag);
9801 assert_eq!(identity.launchd_label(), label);
9802 let path = std::env::current_dir()
9803 .expect("working directory")
9804 .join(format!("{label}.plist"));
9805 let installed = std::fs::read_to_string(&path).expect("own plist");
9806 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
9807 let drift = DefinitionDrift {
9808 path: path.clone(),
9809 installed_by_version: "0.4.31".into(),
9810 rendered: installed.replace(
9811 "<string>Background</string>",
9812 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
9813 ),
9814 start_mode: StartMode::Login,
9815 manager: DefinitionKind::LaunchdPlist,
9816 writable: Vec::new(),
9817 };
9818 replace_definition(identity.name(), &drift).expect("plist replaced");
9819 super::sys::reload_detached(&identity, StartMode::Login, &path)
9820 .expect("helper started");
9821 }
9822 std::thread::sleep(Duration::from_secs(120));
9823 }
9824
9825 #[cfg(target_os = "macos")]
9835 #[test]
9836 fn a_job_reloads_itself_from_its_rewritten_plist() {
9837 if !super::launchd_fixture::enabled() {
9838 return;
9839 }
9840 let root = tempfile::tempdir().expect("a temporary directory");
9841 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
9842 let label = identity.launchd_label();
9843 let path = root.path().join(format!("{label}.plist"));
9844 let binary = super::launchd_fixture::job_binary(root.path());
9845 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
9846 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
9847 plist.push_str(&plist_string("Label", &label));
9848 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
9849 for argument in [
9850 binary.to_string_lossy().as_ref(),
9851 "--ignored",
9852 "--exact",
9853 "service::tests::detached_reload_job",
9854 "--test-threads=1",
9855 ] {
9856 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
9857 }
9858 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
9859 plist.push_str(&plist_string(
9860 "WorkingDirectory",
9861 &root.path().to_string_lossy(),
9862 ));
9863 plist.push_str(&plist_string("ProcessType", "Background"));
9864 plist.push_str("</dict>\n</plist>\n");
9865 std::fs::write(&path, plist).expect("plist written");
9866
9867 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
9868 let spawn_type = || {
9869 let printed = std::process::Command::new("launchctl")
9870 .args(["print", loaded.target()])
9871 .output()
9872 .expect("launchctl runs");
9873 String::from_utf8_lossy(&printed.stdout)
9874 .lines()
9875 .find_map(|line| {
9876 line.trim()
9877 .strip_prefix("spawn type = ")
9878 .map(str::to_string)
9879 })
9880 };
9881 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
9882
9883 let deadline = std::time::Instant::now() + Duration::from_secs(90);
9884 while spawn_type().as_deref() != Some("interactive (4)") {
9885 assert!(
9886 std::time::Instant::now() < deadline,
9887 "the job did not come back under its rewritten plist; launchd reports {:?}",
9888 spawn_type()
9889 );
9890 std::thread::sleep(Duration::from_millis(250));
9891 }
9892 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
9893 assert_eq!(
9894 plist_string_value(&rewritten, "ProcessType").as_deref(),
9895 Some(LAUNCHD_PROCESS_TYPE)
9896 );
9897 }
9898
9899 #[test]
9900 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
9901 let plist = launchd_plist(&linux_plan(StartMode::Boot));
9902 assert!(
9903 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
9904 "{plist}"
9905 );
9906 assert!(plist.contains("<string>daemon</string>"), "{plist}");
9907 assert!(plist.contains("<string>run</string>"), "{plist}");
9908 }
9909
9910 #[test]
9911 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
9912 assert_eq!(
9913 ServiceIdentity::product().launchd_label(),
9914 "io.github.IvanMurzak.runner-manager"
9915 );
9916 }
9917
9918 #[test]
9923 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
9924 let xml = windows_scheduled_task_xml(
9925 &windows_plan(StartMode::Login),
9926 &TaskPrincipal::named("HOST\\operator"),
9927 );
9928 assert!(xml.contains("<LogonTrigger>"), "{xml}");
9929 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
9930 assert!(
9931 xml.contains("<LogonType>InteractiveToken</LogonType>"),
9932 "{xml}"
9933 );
9934 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
9935 assert!(
9936 xml.contains("<Interval>PT1M</Interval>"),
9937 "Task Scheduler takes whole minutes only, and rejects the registration outright \
9938 for anything finer:\n{xml}"
9939 );
9940 }
9941
9942 #[test]
9943 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
9944 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
9949 let policy =
9950 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
9951 .expect("inside the supported range");
9952 assert_eq!(
9953 policy
9954 .effective_delay(DefinitionKind::WindowsScheduledTask)
9955 .as_secs(),
9956 enforced,
9957 "a {asked}s delay must be enforced as {enforced}s"
9958 );
9959 assert!(
9960 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
9961 "rounding must never shorten the bound"
9962 );
9963 }
9964 }
9965
9966 #[test]
9967 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
9968 let policy = RestartPolicy::default();
9969 for kind in [
9970 DefinitionKind::WindowsService,
9971 DefinitionKind::LaunchdPlist,
9972 DefinitionKind::SystemdUnit,
9973 ] {
9974 assert_eq!(
9975 policy.effective_delay(kind),
9976 policy.delay(),
9977 "{kind:?} takes seconds and enforces exactly what it is given"
9978 );
9979 }
9980 }
9981
9982 #[test]
9983 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
9984 let host = Host::new();
9985 let operations = host.operations();
9986 operations
9987 .install(&host.request(StartMode::Login))
9988 .expect("an install at login");
9989 host.controls.edit("runner-manager", |registration| {
9991 registration.manager = DefinitionKind::WindowsScheduledTask;
9992 registration.restart_delay = Some(Duration::from_secs(60));
9993 });
9994
9995 let status = operations.status().expect("a status");
9996 assert!(
9997 status.is_healthy(),
9998 "minute granularity is the manager's, not a mis-registration: {status}"
9999 );
10000 assert!(
10001 status
10002 .notes()
10003 .iter()
10004 .any(|note| note.contains("whole minutes")),
10005 "but the operator must be told why 15 became 60: {status}"
10006 );
10007
10008 host.controls.edit("runner-manager", |registration| {
10011 registration.restart_delay = Some(Duration::from_secs(1));
10012 });
10013 assert!(
10014 !operations.status().expect("a status").is_healthy(),
10015 "a one-second delay is not what any manager was asked for"
10016 );
10017 }
10018
10019 #[test]
10020 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
10021 let plan = windows_plan(StartMode::Login);
10022 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
10023 assert_eq!(
10024 xml_value(&xml, "Command").as_deref(),
10025 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
10026 "{xml}"
10027 );
10028 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
10029 }
10030
10031 #[test]
10032 fn an_account_name_holding_xml_punctuation_is_escaped() {
10033 let xml = windows_scheduled_task_xml(
10034 &windows_plan(StartMode::Login),
10035 &TaskPrincipal::named("R&D\\ops"),
10036 );
10037 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
10038 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
10039 }
10040
10041 #[test]
10046 fn the_service_starts_automatically_under_the_account_the_store_admits() {
10047 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
10048 let directives = ini_directives(&text, "windows-service");
10049 assert_eq!(
10050 directives.get("StartType").map(String::as_str),
10051 Some("AutoStart")
10052 );
10053 assert_eq!(
10054 directives.get("Account").map(String::as_str),
10055 Some("NT AUTHORITY\\SYSTEM")
10056 );
10057 assert_eq!(
10058 directives.get("ServiceType").map(String::as_str),
10059 Some("OWN_PROCESS")
10060 );
10061 assert_eq!(
10062 directives
10063 .get("FailureActionRestartDelaySecs")
10064 .map(String::as_str),
10065 Some("15")
10066 );
10067 assert_eq!(
10068 directives
10069 .get("FailureActionsOnNonCrashFailures")
10070 .map(String::as_str),
10071 Some("true"),
10072 "without this flag a non-zero exit is not a failure the manager restarts"
10073 );
10074 }
10075
10076 #[test]
10077 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
10078 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
10079 assert_eq!(spec.account, None);
10080 assert!(spec.automatic_start);
10081 assert!(
10082 spec.command_line.contains("daemon run"),
10083 "{}",
10084 spec.command_line
10085 );
10086 }
10087
10088 #[test]
10093 fn each_definition_goes_where_its_platform_expects_it() {
10094 let home = PathBuf::from("/home/op");
10095 assert_eq!(
10096 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
10097 Some(Path::new(
10098 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
10099 ))
10100 );
10101 assert_eq!(
10102 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
10103 Some(Path::new(
10104 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
10105 ))
10106 );
10107 assert_eq!(
10108 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
10109 Some(Path::new("/etc/systemd/system/runner-manager.service"))
10110 );
10111 assert_eq!(
10112 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
10113 Some(Path::new(
10114 "/home/op/.config/systemd/user/runner-manager.service"
10115 ))
10116 );
10117 assert_eq!(
10118 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
10119 None,
10120 "the Service Control Manager has no file"
10121 );
10122 }
10123
10124 #[test]
10125 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
10126 assert_eq!(
10127 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
10128 None
10129 );
10130 assert_eq!(
10131 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
10132 None
10133 );
10134 }
10135
10136 #[test]
10141 fn the_rendered_definitions_are_all_least_privilege() {
10142 let linux = linux_plan(StartMode::Boot);
10143 let windows = windows_plan(StartMode::Boot);
10144 for (definition, plan) in [
10145 (ServiceDefinition::systemd(&linux, None), &linux),
10146 (ServiceDefinition::launchd(&linux, None), &linux),
10147 (ServiceDefinition::windows_service(&windows), &windows),
10148 ] {
10149 let review = review_least_privilege(&definition, plan);
10150 assert!(
10151 review.is_least_privilege(),
10152 "{:?} should be least privilege, got:\n{review}",
10153 definition.kind()
10154 );
10155 assert!(
10156 !review.controls().is_empty(),
10157 "a review that confirms nothing proves nothing: {:?}",
10158 definition.kind()
10159 );
10160 }
10161 }
10162
10163 #[test]
10164 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
10165 let plan = windows_plan(StartMode::Login);
10166 let definition =
10167 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
10168 let review = review_least_privilege(&definition, &plan);
10169 assert!(review.is_least_privilege(), "{review}");
10170 assert!(
10171 review
10172 .controls()
10173 .iter()
10174 .any(|control| control.contains("LeastPrivilege")),
10175 "{review}"
10176 );
10177 }
10178
10179 #[test]
10180 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
10181 let plan = linux_plan(StartMode::Boot);
10182 let rendered = systemd_unit(&plan);
10183 let widened = edited(
10184 &rendered,
10185 "ReadWritePaths=/var/lib/runner-manager/config",
10186 "ReadWritePaths=/etc /var/lib/runner-manager/config",
10187 );
10188 let review = review_least_privilege(
10189 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10190 &plan,
10191 );
10192 assert!(!review.is_least_privilege(), "{review}");
10193 assert!(
10194 review
10195 .excesses()
10196 .iter()
10197 .any(|finding| finding.detail.contains("/etc")),
10198 "the review must name the directory it objects to: {review}"
10199 );
10200 }
10201
10202 #[test]
10203 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
10204 let plan = linux_plan(StartMode::Boot);
10205 let rendered = systemd_unit(&plan);
10206 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
10207 let review = review_least_privilege(
10208 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
10209 &plan,
10210 );
10211 assert!(!review.is_least_privilege(), "{review}");
10212 assert!(
10213 review
10214 .excesses()
10215 .iter()
10216 .any(|finding| finding.subject == "NoNewPrivileges"),
10217 "{review}"
10218 );
10219 }
10220
10221 fn reports(unit: &str, plan: &InstallPlan, subject: &str, kind: FindingKind) -> bool {
10223 review_least_privilege(
10224 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, unit.to_string()),
10225 plan,
10226 )
10227 .findings()
10228 .iter()
10229 .any(|finding| finding.subject == subject && finding.kind == kind)
10230 }
10231
10232 #[test]
10233 fn protected_paths_are_inaccessible_and_the_review_keeps_them_so() {
10234 let plan = linux_plan(StartMode::Boot).with_protected_paths(vec![
10235 PathBuf::from("/srv/keys"),
10236 PathBuf::from("/data/my backups"),
10237 ]);
10238 let unit = systemd_unit(&plan);
10239 let directives = ini_directives(&unit, "Service");
10240 assert_eq!(
10241 split_quoted(&directives["InaccessiblePaths"]),
10242 ["-/srv/keys", "-/data/my backups"]
10243 );
10244 assert!(!reports(
10245 &unit,
10246 &plan,
10247 "InaccessiblePaths",
10248 FindingKind::Excess
10249 ));
10250 let dropped = unit.replace(
10251 &format!("InaccessiblePaths={}\n", directives["InaccessiblePaths"]),
10252 "",
10253 );
10254 assert!(reports(
10255 &dropped,
10256 &plan,
10257 "InaccessiblePaths",
10258 FindingKind::Excess
10259 ));
10260 assert!(
10261 !ini_directives(&systemd_unit(&linux_plan(StartMode::Boot)), "Service")
10262 .contains_key("InaccessiblePaths")
10263 );
10264 }
10265
10266 #[cfg(unix)]
10268 #[test]
10269 fn a_path_every_job_or_the_service_needs_cannot_be_protected() {
10270 let own = [PathBuf::from("/var/lib/runner-manager/config")];
10271 for refused in [
10272 "/",
10273 "/usr",
10274 "/etc",
10275 "/tmp",
10276 "relative",
10277 "/srv/../etc",
10278 "/var/lib",
10279 "/var/lib/runner-manager/config/x",
10280 ] {
10281 assert!(
10282 ProtectedPaths::refusal(Path::new(refused), &own).is_some(),
10283 "{refused}"
10284 );
10285 }
10286 for accepted in ["/srv/keys", "/home/ivan/.ssh", "/var/lib/postgresql"] {
10287 assert_eq!(
10288 ProtectedPaths::refusal(Path::new(accepted), &own),
10289 None,
10290 "{accepted}"
10291 );
10292 }
10293 let directory = tempfile::tempdir().unwrap();
10294 assert_eq!(
10295 ProtectedPaths::load(directory.path()).unwrap(),
10296 ProtectedPaths::default()
10297 );
10298 let list = ProtectedPaths {
10299 paths: vec![PathBuf::from("/srv/keys")],
10300 };
10301 list.save(directory.path()).unwrap();
10302 assert_eq!(ProtectedPaths::load(directory.path()).unwrap(), list);
10303 }
10304
10305 #[test]
10306 fn a_boot_unit_with_directories_under_root_binds_them_back_into_its_tmpfs_home() {
10307 let legacy = InstallPlan::unchecked(
10308 ServiceIdentity::product(),
10309 StartMode::Boot,
10310 "/root/.local/state/runner-manager/bin/runner-manager",
10311 ServiceDirectories {
10312 config: PathBuf::from("/root/.config/runner-manager"),
10313 state: PathBuf::from("/root/.local/state/runner-manager"),
10314 runtime: PathBuf::from("/root/.local/share/runner-manager/runtime"),
10315 logs: PathBuf::from("/root/.local/share/runner-manager/logs"),
10316 },
10317 )
10318 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token");
10319 let unit = systemd_unit(&legacy);
10320 let directives = ini_directives(&unit, "Service");
10321 assert_eq!(directives["ProtectHome"], "tmpfs");
10322 assert_eq!(
10323 directives["BindPaths"],
10324 "/root/.config/runner-manager /root/.local/state/runner-manager \
10325 /root/.local/share/runner-manager/runtime /root/.local/share/runner-manager/logs",
10326 "only the directories under a home tree, never the secrets outside it"
10327 );
10328 assert!(!reports(&unit, &legacy, "BindPaths", FindingKind::Excess));
10329 let widened = unit.replace("BindPaths=", "BindPaths=/root/.ssh /etc:/root/.config ");
10330 assert!(reports(&widened, &legacy, "BindPaths", FindingKind::Excess));
10331 }
10332
10333 #[test]
10334 fn a_move_stops_at_an_operator_drop_in_naming_the_old_tree_but_not_at_its_own() {
10335 let root = tempfile::tempdir().expect("dir");
10336 let drop_ins = root.path().join("runner-manager.service.d");
10337 std::fs::create_dir_all(&drop_ins).unwrap();
10338 let runtime = PathBuf::from("/root/.local/share/runner-manager/runtime");
10339 let config = PathBuf::from("/root/.config/runner-manager");
10340 let trees = [&config, &runtime];
10341 std::fs::write(
10342 drop_ins.join("20-wsl-recovery.conf"),
10343 "# Managed by runner-manager; the exact Windows/WSL recovery bridge.\n[Service]\n\
10344 ReadWritePaths=/root/.config/runner-manager/wsl-recovery\n",
10345 )
10346 .unwrap();
10347 assert_eq!(operator_drop_in_naming(&drop_ins, &trees), None);
10348 std::fs::write(
10349 drop_ins.join("10-actions-tar.conf"),
10350 "[Service]\nEnvironment=\"TMPDIR=/root/.local/share/runner-manager/runtime/tmp\"\n",
10351 )
10352 .unwrap();
10353 std::fs::write(drop_ins.join("notes.txt"), "/root/.config/runner-manager").unwrap();
10354 assert_eq!(
10355 operator_drop_in_naming(&drop_ins, &trees),
10356 Some(drop_ins.join("10-actions-tar.conf"))
10357 );
10358 assert_eq!(
10359 operator_drop_in_naming(&root.path().join("absent"), &trees),
10360 None
10361 );
10362 }
10363
10364 #[test]
10365 fn no_unit_breaks_tar_by_answering_openat2_with_enosys() {
10366 for mode in [StartMode::Boot, StartMode::Login] {
10367 let unit = systemd_unit(&linux_plan(mode));
10368 assert!(
10369 !unit.contains("RestrictSUIDSGID"),
10370 "RestrictSUIDSGID fails every tar extraction in a job: {unit}"
10371 );
10372 }
10373 }
10374
10375 #[test]
10376 fn only_a_boot_daemon_keeps_capabilities_and_boot_units_hide_home_trees() {
10377 let boot = systemd_unit(&linux_plan(StartMode::Boot));
10378 let directives = ini_directives(&boot, "Service");
10379 assert_eq!(directives["CapabilityBoundingSet"], BOOT_CAPABILITIES);
10380 assert_eq!(directives["AmbientCapabilities"], "");
10381 assert_eq!(directives["NoNewPrivileges"], "yes");
10382 assert_eq!(directives["ProtectHome"], "tmpfs");
10383 assert!(!directives.contains_key("BindPaths"));
10384
10385 let login = systemd_unit(&linux_plan(StartMode::Login));
10386 let directives = ini_directives(&login, "Service");
10387 assert_eq!(directives["CapabilityBoundingSet"], "");
10388 assert!(!directives.contains_key("ProtectHome"));
10389
10390 let unprotected = boot.replace("ProtectHome=tmpfs\n", "");
10391 assert!(reports(
10392 &unprotected,
10393 &linux_plan(StartMode::Boot),
10394 "ProtectHome",
10395 FindingKind::Excess
10396 ));
10397 let boot_set = format!("CapabilityBoundingSet={BOOT_CAPABILITIES}\n");
10398 let inverted = boot.replace(&boot_set, "CapabilityBoundingSet=~CAP_SYS_ADMIN\n");
10399 assert!(reports(
10400 &inverted,
10401 &linux_plan(StartMode::Boot),
10402 "CapabilityBoundingSet",
10403 FindingKind::Excess
10404 ));
10405 let widened = boot.replace(
10406 &boot_set,
10407 &format!("CapabilityBoundingSet={BOOT_CAPABILITIES} CAP_SYS_ADMIN\n"),
10408 );
10409 assert!(reports(
10410 &widened,
10411 &linux_plan(StartMode::Boot),
10412 "CapabilityBoundingSet",
10413 FindingKind::Excess
10414 ));
10415 let narrowed = boot.replace(&boot_set, "CapabilityBoundingSet=CAP_SETUID\n");
10416 assert!(reports(
10417 &narrowed,
10418 &linux_plan(StartMode::Boot),
10419 "CapabilityBoundingSet",
10420 FindingKind::Shortfall
10421 ));
10422 }
10423
10424 #[test]
10425 fn status_reports_the_registered_account_and_falls_back_to_the_record() {
10426 let recorded = ServiceAccount::for_start_mode(StartMode::Boot).to_string();
10427 for (registered, shown) in [(Some("ivan"), "ivan"), (None, recorded.as_str())] {
10428 let host = Host::new();
10429 let operations = host.operations();
10430 operations.install(&host.request(StartMode::Boot)).unwrap();
10431 host.controls.edit("runner-manager", |registration| {
10432 registration.account = registered.map(str::to_string);
10433 });
10434 let status = operations.status().unwrap().to_string();
10435 assert!(
10436 status.contains(&format!("account {shown}\n")),
10437 "{status}"
10438 );
10439 }
10440 }
10441
10442 #[cfg(target_os = "linux")]
10443 #[test]
10444 fn a_boot_install_refuses_a_unit_a_drop_in_runs_as_another_account() {
10445 let host = Host::new();
10446 let operations = host.operations();
10447 operations.install(&host.request(StartMode::Boot)).unwrap();
10448 host.controls.edit("runner-manager", |registration| {
10449 registration.account = Some("ivan".to_string());
10450 });
10451 let refused = operations
10452 .install(&host.request(StartMode::Boot))
10453 .expect_err("a unit running as ivan cannot use root's tree");
10454 let message = refused.to_string();
10455 assert!(message.contains("run as ivan"), "{message}");
10456 assert!(message.contains("Remove `User=`"), "{message}");
10457 }
10458
10459 #[test]
10460 fn manager_account_spellings_match_the_record() {
10461 assert_eq!(
10462 windows_service_account("LocalSystem"),
10463 ServiceAccount::LocalSystem.as_str()
10464 );
10465 assert_eq!(windows_service_account(".\\svc-runner"), ".\\svc-runner");
10466 assert_eq!(
10467 scheduled_task_account(Some("S-1-5-21-1-2-3-1001".into())),
10468 None
10469 );
10470 assert_eq!(
10471 scheduled_task_account(Some("HOST\\op".into())),
10472 Some("HOST\\op".to_string())
10473 );
10474 }
10475
10476 #[test]
10477 fn exec_start_prefixes_are_not_part_of_the_binary() {
10478 for (value, expected) in [
10479 ("/opt/rm daemon run", "/opt/rm daemon run"),
10480 ("-/opt/rm daemon run", "/opt/rm daemon run"),
10481 ("+!/opt/rm daemon run", "/opt/rm daemon run"),
10482 ("!!/opt/rm daemon run", "/opt/rm daemon run"),
10483 ("@/opt/rm runner-manager daemon run", "/opt/rm daemon run"),
10484 ("-@\"/opt/my rm\" rm daemon", "\"/opt/my rm\" daemon"),
10485 ] {
10486 assert_eq!(systemd_exec_command_line(value), expected, "{value}");
10487 }
10488 assert_eq!(
10489 executable_from_command_line(&systemd_exec_command_line("-/opt/rm daemon")),
10490 Some(PathBuf::from("/opt/rm"))
10491 );
10492 }
10493
10494 #[test]
10495 fn restart_sec_accepts_systemd_time_spans() {
10496 for (value, expected) in [
10497 ("15", Duration::from_secs(15)),
10498 ("15s", Duration::from_secs(15)),
10499 ("1min", Duration::from_secs(60)),
10500 ("1min 30s", Duration::from_secs(90)),
10501 ("1min30s", Duration::from_secs(90)),
10502 ("2h", Duration::from_secs(7_200)),
10503 ("500ms", Duration::from_millis(500)),
10504 ("1.5s", Duration::from_millis(1_500)),
10505 ] {
10506 assert_eq!(systemd_timespan(value), Some(expected), "{value}");
10507 }
10508 for value in ["", "infinity", "soon", "5 parsecs", "s"] {
10509 assert_eq!(systemd_timespan(value), None, "{value}");
10510 }
10511 }
10512
10513 #[cfg(unix)]
10514 #[test]
10515 fn the_system_tree_is_reported_as_roots_installation_under_var_lib() {
10516 let system = ServiceDirectories::of(&AppPaths::system());
10517 assert_eq!(system.root(), Path::new("/var/lib/runner-manager"));
10518 assert_eq!(
10519 data_root_owner(&system.root(), Some(Path::new("/home/op"))).as_deref(),
10520 Some("root")
10521 );
10522 }
10523
10524 #[test]
10525 fn a_boot_service_refuses_directories_it_could_not_open() {
10526 let var = Path::new("/var/lib/runner-manager/state");
10527 assert_eq!(boot_directory_refusal(var, Some(0)), None);
10528 let refused = boot_directory_refusal(var, Some(1000)).expect("another owner");
10529 assert!(refused.contains("uid 1000"), "{refused}");
10530 assert!(boot_directory_refusal(var, None).is_some());
10531 for hidden in ["/home/op/.config/runner-manager", "/root/.local/state/rm"] {
10532 let refused =
10533 boot_directory_refusal(Path::new(hidden), Some(0)).expect("a hidden tree");
10534 assert!(refused.contains("cannot see"), "{refused}");
10535 }
10536 }
10537
10538 #[cfg(unix)]
10539 #[test]
10540 fn a_moved_tree_keeps_files_modes_and_links_but_not_the_skipped_entry() {
10541 use std::os::unix::fs::PermissionsExt as _;
10542
10543 let from = tempfile::tempdir().unwrap();
10544 let to = tempfile::tempdir().unwrap();
10545 let target = to.path().join("state");
10546 std::fs::create_dir_all(from.path().join("journal/2026")).unwrap();
10547 std::fs::write(from.path().join("journal/2026/attempt"), b"entry").unwrap();
10548 std::fs::write(from.path().join("secret"), b"x").unwrap();
10549 std::fs::set_permissions(
10550 from.path().join("secret"),
10551 std::fs::Permissions::from_mode(0o600),
10552 )
10553 .unwrap();
10554 std::os::unix::fs::symlink("journal", from.path().join("latest")).unwrap();
10555 std::fs::create_dir(from.path().join("bin")).unwrap();
10556
10557 copy_tree(from.path(), &target, Some("bin")).unwrap();
10558
10559 assert_eq!(
10560 std::fs::read(target.join("journal/2026/attempt")).unwrap(),
10561 b"entry"
10562 );
10563 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
10564 assert_eq!(mode(&target.join("secret")), 0o600);
10565 assert_eq!(mode(&target.join("journal")), 0o700);
10566 assert_eq!(
10567 std::fs::read_link(target.join("latest")).unwrap(),
10568 Path::new("journal")
10569 );
10570 assert!(!target.join("bin").exists());
10571
10572 std::fs::write(target.join("stale.sqlite3-wal"), b"stale").unwrap();
10574 set_aside(&target, "20261110T000000Z").unwrap();
10575 assert!(!target.exists());
10576 let aside = to.path().join("state.before-move-20261110T000000Z");
10577 assert_eq!(
10578 std::fs::read(aside.join("stale.sqlite3-wal")).unwrap(),
10579 b"stale"
10580 );
10581 copy_tree(from.path(), &target, Some("bin")).unwrap();
10582 assert!(!target.join("stale.sqlite3-wal").exists());
10583 set_aside(&to.path().join("absent"), "x").unwrap();
10584 }
10585
10586 #[test]
10587 fn systemd_drop_ins_replace_base_service_directives() {
10588 let base = systemd_unit(&linux_plan(StartMode::Boot));
10589 let combined = format!(
10590 "# /etc/systemd/system/runner-manager.service\n{base}\n\
10591 # /etc/systemd/system/runner-manager.service.d/account.conf\n\
10592 [Service]\nUser=ivan\nRestartSec=30\nExecStart=\n\
10593 ExecStart=/opt/runner-manager daemon run\n"
10594 );
10595 let directives = ini_directives(&combined, "Service");
10596 assert_eq!(directives["User"], "ivan");
10597 assert_eq!(directives["RestartSec"], "30");
10598 assert_eq!(directives["ExecStart"], "/opt/runner-manager daemon run");
10599 assert_eq!(directives["ProtectSystem"], "strict");
10600 }
10601
10602 #[test]
10603 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
10604 let plan = linux_plan(StartMode::Login);
10605 let rendered = systemd_unit(&plan);
10606 let widened = edited(
10607 &rendered,
10608 "CapabilityBoundingSet=\n",
10609 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
10610 );
10611 let review = review_least_privilege(
10612 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10613 &plan,
10614 );
10615 assert!(!review.is_least_privilege(), "{review}");
10616 assert!(
10617 review
10618 .excesses()
10619 .iter()
10620 .any(|finding| finding.subject == "CapabilityBoundingSet"),
10621 "{review}"
10622 );
10623 }
10624
10625 #[test]
10626 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
10627 let plan = linux_plan(StartMode::Boot);
10628 let rendered = systemd_unit(&plan);
10629 let widened = edited(
10630 &rendered,
10631 "[Install]",
10632 "ListenStream=127.0.0.1:9000\n\n[Install]",
10633 );
10634 let review = review_least_privilege(
10635 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
10636 &plan,
10637 );
10638 assert!(
10639 !review.is_least_privilege(),
10640 "07-security.md rule 2 forbids any inbound surface: {review}"
10641 );
10642 }
10643
10644 #[test]
10645 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
10646 let plan = linux_plan(StartMode::Boot);
10647 let rendered = systemd_unit(&plan);
10648 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
10649 let review = review_least_privilege(
10650 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
10651 &plan,
10652 );
10653 assert!(
10654 review.is_least_privilege(),
10655 "too little authority is not an excess: {review}"
10656 );
10657 assert!(
10658 review
10659 .findings()
10660 .iter()
10661 .any(|finding| finding.kind == FindingKind::Shortfall
10662 && finding.detail.contains("runtime")),
10663 "{review}"
10664 );
10665 }
10666
10667 #[test]
10668 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
10669 let plan = linux_plan(StartMode::Login);
10670 let rendered = launchd_plist(&plan);
10671 let widened = edited(
10672 &rendered,
10673 "<key>ProcessType</key>",
10674 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
10675 );
10676 let review = review_least_privilege(
10677 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10678 &plan,
10679 );
10680 assert!(!review.is_least_privilege(), "{review}");
10681 assert!(
10682 review
10683 .excesses()
10684 .iter()
10685 .any(|finding| finding.subject == "UserName"),
10686 "{review}"
10687 );
10688 }
10689
10690 #[test]
10691 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
10692 let plan = linux_plan(StartMode::Boot);
10693 let rendered = launchd_plist(&plan);
10694 let widened = edited(
10695 &rendered,
10696 "<key>SessionCreate</key>\n <false/>",
10697 "<key>SessionCreate</key>\n <true/>",
10698 );
10699 let review = review_least_privilege(
10700 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10701 &plan,
10702 );
10703 assert!(!review.is_least_privilege(), "{review}");
10704 assert!(
10705 review
10706 .excesses()
10707 .iter()
10708 .any(|finding| finding.subject == "SessionCreate"),
10709 "{review}"
10710 );
10711 }
10712
10713 #[test]
10714 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
10715 let plan = linux_plan(StartMode::Boot);
10716 let rendered = launchd_plist(&plan);
10717 let widened = edited(
10718 &rendered,
10719 "<key>ProcessType</key>",
10720 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
10721 );
10722 let review = review_least_privilege(
10723 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
10724 &plan,
10725 );
10726 assert!(!review.is_least_privilege(), "{review}");
10727 }
10728
10729 #[test]
10730 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
10731 let plan = windows_plan(StartMode::Login);
10732 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10733 let widened = edited(
10734 &rendered,
10735 "<RunLevel>LeastPrivilege</RunLevel>",
10736 "<RunLevel>HighestAvailable</RunLevel>",
10737 );
10738 let review = review_least_privilege(
10739 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10740 &plan,
10741 );
10742 assert!(!review.is_least_privilege(), "{review}");
10743 assert!(
10744 review
10745 .excesses()
10746 .iter()
10747 .any(|finding| finding.subject == "RunLevel"),
10748 "{review}"
10749 );
10750 }
10751
10752 #[test]
10753 fn a_task_that_would_store_a_password_is_not_least_privilege() {
10754 let plan = windows_plan(StartMode::Login);
10755 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
10756 let widened = edited(
10757 &rendered,
10758 "<LogonType>InteractiveToken</LogonType>",
10759 "<LogonType>Password</LogonType>",
10760 );
10761 let review = review_least_privilege(
10762 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
10763 &plan,
10764 );
10765 assert!(!review.is_least_privilege(), "{review}");
10766 }
10767
10768 #[test]
10769 fn an_interactive_windows_service_is_not_least_privilege() {
10770 let plan = windows_plan(StartMode::Boot);
10771 let rendered = windows_service_descriptor(&plan);
10772 let widened = edited(
10773 &rendered,
10774 "ServiceType=OWN_PROCESS",
10775 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
10776 );
10777 let review = review_least_privilege(
10778 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
10779 &plan,
10780 );
10781 assert!(!review.is_least_privilege(), "{review}");
10782 assert!(
10783 review
10784 .excesses()
10785 .iter()
10786 .any(|finding| finding.subject == "ServiceType"),
10787 "{review}"
10788 );
10789 }
10790
10791 #[test]
10792 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
10793 let plan = windows_plan(StartMode::Boot);
10794 let rendered = windows_service_descriptor(&plan);
10795 let changed = edited(
10796 &rendered,
10797 "Account=NT AUTHORITY\\SYSTEM",
10798 "Account=NT AUTHORITY\\LocalService",
10799 );
10800 let review = review_least_privilege(
10801 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
10802 &plan,
10803 );
10804 assert!(review.is_least_privilege(), "{review}");
10810 assert!(
10811 review.findings().iter().any(|finding| {
10812 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
10813 }),
10814 "{review}"
10815 );
10816 }
10817
10818 #[test]
10823 fn a_recorded_path_that_is_still_there_is_current() {
10824 let host = Host::new();
10825 let state = inspect_binary(&host.binary, Some(&host.binary));
10826 assert!(!state.is_error(), "{state}");
10827 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
10828 }
10829
10830 #[test]
10831 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
10832 let host = Host::new();
10833 let recorded = host.binary.clone();
10837 let healthy = inspect_binary(&recorded, Some(&recorded));
10838 assert!(
10839 !healthy.is_error(),
10840 "the discriminator: before the binary moves, this must be healthy"
10841 );
10842
10843 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
10844
10845 let state = inspect_binary(&recorded, Some(&recorded));
10846 assert!(state.is_error(), "{state}");
10847 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
10848 assert!(
10849 state.to_string().contains("npm"),
10850 "the message must name the cause an operator will not otherwise connect: {state}"
10851 );
10852 }
10853
10854 #[test]
10855 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
10856 let root = tempfile::tempdir().expect("a temporary directory");
10857 let state = inspect_binary(root.path(), None);
10858 assert!(state.is_error(), "{state}");
10859 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
10860 }
10861
10862 #[test]
10863 fn a_registration_naming_a_different_binary_is_a_divergence() {
10864 let host = Host::new();
10865 let other = host.binary.with_file_name("something-else");
10866 let state = inspect_binary(&host.binary, Some(&other));
10867 assert!(state.is_error(), "{state}");
10868 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
10869 }
10870
10871 #[test]
10872 fn absence_is_reported_before_divergence() {
10873 let host = Host::new();
10874 let recorded = host.binary.clone();
10875 std::fs::remove_file(&recorded).expect("removable");
10876 let other = recorded.with_file_name("something-else");
10877 assert!(matches!(
10881 inspect_binary(&recorded, Some(&other)),
10882 BinaryPath::Missing { .. }
10883 ));
10884 }
10885
10886 #[test]
10891 fn the_record_round_trips_through_toml() {
10892 let host = Host::new();
10893 let plan = InstallPlan::resolve(
10894 ServiceIdentity::product(),
10895 &host.request(StartMode::Boot),
10896 ServiceDirectories::of(&host.paths),
10897 )
10898 .expect("a resolvable plan");
10899 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
10900 let record = InstallRecord::of(&plan, &definition, Utc::now());
10901 record.write(&host.paths).expect("a writable record");
10902 let read = InstallRecord::read(&host.paths)
10903 .expect("a readable record")
10904 .expect("a record is there");
10905 assert_eq!(read, record);
10906 assert_eq!(read.binary, host.binary);
10907 assert!(read.binary.is_absolute());
10908 }
10909
10910 #[test]
10911 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
10912 let host = Host::new();
10913 let identity = ServiceIdentity::product();
10914 let plan = InstallPlan::resolve(
10915 identity.clone(),
10916 &host.request(StartMode::Boot),
10917 ServiceDirectories::of(&host.paths),
10918 )
10919 .expect("a resolvable plan");
10920 let installed = systemd_unit(&plan);
10921 let definition =
10922 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
10923 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
10924 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
10925
10926 assert_eq!(
10927 systemd_definition_drift(&identity, &record, path.clone(), &installed),
10928 None,
10929 "the record must rebuild exactly the unit `service install` wrote"
10930 );
10931
10932 record.installed_by_version = "0.4.7".into();
10933 let frozen = installed.replace(
10934 "[Service]\n",
10935 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
10936 );
10937 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
10938 .expect("an outdated unit is drift");
10939 assert_eq!(drift.installed_by_version, "0.4.7");
10940 assert_eq!(drift.rendered, installed);
10941
10942 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
10943 .with_definition_drift(Some(drift));
10944 assert!(!status.is_healthy());
10945 assert!(
10946 status
10947 .problems()
10948 .iter()
10949 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
10950 "{status}"
10951 );
10952 }
10953
10954 #[test]
10955 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
10956 let host = Host::new();
10957 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10958 let first = Utc::now() - chrono::Duration::hours(9);
10959 record_github_credential_rejected(&host.paths, first).unwrap();
10960 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
10961 let since = github_credential_rejected_since(&host.paths).unwrap();
10962 assert_eq!(since, Some(first));
10963
10964 let status = ServiceStatus::compose(
10965 ServiceIdentity::product(),
10966 None,
10967 None,
10968 None,
10969 None,
10970 &host.paths,
10971 )
10972 .with_credential_rejection(since);
10973 assert!(!status.is_healthy());
10974 assert_eq!(status.credential_rejected_since(), Some(first));
10975 assert!(status.to_string().contains("auth login"), "{status}");
10976
10977 clear_github_credential_rejection(&host.paths).unwrap();
10978 clear_github_credential_rejection(&host.paths).unwrap();
10979 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
10980 }
10981
10982 #[test]
10984 fn blocked_launches_make_the_service_unhealthy_with_their_remedy() {
10985 use crate::launch_health::{
10986 BlockCause, Viewpoint, clear_launches_blocked, launches_blocked,
10987 record_launches_blocked,
10988 };
10989
10990 let host = Host::new();
10991 let compose = || {
10992 ServiceStatus::compose(
10993 ServiceIdentity::product(),
10994 None,
10995 None,
10996 None,
10997 None,
10998 &host.paths,
10999 )
11000 .with_launches_blocked(launches_blocked(&host.paths, Utc::now()))
11001 };
11002 assert!(
11003 !compose()
11004 .problems()
11005 .iter()
11006 .any(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
11007 );
11008 let blocked = BlockCause::FenceHeld {
11009 directory: PathBuf::from("launch-fence"),
11010 owner: None,
11011 }
11012 .blocked_since(Utc::now(), Viewpoint::Guest);
11013 record_launches_blocked(&host.paths, &blocked, Utc::now()).unwrap();
11014
11015 let status = compose();
11016 assert!(!status.is_healthy());
11017 assert_eq!(status.launches_blocked(), Some(&blocked));
11018 let problem = status
11019 .problems()
11020 .iter()
11021 .find(|problem| problem.subject == LAUNCHES_BLOCKED_SUBJECT)
11022 .expect("reported under its own subject");
11023 assert!(problem.detail.contains("systemctl restart"), "{status}");
11024
11025 clear_launches_blocked(&host.paths).unwrap();
11026 assert_eq!(compose().launches_blocked(), None);
11027 }
11028
11029 #[test]
11032 fn an_unreadable_credential_keeps_its_first_moment_and_names_the_exact_command() {
11033 let host = Host::new();
11034 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
11035 let first = Utc::now() - chrono::Duration::minutes(40);
11036 record_credential_unreadable(&host.paths, first).unwrap();
11037 record_credential_unreadable(&host.paths, Utc::now()).unwrap();
11038 let since = credential_unreadable_since(&host.paths).unwrap();
11039 assert_eq!(since, Some(first), "a restart loop must not move the start");
11040
11041 let status = ServiceStatus::compose(
11042 ServiceIdentity::product(),
11043 None,
11044 None,
11045 None,
11046 None,
11047 &host.paths,
11048 )
11049 .with_credential_unreadable(since);
11050 assert!(!status.is_healthy());
11051 let problem = status
11052 .problems()
11053 .iter()
11054 .find(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT)
11055 .expect("reported under its own subject");
11056 assert!(
11058 problem
11059 .detail
11060 .contains(&sign_in_instruction(StartMode::default())),
11061 "{status}"
11062 );
11063
11064 clear_credential_unreadable(&host.paths).unwrap();
11065 clear_credential_unreadable(&host.paths).unwrap();
11066 assert_eq!(credential_unreadable_since(&host.paths).unwrap(), None);
11067 let healthy = ServiceStatus::compose(
11068 ServiceIdentity::product(),
11069 None,
11070 None,
11071 None,
11072 None,
11073 &host.paths,
11074 )
11075 .with_credential_unreadable(None);
11076 assert!(
11077 !healthy
11078 .problems()
11079 .iter()
11080 .any(|problem| problem.subject == CREDENTIAL_UNREADABLE_SUBJECT),
11081 "{healthy}"
11082 );
11083 }
11084
11085 #[test]
11086 fn a_sudo_command_names_a_binary_sudo_would_not_find() {
11087 assert_eq!(sudo_program(None), "runner-manager");
11088 assert_eq!(
11089 sudo_program(Some(Path::new("/usr/local/bin/runner-manager"))),
11090 "runner-manager"
11091 );
11092 assert_eq!(
11093 sudo_program(Some(Path::new(
11094 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
11095 runner-manager-linux-x64/bin/runner-manager"
11096 ))),
11097 "/home/op/.nvm/versions/node/v26.13.0/lib/node_modules/@ivan-murzak/\
11098 runner-manager-linux-x64/bin/runner-manager"
11099 );
11100 assert_eq!(
11101 sudo_program(Some(Path::new("/home/o'p/my bin/runner-manager"))),
11102 "'/home/o'\\''p/my bin/runner-manager'"
11103 );
11104 assert_eq!(
11105 sudo_command("service status"),
11106 "sudo runner-manager service status"
11107 );
11108 }
11109
11110 #[test]
11111 fn the_sign_in_instruction_names_the_start_mode_it_writes() {
11112 assert!(sign_in_instruction(StartMode::Login).contains("auth login --start-at login`"));
11113 assert!(sign_in_instruction(StartMode::Boot).contains("auth login --start-at boot`"));
11114 assert_eq!(
11115 sign_in_instruction(StartMode::Boot).starts_with("`sudo "),
11116 cfg!(unix),
11117 "a machine-scoped store needs root on Unix"
11118 );
11119 assert_eq!(
11120 sign_in_instruction(StartMode::Login).contains("SSH"),
11121 cfg!(target_os = "macos"),
11122 "only the macOS login keychain is out of an SSH session's reach"
11123 );
11124 }
11125
11126 #[cfg(unix)]
11135 #[test]
11136 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
11137 use std::os::unix::fs::PermissionsExt as _;
11138
11139 let host = Host::new();
11140 let plan = InstallPlan::resolve(
11141 ServiceIdentity::product(),
11142 &host.request(StartMode::Boot),
11143 ServiceDirectories::of(&host.paths),
11144 )
11145 .expect("a resolvable plan");
11146 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
11147 InstallRecord::of(&plan, &definition, Utc::now())
11148 .write(&host.paths)
11149 .expect("a writable record");
11150
11151 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
11152 .expect("the record is there")
11153 .permissions()
11154 .mode()
11155 & 0o777;
11156 assert_eq!(
11157 mode, 0o644,
11158 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
11159 service install` wrote, and `service status` fails on their own host. It holds no \
11160 credential and sits in a 0700 directory, so 0644 discloses nothing"
11161 );
11162 }
11163
11164 #[test]
11168 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
11169 let host = Host::new();
11170 let path = InstallRecord::path(&host.paths);
11171 std::fs::write(
11172 &path,
11173 format!(
11174 "schema_version = {RECORD_SCHEMA_VERSION}
11175service_name = \"runner-manager\"
11176 manager = \"systemd\"
11177start_mode = \"boot\"
11178account = \"root\"
11179 binary = \"/x\"
11180arguments = []
11181restart_delay_secs = 15
11182 restart_reset_secs = 600
11183log_file = \"/x\"
11184 installed_at = \"2026-01-01T00:00:00Z\"
11185installed_by_version = \"0.1.0\"
11186 [directories]
11187config = \"/a\"
11188state = \"/b\"
11189runtime = \"/c\"
11190logs = \"/d\"
11191"
11192 ),
11193 )
11194 .expect("a writable record");
11195 let read = InstallRecord::read(&host.paths)
11196 .expect("a record missing an optional field is still readable")
11197 .expect("a record is there");
11198 assert_eq!(
11199 read.source_binary, None,
11200 "the legacy layout has no source, and must not invent one"
11201 );
11202 }
11203
11204 #[test]
11206 fn a_registration_remembers_the_file_it_was_copied_from() {
11207 let host = Host::new();
11208 let source = host.binary.with_file_name("npm-installed-runner-manager");
11209 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
11210 let plan = InstallPlan::resolve(
11211 ServiceIdentity::product(),
11212 &host.request(StartMode::Boot).copied_from(&source),
11213 ServiceDirectories::of(&host.paths),
11214 )
11215 .expect("a resolvable plan");
11216 let definition = ServiceDefinition::from_text(
11217 DefinitionKind::SystemdUnit,
11218 "[Service]
11219",
11220 );
11221 let record = InstallRecord::of(&plan, &definition, Utc::now());
11222 record.write(&host.paths).expect("a writable record");
11223
11224 let read = InstallRecord::read(&host.paths)
11225 .expect("a readable record")
11226 .expect("a record is there");
11227 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
11228 assert_ne!(
11229 read.source_binary.as_deref(),
11230 Some(read.binary.as_path()),
11231 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
11232 );
11233 }
11234
11235 #[test]
11236 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
11237 let host = Host::new();
11238 let path = InstallRecord::path(&host.paths);
11239 std::fs::write(
11240 &path,
11241 format!(
11242 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
11243 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
11244 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
11245 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
11246 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
11247 RECORD_SCHEMA_VERSION + 1
11248 ),
11249 )
11250 .expect("a writable record");
11251 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
11252 assert!(
11253 matches!(error, ServiceError::RecordUnreadable { .. }),
11254 "{error}"
11255 );
11256 assert!(
11257 error.to_string().contains("service uninstall"),
11258 "the message must say how to recover: {error}"
11259 );
11260 }
11261
11262 #[test]
11263 fn no_record_is_not_an_error() {
11264 let host = Host::new();
11265 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
11266 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
11267 }
11268
11269 #[test]
11274 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
11275 let host = Host::new();
11276 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
11277 }
11278
11279 #[test]
11280 fn the_heartbeat_round_trips_to_the_second() {
11281 let host = Host::new();
11282 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
11283 .expect("a valid timestamp")
11284 .with_timezone(&Utc);
11285 record_github_contact(&host.paths, at).expect("a writable heartbeat");
11286 assert_eq!(
11287 last_github_contact(&host.paths).expect("readable"),
11288 Some(at)
11289 );
11290 }
11291
11292 #[test]
11293 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
11294 let host = Host::new();
11295 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
11296 let error = last_github_contact(&host.paths)
11297 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
11298 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11299 }
11300
11301 #[test]
11307 fn a_runner_root_refusal_round_trips_for_service_status() {
11308 let host = Host::new();
11309 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11310
11311 assert!(
11312 runner_root_refusals(&host.paths)
11313 .expect("readable")
11314 .is_empty(),
11315 "no record means every policy is placing runners"
11316 );
11317
11318 record_runner_root_refusal(
11319 &host.paths,
11320 "policy-a",
11321 at,
11322 "denied_by_privacy_policy",
11323 "/Volumes/NVME/runners",
11324 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
11325 )
11326 .expect("a writable record");
11327
11328 let refusals = runner_root_refusals(&host.paths).expect("readable");
11329 assert_eq!(refusals.len(), 1);
11330 assert_eq!(refusals[0].policy, "policy-a");
11331 assert_eq!(refusals[0].at, at);
11332 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
11333 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
11334 assert!(
11335 refusals[0].detail.contains("/Volumes/NVME/runners")
11336 && refusals[0].detail.contains("Full Disk Access"),
11337 "the path and the remediation are the whole point of this file: {refusals:?}"
11338 );
11339 }
11340
11341 #[test]
11349 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
11350 let host = Host::new();
11351 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
11352 record_runner_root_refusal(
11353 &host.paths,
11354 "broken",
11355 at,
11356 "denied_by_privacy_policy",
11357 "/Volumes/NVME/runners",
11358 "detail",
11359 )
11360 .expect("a writable record");
11361 record_runner_root_refusal(
11362 &host.paths,
11363 "also-broken",
11364 at,
11365 "not_writable",
11366 "/srv/other",
11367 "detail",
11368 )
11369 .expect("a writable record");
11370
11371 clear_runner_root_refusal(&host.paths, "healthy")
11373 .expect("clearing an absent policy is fine");
11374 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
11375
11376 let refusals = runner_root_refusals(&host.paths).expect("readable");
11377 assert_eq!(
11378 refusals
11379 .iter()
11380 .map(|r| r.policy.as_str())
11381 .collect::<Vec<_>>(),
11382 vec!["broken"],
11383 "the policy that is still refused must keep its record"
11384 );
11385 }
11386
11387 #[test]
11390 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
11391 let host = Host::new();
11392 record_runner_root_refusal(
11393 &host.paths,
11394 "p",
11395 Utc::now(),
11396 "not_writable",
11397 "/srv/x",
11398 "detail",
11399 )
11400 .expect("a writable record");
11401
11402 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
11403 assert!(
11404 !root_refusal_path(&host.paths).exists(),
11405 "a host with nothing refused leaves nothing behind"
11406 );
11407 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
11408 }
11409
11410 #[test]
11413 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
11414 let host = Host::new();
11415 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
11416 let error = runner_root_refusals(&host.paths)
11417 .expect_err("an unparseable record is not the same as no record");
11418 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11419 }
11420
11421 #[test]
11431 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
11432 let host = Host::new();
11433 record_runner_root_refusal(
11434 &host.paths,
11435 "policy-a",
11436 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
11437 "denied_by_privacy_policy",
11438 "/Volumes/NVME/runners",
11439 "Grant Full Disk Access to the program that runs the service",
11440 )
11441 .expect("a writable record");
11442
11443 let status = host.operations().status().expect("a readable status");
11444 let notes = status.notes().join("\n");
11445
11446 assert!(
11447 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
11448 "the directory and the remediation the log had to scrub must appear here: {notes}"
11449 );
11450 assert!(
11451 notes.contains("policy-a"),
11452 "the operator has to know which target placed no runner: {notes}"
11453 );
11454 assert!(
11455 !status
11456 .problems()
11457 .iter()
11458 .any(|problem| problem.subject == "runner root"),
11459 "a record nothing an operator types can clear must not drive the exit code"
11460 );
11461 }
11462
11463 #[test]
11469 fn install_records_the_absolute_binary_path_and_the_four_directories() {
11470 let host = Host::new();
11471 let installed = host
11472 .operations()
11473 .install(&host.request(StartMode::Boot))
11474 .expect("an install against the recording controls");
11475
11476 assert_eq!(installed.record.binary, host.binary);
11477 assert!(installed.record.binary.is_absolute());
11478 assert_eq!(installed.record.start_mode, StartMode::Boot);
11479 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
11480 assert_eq!(
11481 installed.record.directories,
11482 ServiceDirectories::of(&host.paths)
11483 );
11484 assert_eq!(
11485 installed.record.log_file,
11486 host.paths.logs_dir().join(LOG_FILE_STEM)
11487 );
11488 assert_eq!(installed.record.restart_delay_secs, 15);
11489
11490 let registrations = host.controls.registrations();
11491 assert_eq!(registrations.len(), 1);
11492 assert_eq!(registrations[0].0, StartMode::Boot);
11493 assert_eq!(registrations[0].1, "runner-manager");
11494 }
11495
11496 #[test]
11497 fn install_is_refused_while_the_single_instance_lock_is_held() {
11498 let host = Host::new();
11499 {
11501 let operations = host.operations();
11502 operations
11503 .install(&host.request(StartMode::Boot))
11504 .expect("an install with the lock free");
11505 operations.uninstall().expect("a clean slate");
11506 }
11507
11508 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
11509 .expect("this process takes the lock first");
11510
11511 let error = host
11512 .operations()
11513 .install(&host.request(StartMode::Boot))
11514 .expect_err("a second agent must not be registered while one is running");
11515 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
11516 assert!(
11517 error.to_string().contains("already running"),
11518 "the message must be actionable: {error}"
11519 );
11520 assert!(
11521 host.controls.registrations().is_empty(),
11522 "a refused install must register nothing"
11523 );
11524 assert_eq!(
11525 InstallRecord::read(&host.paths).expect("readable"),
11526 None,
11527 "a refused install must write no record"
11528 );
11529 }
11530
11531 #[test]
11539 fn installing_over_the_same_start_mode_replaces_the_registration() {
11540 let host = Host::new();
11541 let operations = host.operations();
11542 operations
11543 .install(&host.request(StartMode::Boot))
11544 .expect("the first install");
11545
11546 let again = operations
11547 .install(&host.request(StartMode::Boot))
11548 .expect("an install over the same mode replaces rather than refusing");
11549
11550 assert!(
11551 again.replaced_existing,
11552 "the operator is told this replaced something rather than made it"
11553 );
11554 assert_eq!(
11555 host.controls.registrations().len(),
11556 1,
11557 "replacing must not leave two registrations behind"
11558 );
11559 assert_eq!(
11560 InstallRecord::read(&host.paths)
11561 .expect("readable")
11562 .expect("a record")
11563 .start_mode,
11564 StartMode::Boot
11565 );
11566 }
11567
11568 #[test]
11572 fn installing_over_the_other_start_mode_is_refused() {
11573 let host = Host::new();
11574 let operations = host.operations();
11575 operations
11576 .install(&host.request(StartMode::Boot))
11577 .expect("the first install");
11578
11579 let error = operations
11580 .install(&host.request(StartMode::Login))
11581 .expect_err("a mode change is not an install");
11582 assert!(
11583 matches!(
11584 error,
11585 ServiceError::AlreadyInstalled {
11586 existing: StartMode::Boot,
11587 requested: StartMode::Login,
11588 ..
11589 }
11590 ),
11591 "{error}"
11592 );
11593 assert!(
11594 !error
11595 .to_string()
11596 .contains("switch the start mode in place,"),
11597 "the old remedy named a capability no command offers; the terminal UI is where \
11598 the start mode moves: {error}"
11599 );
11600 assert_eq!(host.controls.registrations().len(), 1);
11601 }
11602
11603 #[test]
11604 fn install_rolls_back_the_registration_when_record_persistence_fails() {
11605 let host = Host::new();
11606 let record_path = InstallRecord::path(&host.paths);
11607 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
11608
11609 let error = host
11610 .operations()
11611 .install(&host.request(StartMode::Boot))
11612 .expect_err("record persistence must fail");
11613
11614 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
11615 assert!(
11616 host.controls.registrations().is_empty(),
11617 "a failed install must not leave a live unrecorded registration"
11618 );
11619 assert!(
11620 host.controls
11621 .calls()
11622 .iter()
11623 .any(|call| call == "uninstall runner-manager (boot)"),
11624 "the registration must be explicitly rolled back: {:?}",
11625 host.controls.calls()
11626 );
11627 assert!(
11628 !host.runner_root.as_path().exists(),
11629 "the rollback must take the runner root this install created with it; a directory \
11630 prepared for a registration that does not exist is litter, and on Windows it is \
11631 litter with a security descriptor"
11632 );
11633 }
11634
11635 #[test]
11640 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
11641 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
11642
11643 assert_eq!(
11647 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
11648 ServiceAccount::LocalSystem
11649 );
11650 assert_eq!(
11651 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
11652 ServiceAccount::InvokingUser
11653 );
11654
11655 let boot = default_root_sddl(&RootAdmission::LocalSystem);
11656 assert!(!grants_broad_write(&boot), "{boot}");
11657 assert!(
11658 !boot.contains("S-1-5-21"),
11659 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
11660 );
11661
11662 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
11667 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
11668 assert!(!grants_broad_write(&login), "{login}");
11669 }
11670
11671 #[test]
11672 fn an_install_reports_the_runner_root_it_prepared() {
11673 let host = Host::new();
11674 let installed = host
11675 .operations()
11676 .install(&host.request(StartMode::Boot))
11677 .expect("an install");
11678 let rendered = installed.runner_root.to_string();
11679 assert!(
11680 !rendered.contains("S-1-5-21"),
11681 "the report must add no identity to the output: {rendered}"
11682 );
11683 if cfg!(windows) {
11684 assert_eq!(
11685 installed.runner_root.path(),
11686 Some(host.runner_root.as_path())
11687 );
11688 assert!(
11689 host.runner_root.as_path().is_dir(),
11690 "the directory jobs would run in has to exist once the service is registered"
11691 );
11692 } else {
11693 assert_eq!(
11694 installed.runner_root,
11695 crate::runner_root_access::RootAccessSummary::NotApplicable,
11696 "macOS and Linux keep the runtime directory they have always used"
11697 );
11698 }
11699 }
11700
11701 #[test]
11702 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
11703 let host = Host::new();
11704 let operations = host.operations();
11705 operations
11706 .install(&host.request(StartMode::Boot))
11707 .expect("an install at boot");
11708
11709 let change = operations
11710 .set_start_mode(StartMode::Login)
11711 .expect("a switch to login");
11712
11713 assert!(change.changed);
11714 if cfg!(windows) {
11715 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
11716 assert!(
11717 host.runner_root.as_path().is_dir(),
11718 "the switch must not remove the directory it reconciled"
11719 );
11720 }
11721 assert!(
11724 change.to_string().contains("runner root"),
11725 "{}",
11726 change.to_string()
11727 );
11728 }
11729
11730 #[test]
11731 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
11732 let host = Host::new();
11733 let operations = host.operations();
11734 operations
11735 .install(&host.request(StartMode::Boot))
11736 .expect("an install at boot");
11737
11738 let change = operations
11739 .set_start_mode(StartMode::Boot)
11740 .expect("a switch to the mode already in force");
11741
11742 assert!(!change.changed);
11743 assert_eq!(
11744 change.runner_root,
11745 crate::runner_root_access::RootAccessSummary::NotApplicable,
11746 "nothing moves, so nothing about the root's access control has to; reconciling here \
11747 would turn a no-op command into one that can fail on a permission it does not need"
11748 );
11749 }
11750
11751 #[cfg(windows)]
11752 #[test]
11753 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
11754 let host = Host::new();
11755 host.controls
11756 .fail_next_install(StartMode::Boot, "injected registration failure");
11757
11758 let error = host
11759 .operations()
11760 .install(&host.request(StartMode::Boot))
11761 .expect_err("the manager refuses the registration");
11762
11763 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
11764 assert!(
11765 !host.runner_root.as_path().exists(),
11766 "the directory was created for a registration that does not exist"
11767 );
11768 }
11769
11770 #[cfg(windows)]
11771 #[test]
11772 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
11773 let host = Host::new();
11774 crate::runner_root_access::create_with_descriptor_for_tests(
11778 host.runner_root.as_path(),
11779 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
11780 )
11781 .expect("a deliberately open runner root");
11782 let before = crate::runner_root_access::report(host.runner_root.as_path());
11783
11784 let error = host
11785 .operations()
11786 .install(&host.request(StartMode::Boot))
11787 .expect_err("an open runner root is refused");
11788
11789 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
11790 assert!(
11791 error.to_string().contains("nothing was registered"),
11792 "{error}"
11793 );
11794 assert!(
11795 host.controls.registrations().is_empty(),
11796 "the refusal has to come before the platform is asked to register anything: {:?}",
11797 host.controls.calls()
11798 );
11799 assert_eq!(
11800 crate::runner_root_access::report(host.runner_root.as_path()),
11801 before,
11802 "an open directory is refused rather than tightened: its contents cannot be trusted, \
11803 so adopting it would be worse than declining it"
11804 );
11805 }
11806
11807 #[cfg(windows)]
11808 #[test]
11809 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
11810 let host = Host::new();
11811 let operations = host.operations();
11812 operations
11813 .install(&host.request(StartMode::Boot))
11814 .expect("an install");
11815 assert!(host.runner_root.as_path().is_dir());
11816
11817 operations.uninstall().expect("an uninstall");
11818
11819 assert!(
11820 host.runner_root.as_path().is_dir(),
11821 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
11822 runner root may hold an operator's retained workspaces."
11823 );
11824 }
11825
11826 #[test]
11827 fn install_reviews_what_it_registered() {
11828 let host = Host::new();
11829 let installed = host
11830 .operations()
11831 .install(&host.request(StartMode::Boot))
11832 .expect("an install");
11833 assert!(
11834 installed.review.is_least_privilege(),
11835 "{}",
11836 installed.review
11837 );
11838 assert!(
11839 !installed.review.controls().is_empty(),
11840 "a review that confirms nothing proves nothing: {}",
11841 installed.review
11842 );
11843 assert_eq!(
11844 installed.review.kind(),
11845 host_definition_kind(StartMode::Boot),
11846 "the review must be of the definition this host's manager was given"
11847 );
11848 assert!(
11849 !installed.review.account().justification().is_empty(),
11850 "a privileged account with no stated reason is an unreviewed one"
11851 );
11852 }
11853
11854 #[test]
11859 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
11860 let host = Host::new();
11861 let operations = host.operations();
11862 operations
11863 .install(&host.request(StartMode::Boot))
11864 .expect("an install");
11865
11866 let config = host.paths.config_dir();
11870 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
11871 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
11872 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
11873 std::fs::write(
11874 host.paths
11875 .state_dir()
11876 .join("packages/2.330.0/runner.tar.gz"),
11877 b"cached package",
11878 )
11879 .expect("writable");
11880 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
11881 std::fs::write(
11882 host.paths.state_dir().join("secrets/user-access-token"),
11883 b"a stand-in for the stored credential",
11884 )
11885 .expect("writable");
11886 std::fs::write(
11887 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
11888 b"diagnostics",
11889 )
11890 .expect("writable");
11891
11892 let roots: Vec<PathBuf> = host
11893 .paths
11894 .all()
11895 .iter()
11896 .map(|(_, path)| (*path).to_path_buf())
11897 .collect();
11898 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
11899 let before = snapshot(&roots);
11900
11901 assert!(
11904 before.len() >= 6,
11905 "the fixture must actually contain the files this test is about, got {before:#?}"
11906 );
11907 let record_path = InstallRecord::path(&host.paths);
11908 assert!(
11909 before.contains_key(&record_path),
11910 "the install record must be present before uninstall"
11911 );
11912
11913 let uninstalled = operations.uninstall().expect("an uninstall");
11914 assert!(uninstalled.removed_registration);
11915 assert!(uninstalled.removed_record);
11916
11917 let after = snapshot(&roots);
11918
11919 let mut expected = before.clone();
11921 expected.remove(&record_path);
11922 assert_eq!(
11923 after, expected,
11924 "uninstall must remove its own record and nothing else"
11925 );
11926 assert!(
11927 !record_path.exists(),
11928 "the record itself must go, or `uninstall` did nothing at all"
11929 );
11930 assert!(
11931 uninstalled
11932 .preserved
11933 .iter()
11934 .all(|path| roots.contains(&path.as_path())),
11935 "the preserved list must name the four directories: {uninstalled}"
11936 );
11937 }
11938
11939 #[test]
11940 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
11941 let host = Host::new();
11942 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
11943 assert!(!uninstalled.removed_registration);
11944 assert!(!uninstalled.removed_record);
11945 }
11946
11947 #[test]
11948 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
11949 let host = Host::new();
11950 let operations = host.operations();
11951 operations
11952 .install(&host.request(StartMode::Boot))
11953 .expect("an install");
11954 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
11955
11956 let uninstalled = operations.uninstall().expect("an uninstall");
11957 assert!(
11958 uninstalled.removed_registration,
11959 "a lost record must not strand a registration"
11960 );
11961 assert!(host.controls.registrations().is_empty());
11962 }
11963
11964 #[test]
11969 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
11970 let host = Host::new();
11971 let operations = host.operations();
11972 operations
11973 .install(&host.request(StartMode::Boot))
11974 .expect("an install at boot");
11975
11976 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
11980
11981 let change = operations
11982 .set_start_mode(StartMode::Login)
11983 .expect("a switch that does not reinstall the product");
11984 assert!(change.changed);
11985 assert_eq!(change.from, StartMode::Boot);
11986 assert_eq!(change.to, StartMode::Login);
11987 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
11988
11989 let record = InstallRecord::read(&host.paths)
11990 .expect("readable")
11991 .expect("a record");
11992 assert_eq!(record.start_mode, StartMode::Login);
11993 assert_eq!(
11994 record.binary, host.binary,
11995 "the recorded path must survive the switch untouched"
11996 );
11997
11998 let registrations = host.controls.registrations();
11999 assert_eq!(registrations.len(), 1, "{registrations:?}");
12000 assert_eq!(registrations[0].0, StartMode::Login);
12001 assert!(
12002 registrations[0]
12003 .2
12004 .command_line
12005 .contains(&host.binary.to_string_lossy().into_owned()),
12006 "{:?}",
12007 registrations[0].2
12008 );
12009 }
12010
12011 #[test]
12012 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
12013 let host = Host::new();
12014 let operations = host.operations();
12015 operations
12016 .install(&host.request(StartMode::Boot))
12017 .expect("an install at boot");
12018 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
12019 host.controls
12020 .fail_next_install(StartMode::Login, "injected target failure");
12021
12022 let error = operations
12023 .set_start_mode(StartMode::Login)
12024 .expect_err("the target manager refuses the install");
12025
12026 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
12027 assert_eq!(
12028 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
12029 record_before
12030 );
12031 let registrations = host.controls.registrations();
12032 assert_eq!(registrations.len(), 1, "{registrations:?}");
12033 assert_eq!(registrations[0].0, StartMode::Boot);
12034 }
12035
12036 #[test]
12037 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
12038 let host = Host::new();
12039 let operations = host.operations();
12040 operations
12041 .install(&host.request(StartMode::Boot))
12042 .expect("an install at boot");
12043 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
12044 let config = host.paths.config_dir().to_path_buf();
12045 let hidden = config.with_file_name("config-hidden-by-fault");
12046 host.controls.hide_directory_after_install(
12047 StartMode::Login,
12048 config.clone(),
12049 hidden.clone(),
12050 );
12051
12052 let error = operations
12053 .set_start_mode(StartMode::Login)
12054 .expect_err("the injected filesystem fault prevents persistence");
12055
12056 std::fs::remove_file(&config).expect("remove the injected blocker");
12057 std::fs::rename(&hidden, &config).expect("restore the record directory");
12058 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
12059 assert_eq!(
12060 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
12061 record_before
12062 );
12063 let registrations = host.controls.registrations();
12064 assert_eq!(registrations.len(), 1, "{registrations:?}");
12065 assert_eq!(registrations[0].0, StartMode::Boot);
12066 assert!(
12067 host.controls
12068 .calls()
12069 .iter()
12070 .any(|call| call == "uninstall runner-manager (login)"),
12071 "the target must be rolled back: {:?}",
12072 host.controls.calls()
12073 );
12074 }
12075
12076 #[test]
12077 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
12078 let host = Host::new();
12079 let operations = host.operations();
12080 operations
12081 .install(&host.request(StartMode::Boot))
12082 .expect("an install");
12083 let before = host.controls.calls().len();
12084
12085 let change = operations
12086 .set_start_mode(StartMode::Boot)
12087 .expect("a no-op switch");
12088 assert!(!change.changed);
12089 assert_eq!(
12090 host.controls.calls().len(),
12091 before,
12092 "a no-op switch must not touch the service manager"
12093 );
12094 }
12095
12096 #[test]
12097 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
12098 let host = Host::new();
12099 let error = host
12100 .operations()
12101 .set_start_mode(StartMode::Login)
12102 .expect_err("there is nothing to switch");
12103 assert!(
12104 matches!(error, ServiceError::NotInstalled { .. }),
12105 "{error}"
12106 );
12107 }
12108
12109 #[test]
12114 fn status_reports_the_four_facts_journey_five_asks_for() {
12115 let host = Host::new();
12116 let operations = host.operations();
12117 operations
12118 .install(&host.request(StartMode::Boot))
12119 .expect("an install");
12120 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
12121 .expect("a valid timestamp")
12122 .with_timezone(&Utc);
12123 record_github_contact(&host.paths, at).expect("a heartbeat");
12124
12125 let status = operations.status().expect("a status");
12126 assert_eq!(status.start_mode(), Some(StartMode::Boot));
12127 assert_eq!(
12128 status.binary().map(BinaryPath::recorded),
12129 Some(host.binary.as_path())
12130 );
12131 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
12132 assert_eq!(status.last_github_contact(), Some(at));
12133 assert!(status.is_installed());
12134 assert!(status.is_healthy(), "{status}");
12135
12136 let printed = status.to_string();
12137 for fragment in [
12138 "start mode",
12139 "diagnostic log",
12140 "last GitHub contact",
12141 "binary",
12142 ] {
12143 assert!(printed.contains(fragment), "{printed}");
12144 }
12145 }
12146
12147 #[cfg(unix)]
12159 #[test]
12160 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
12161 use std::os::unix::fs::PermissionsExt as _;
12162
12163 if unsafe { libc::geteuid() } == 0 {
12167 return;
12168 }
12169
12170 let host = Host::new();
12171 let operations = host.operations();
12172 operations
12173 .install(&host.request(StartMode::Boot))
12174 .expect("an install");
12175 assert!(
12176 operations.status().expect("a status").is_healthy(),
12177 "the discriminator: healthy before the record is made unreadable"
12178 );
12179
12180 let record = InstallRecord::path(&host.paths);
12181 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
12182 .expect("the mode is applied");
12183
12184 let status = operations
12185 .status()
12186 .expect("a record this account may not read is reported, not thrown");
12187 assert!(status.is_installed(), "{status}");
12188 assert!(!status.is_healthy(), "{status}");
12189
12190 let printed = status.to_string();
12191 assert!(
12192 printed.contains("this account may not read it"),
12193 "the operator is told which of the two states this is: {printed}"
12194 );
12195 assert!(
12196 !printed.contains("there is no install record"),
12197 "a record that is there and unreadable is not a record that is missing, and the \
12198 missing one's remedy starts with `service uninstall`: {printed}"
12199 );
12200
12201 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
12204 .expect("the mode is restored");
12205 }
12206
12207 #[test]
12208 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
12209 let host = Host::new();
12210 let operations = host.operations();
12211 operations
12212 .install(&host.request(StartMode::Boot))
12213 .expect("an install");
12214
12215 assert!(
12218 operations.status().expect("a status").is_healthy(),
12219 "the freshly installed host must be healthy"
12220 );
12221
12222 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
12223
12224 let status = operations.status().expect("a status");
12225 assert!(!status.is_healthy(), "{status}");
12226 assert!(
12227 status
12228 .problems()
12229 .iter()
12230 .any(|problem| problem.subject == "binary"),
12231 "{status}"
12232 );
12233 assert!(status.to_string().contains("STALE"), "{status}");
12234 }
12235
12236 #[test]
12237 fn status_reports_a_registration_that_would_not_start_at_boot() {
12238 let host = Host::new();
12239 let operations = host.operations();
12240 operations
12241 .install(&host.request(StartMode::Boot))
12242 .expect("an install");
12243 assert!(operations.status().expect("a status").is_healthy());
12244
12245 host.controls.edit("runner-manager", |registration| {
12246 registration.starts_automatically = false;
12247 });
12248
12249 let status = operations.status().expect("a status");
12250 assert!(!status.is_healthy(), "{status}");
12251 assert!(
12252 status
12253 .problems()
12254 .iter()
12255 .any(|problem| problem.detail.contains("after a reboot")),
12256 "{status}"
12257 );
12258 }
12259
12260 #[test]
12261 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
12262 let host = Host::new();
12263 let operations = host.operations();
12264 operations
12265 .install(&host.request(StartMode::Login))
12266 .expect("install and immediate start");
12267
12268 assert!(
12269 operations.status().expect("running status").is_running(),
12270 "install must not wait for the next login trigger"
12271 );
12272 operations.stop().expect("stop the registration");
12273 let status = operations.status().expect("stopped status");
12274 assert!(!status.is_healthy(), "{status}");
12275 assert!(
12276 status
12277 .problems()
12278 .iter()
12279 .any(|problem| problem.subject == "runtime"),
12280 "{status}"
12281 );
12282 let rendered = status.to_string();
12283 assert!(
12284 rendered.contains("runner-manager service install --start-at login"),
12285 "{rendered}"
12286 );
12287 assert!(
12288 !rendered.contains("runner-manager service start"),
12289 "{rendered}"
12290 );
12291 }
12292
12293 #[test]
12294 fn status_reports_a_restart_policy_something_else_edited() {
12295 let host = Host::new();
12296 let operations = host.operations();
12297 operations
12298 .install(&host.request(StartMode::Boot))
12299 .expect("an install");
12300 assert!(operations.status().expect("a status").is_healthy());
12301
12302 host.controls.edit("runner-manager", |registration| {
12303 registration.restart_delay = Some(Duration::from_secs(1));
12304 });
12305
12306 let status = operations.status().expect("a status");
12307 assert!(!status.is_healthy(), "{status}");
12308 assert!(
12309 status
12310 .problems()
12311 .iter()
12312 .any(|problem| problem.subject == "restart policy"),
12313 "{status}"
12314 );
12315 }
12316
12317 #[test]
12318 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
12319 let host = Host::new();
12320 let operations = host.operations();
12321 operations
12322 .install(&host.request(StartMode::Boot))
12323 .expect("an install");
12324 let other = host.binary.with_file_name("someone-elses.exe");
12325 std::fs::write(&other, b"x").expect("writable");
12326
12327 host.controls.edit("runner-manager", |registration| {
12328 registration.command_line = quote_argument(&other.to_string_lossy());
12329 });
12330
12331 let status = operations.status().expect("a status");
12332 assert!(!status.is_healthy(), "{status}");
12333 assert!(
12334 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12335 "{status}"
12336 );
12337 }
12338
12339 #[test]
12340 fn status_reports_a_record_no_service_manager_knows_about() {
12341 let host = Host::new();
12342 let operations = host.operations();
12343 operations
12344 .install(&host.request(StartMode::Boot))
12345 .expect("an install");
12346 for mode in [StartMode::Boot, StartMode::Login] {
12348 host.controls
12349 .control(mode)
12350 .expect("a control")
12351 .uninstall(&ServiceIdentity::product())
12352 .expect("removed");
12353 }
12354
12355 let status = operations.status().expect("a status");
12356 assert!(!status.is_healthy(), "{status}");
12357 assert!(
12358 status
12359 .problems()
12360 .iter()
12361 .any(|problem| problem.subject == "registration"),
12362 "{status}"
12363 );
12364 }
12365
12366 #[test]
12367 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
12368 let host = Host::new();
12369 let status = host.operations().status().expect("a status");
12370 assert!(!status.is_installed());
12371 assert!(
12372 status.is_healthy(),
12373 "a host that never installed the service has no fault to report: {status}"
12374 );
12375 assert!(status.to_string().contains("installed"), "{status}");
12376 }
12377
12378 #[test]
12379 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
12380 let host = Host::new();
12381 let operations = host.operations();
12382 operations
12383 .install(&host.request(StartMode::Login))
12384 .expect("an install at login");
12385 let status = operations.status().expect("a status");
12386 assert!(
12387 status
12388 .notes()
12389 .iter()
12390 .any(|note| note.contains("does not run until the operator signs in")),
12391 "05-infrastructure.md requires `service status` to say so: {status}"
12392 );
12393 }
12394
12395 #[test]
12398 fn a_mac_with_automatic_login_as_the_service_account_resumes_after_a_reboot() {
12399 use crate::unattended_login::{AutoLogin, UnattendedLogin};
12400 let automatic = UnattendedLogin {
12401 auto_login: AutoLogin::As("ivan".into()),
12402 filevault_on: Some(false),
12403 };
12404 assert_eq!(
12405 unattended_gap(StartMode::Login, "ivan", Some(&automatic)),
12406 None
12407 );
12408
12409 let off = UnattendedLogin {
12410 auto_login: AutoLogin::Off,
12411 filevault_on: Some(false),
12412 };
12413 let gap = unattended_gap(StartMode::Login, "ivan", Some(&off)).expect("a gap");
12414 assert!(gap.reason.contains("automatic login is off"), "{gap:?}");
12415 assert!(gap.reason.contains("Automatically log in as"), "{gap:?}");
12416
12417 let locked = UnattendedLogin {
12418 auto_login: AutoLogin::As("ivan".into()),
12419 filevault_on: Some(true),
12420 };
12421 let gap = unattended_gap(StartMode::Boot, "root", Some(&locked)).expect("a gap");
12422 assert!(gap.reason.contains("FileVault is on"), "{gap:?}");
12423
12424 let unread = UnattendedLogin {
12427 auto_login: AutoLogin::As("ivan".into()),
12428 filevault_on: None,
12429 };
12430 assert_eq!(
12431 unattended_gap(StartMode::Login, "ivan", Some(&unread)),
12432 None
12433 );
12434
12435 let gap = unattended_gap(StartMode::Login, "ivan", None).expect("a gap");
12437 assert!(
12438 gap.reason
12439 .contains("does not run until the operator signs in")
12440 );
12441 assert_eq!(unattended_gap(StartMode::Boot, "root", None), None);
12442 }
12443
12444 #[test]
12447 fn a_daemon_that_stopped_beating_makes_the_service_unhealthy() {
12448 let host = Host::new();
12449 let operations = host.operations();
12450 operations
12451 .install(&host.request(StartMode::Login))
12452 .expect("an install at login");
12453 let stalled_problem = |operations: &ServiceOperations| {
12454 operations
12455 .status()
12456 .expect("a status")
12457 .problems()
12458 .iter()
12459 .find(|problem| problem.subject == DAEMON_STALLED_SUBJECT)
12460 .map(|problem| problem.detail.clone())
12461 };
12462 crate::daemon_heartbeat::beat(&operations.paths, Utc::now()).unwrap();
12463 assert_eq!(stalled_problem(&operations), None, "beating");
12464
12465 crate::daemon_heartbeat::beat(
12466 &operations.paths,
12467 Utc::now() - chrono::Duration::minutes(20),
12468 )
12469 .unwrap();
12470 let detail = stalled_problem(&operations).expect("a stall is a problem");
12471 assert!(detail.contains("has made no progress since"), "{detail}");
12472 let restart = ServiceIdentity::product().restart_command(StartMode::Login);
12475 assert!(detail.contains(&restart), "{detail}");
12476 assert!(!detail.contains("runner-manager service stop"), "{detail}");
12477 assert!(!detail.contains("runner-manager service start"), "{detail}");
12478 }
12479
12480 #[test]
12484 fn the_restart_command_is_the_service_managers_own() {
12485 let identity = ServiceIdentity::product();
12486 let boot = identity.restart_command(StartMode::Boot);
12487 let login = identity.restart_command(StartMode::Login);
12488 for command in [&boot, &login] {
12489 assert!(!command.contains("runner-manager service"), "{command}");
12490 assert!(command.contains(identity.name()), "{command}");
12491 }
12492 assert_ne!(boot, login);
12493 if cfg!(windows) {
12494 assert!(
12495 boot.starts_with("Restart-Service -Name runner-manager"),
12496 "{boot}"
12497 );
12498 assert!(
12499 login.contains("schtasks /Run /TN runner-manager"),
12500 "{login}"
12501 );
12502 } else if cfg!(target_os = "macos") {
12503 assert!(
12504 boot.starts_with("sudo launchctl kickstart -k system/"),
12505 "{boot}"
12506 );
12507 assert!(login.starts_with("launchctl kickstart -k gui/"), "{login}");
12508 } else {
12509 assert_eq!(boot, "sudo systemctl restart runner-manager.service");
12510 assert_eq!(login, "systemctl --user restart runner-manager.service");
12511 }
12512 }
12513
12514 #[test]
12516 fn a_daemon_that_finds_the_host_unfit_makes_the_service_unhealthy() {
12517 let host = Host::new();
12518 let operations = host.operations();
12519 operations
12520 .install(&host.request(StartMode::Login))
12521 .expect("an install at login");
12522 let paths = operations.paths.clone();
12523 crate::host_fitness::record_host_unfit(
12524 &paths,
12525 &[crate::host_fitness::UnfitFinding {
12526 id: "host.runner_root_responsive".into(),
12527 detail: "macOS has not let the service list /Volumes/NVME/runners".into(),
12528 remedy: Some("click Allow".into()),
12529 }],
12530 Utc::now(),
12531 )
12532 .unwrap();
12533 let status = operations.status().expect("a status");
12534 assert!(!status.is_healthy(), "{status}");
12535 let problem = status
12536 .problems()
12537 .iter()
12538 .find(|problem| problem.subject == HOST_UNFIT_SUBJECT)
12539 .expect("the refusal is a problem");
12540 assert!(
12541 problem.detail.contains("/Volumes/NVME/runners"),
12542 "{problem:?}"
12543 );
12544 assert!(problem.detail.contains("Fix: click Allow"), "{problem:?}");
12545
12546 crate::host_fitness::clear_host_unfit(&paths).unwrap();
12549 crate::host_fitness::record_host_unfit(
12550 &paths,
12551 &[],
12552 Utc::now() - chrono::Duration::hours(2),
12553 )
12554 .unwrap();
12555 let status = operations.status().expect("a status");
12556 assert!(
12557 !status
12558 .problems()
12559 .iter()
12560 .any(|problem| problem.subject == HOST_UNFIT_SUBJECT),
12561 "{status}"
12562 );
12563 }
12564
12565 #[test]
12570 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
12571 let host = Host::new();
12572 let operations = host.operations();
12573 operations
12574 .install(&host.request(StartMode::Login))
12575 .expect("an install at login");
12576 operations.start().expect("a start");
12577 assert!(operations.status().expect("a status").is_running());
12578 assert!(operations.stop().expect("a stop"));
12579 assert!(!operations.status().expect("a status").is_running());
12580 }
12581
12582 #[test]
12583 fn starting_a_host_with_no_registration_is_refused() {
12584 let host = Host::new();
12585 let error = host.operations().start().expect_err("nothing to start");
12586 assert!(
12587 matches!(error, ServiceError::NotInstalled { .. }),
12588 "{error}"
12589 );
12590 }
12591
12592 #[cfg(windows)]
12608 #[test]
12609 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
12610 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
12611
12612 let root = tempfile::tempdir().expect("a temporary directory");
12613 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
12614 .expect("a rooted machine-scoped store");
12615 store
12616 .store(&secrecy::SecretString::from("a stand-in for the token"))
12617 .expect("the store accepts a value");
12618 let protection = store.protection().expect("the DACL can be read back");
12619
12620 assert!(
12621 protection.description().contains(";;;SY)"),
12622 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
12623 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
12624 protection.description()
12625 );
12626 assert_eq!(
12627 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
12628 ServiceAccount::LocalSystem,
12629 "and that is the account this installer registers, which is why SY is what matters"
12630 );
12631 assert!(
12632 !protection.readable_by_other_local_users(),
12633 "the same DACL must still exclude ordinary local users: {}",
12634 protection.description()
12635 );
12636
12637 for rejected in [";;;LS)", ";;;NS)"] {
12641 assert!(
12642 !protection.description().contains(rejected),
12643 "if the store ever admitted {rejected}, the least-privilege analysis in \
12644 docs/service-account.md would need redoing: {}",
12645 protection.description()
12646 );
12647 }
12648 }
12649
12650 fn request_with_directories(host: &Host, mode: StartMode) -> InstallRequest {
12655 let mut arguments: Vec<OsString> = DAEMON_ARGUMENTS.iter().map(OsString::from).collect();
12656 arguments.extend(ServiceDirectories::of(&host.paths).arguments());
12657 host.request(mode).with_arguments(arguments)
12658 }
12659
12660 fn second_account(account: &Host, machine: &Host) -> ServiceOperations {
12663 ServiceOperations::with_controls(
12664 account.paths.clone(),
12665 ServiceIdentity::product(),
12666 std::sync::Arc::new(machine.controls.clone()),
12667 )
12668 .with_runner_root(account.runner_root.clone())
12669 }
12670
12671 fn a_machine_with_a_stale_second_account() -> (Host, Host) {
12675 let owner = Host::new();
12676 owner
12677 .operations()
12678 .install(&request_with_directories(&owner, StartMode::Boot))
12679 .expect("the owner's boot install");
12680 let earlier = Host::new();
12681 earlier
12682 .operations()
12683 .install(&request_with_directories(&earlier, StartMode::Login))
12684 .expect("the second account's earlier login install");
12685 assert!(
12686 InstallRecord::read(&earlier.paths).unwrap().is_some(),
12687 "the earlier install left its record behind"
12688 );
12689 (owner, earlier)
12690 }
12691
12692 const DESTRUCTIVE_ADVICE: [&str; 4] = [
12695 "service uninstall",
12696 "service install`",
12697 "service install --",
12698 "auth login",
12699 ];
12700
12701 #[test]
12702 fn a_service_installed_from_another_data_root_is_reported_as_that_installations() {
12703 let (owner, earlier) = a_machine_with_a_stale_second_account();
12704
12705 let status = second_account(&earlier, &owner).status().expect("a status");
12706
12707 let foreign = status.foreign().expect("the service is the owner's");
12708 assert_eq!(foreign.directories, ServiceDirectories::of(&owner.paths));
12709 assert_eq!(foreign.root, ServiceDirectories::of(&owner.paths).root());
12710 assert!(foreign.unreadable.is_none(), "{status}");
12711 assert!(
12712 status.is_healthy(),
12713 "the owner's service is healthy, and a stale record elsewhere is not its fault: \
12714 {status}"
12715 );
12716 for subject in [
12717 "binary",
12718 "start mode",
12719 "secret store",
12720 "record",
12721 "registration",
12722 ] {
12723 assert!(
12724 !status
12725 .problems()
12726 .iter()
12727 .any(|problem| problem.subject == subject),
12728 "{subject} compared the second account's record against the owner's \
12729 registration: {status}"
12730 );
12731 }
12732 let rendered = status.to_string();
12733 for advice in DESTRUCTIVE_ADVICE {
12734 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12735 }
12736 assert!(rendered.contains("another installation's"), "{rendered}");
12737 assert!(
12738 status
12739 .notes()
12740 .iter()
12741 .any(|note| note.contains("unused local configuration")
12742 && note.contains("host forget-local")),
12743 "{status}"
12744 );
12745 assert_eq!(status.start_mode(), Some(StartMode::Boot));
12747 }
12748
12749 #[test]
12750 fn a_foreign_service_whose_records_cannot_be_read_reports_only_what_the_manager_says() {
12751 let (owner, earlier) = a_machine_with_a_stale_second_account();
12752 InstallRecord::remove(&owner.paths).expect("the owner's record removed");
12755
12756 let operations = second_account(&earlier, &owner);
12757 let status = operations.status().expect("a status");
12758 let foreign = status.foreign().expect("the service is the owner's");
12759 assert!(foreign.unreadable.is_some(), "{status}");
12760 assert!(
12761 status.is_healthy(),
12762 "running, and nothing it could judge: {status}"
12763 );
12764 let rendered = status.to_string();
12765 for advice in DESTRUCTIVE_ADVICE {
12766 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12767 }
12768 assert!(rendered.contains("running; installed from"), "{rendered}");
12769 assert!(!rendered.contains("last GitHub contact"), "{rendered}");
12770
12771 owner.operations().stop().expect("stopped");
12773 let stopped = operations.status().expect("a status");
12774 assert!(!stopped.is_healthy(), "{stopped}");
12775 let rendered = stopped.to_string();
12776 for advice in DESTRUCTIVE_ADVICE {
12777 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12778 }
12779 }
12780
12781 #[test]
12782 fn this_accounts_own_registration_still_reports_every_mismatch() {
12783 let host = Host::new();
12784 let operations = host.operations();
12785 operations
12786 .install(&request_with_directories(&host, StartMode::Boot))
12787 .expect("an install");
12788 let other = host.binary.with_file_name("someone-elses");
12789 std::fs::write(&other, b"x").expect("writable");
12790 host.controls.edit("runner-manager", |registration| {
12792 let mut arguments = split_command_line(®istration.command_line);
12793 arguments[0] = other.to_string_lossy().into_owned();
12794 registration.command_line = arguments
12795 .iter()
12796 .map(|argument| quote_argument(argument))
12797 .collect::<Vec<_>>()
12798 .join(" ");
12799 registration.start_mode = StartMode::Login;
12800 });
12801
12802 let status = operations.status().expect("a status");
12803 assert!(status.foreign().is_none(), "{status}");
12804 assert!(
12805 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
12806 "{status}"
12807 );
12808 assert!(
12809 status
12810 .problems()
12811 .iter()
12812 .any(|problem| problem.subject == "start mode"),
12813 "{status}"
12814 );
12815 assert!(status.to_string().contains("service uninstall"), "{status}");
12816 }
12817
12818 #[test]
12819 fn directories_read_back_from_a_command_line_with_spaces() {
12820 let root = Path::new("/Users/someone/Library/Application Support/runner manager");
12821 let directories = ServiceDirectories {
12822 config: root.join("config"),
12823 state: root.join("data").join("state"),
12824 runtime: root.join("data").join("runtime"),
12825 logs: root.join("data").join("logs"),
12826 };
12827 let mut command_line = quote_argument("/usr/local/bin/runner-manager");
12828 for argument in ["daemon", "run"]
12829 .map(OsString::from)
12830 .into_iter()
12831 .chain(directories.arguments())
12832 {
12833 command_line.push(' ');
12834 command_line.push_str("e_argument(&argument.to_string_lossy()));
12835 }
12836 assert_eq!(
12837 ServiceDirectories::from_command_line(&command_line),
12838 Some(directories.clone())
12839 );
12840 assert_eq!(directories.root(), root);
12841 assert_eq!(
12842 ServiceDirectories::from_command_line("runner-manager daemon run"),
12843 None,
12844 "a registration that names no directories cannot be judged foreign"
12845 );
12846 }
12847
12848 #[test]
12849 fn the_owner_of_a_data_root_is_read_from_its_path() {
12850 let users = Path::new(if cfg!(windows) { r"C:\Users" } else { "/home" });
12851 let home = users.join("ivand");
12852 assert_eq!(
12853 data_root_owner(&users.join("yuriv").join("data"), Some(&home)).as_deref(),
12854 Some("yuriv")
12855 );
12856 assert_eq!(
12857 data_root_owner(
12858 Path::new(if cfg!(windows) { r"D:\rm" } else { "/opt/rm" }),
12859 Some(&home)
12860 ),
12861 None
12862 );
12863 if cfg!(windows) {
12864 assert_eq!(
12865 data_root_owner(
12866 Path::new(r"C:\Windows\System32\config\systemprofile\AppData"),
12867 Some(&home)
12868 )
12869 .as_deref(),
12870 Some("LocalSystem")
12871 );
12872 } else {
12873 assert_eq!(
12874 data_root_owner(Path::new("/root/.config/runner-manager"), Some(&home)).as_deref(),
12875 Some("root")
12876 );
12877 }
12878 }
12879
12880 #[test]
12881 fn forget_local_archives_the_unused_record_and_never_touches_the_service_root() {
12882 let (owner, earlier) = a_machine_with_a_stale_second_account();
12883 let owner_before = snapshot(&[owner._root.path()]);
12884 let config = earlier.paths.config_dir().to_path_buf();
12885 let record = std::fs::read(InstallRecord::path(&earlier.paths)).unwrap();
12886
12887 let forgotten = second_account(&earlier, &owner)
12888 .forget_local(Utc::now())
12889 .expect("archived");
12890
12891 assert_eq!(
12892 snapshot(&[owner._root.path()]),
12893 owner_before,
12894 "the owner's directories must not change by one byte"
12895 );
12896 let archive = forgotten.archive.expect("something was archived");
12897 assert!(archive.starts_with(&config), "{}", archive.display());
12898 assert_eq!(
12899 std::fs::read(archive.join(RECORD_FILE)).unwrap(),
12900 record,
12901 "moved, not rewritten or deleted"
12902 );
12903 assert!(InstallRecord::read(&earlier.paths).unwrap().is_none());
12904 assert_eq!(
12905 forgotten.service_root,
12906 Some(ServiceDirectories::of(&owner.paths).root())
12907 );
12908 let status = second_account(&earlier, &owner).status().expect("status");
12910 assert!(
12911 !status
12912 .notes()
12913 .iter()
12914 .any(|note| note.contains("unused local configuration")),
12915 "{status}"
12916 );
12917 }
12918
12919 #[test]
12920 fn forget_local_refuses_a_configuration_a_service_runs_against() {
12921 let (owner, earlier) = a_machine_with_a_stale_second_account();
12922 let before = snapshot(&[owner._root.path()]);
12923 let refused = owner.operations().forget_local(Utc::now());
12924 assert!(
12925 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12926 "{refused:?}"
12927 );
12928 assert_eq!(snapshot(&[owner._root.path()]), before);
12929
12930 let _agent = HostLock::try_acquire(&earlier.paths, LockKind::SingleInstance)
12932 .expect("the agent's lock");
12933 let refused = second_account(&earlier, &owner).forget_local(Utc::now());
12934 assert!(
12935 matches!(refused, Err(ServiceError::LocalConfigurationInUse { .. })),
12936 "{refused:?}"
12937 );
12938 assert!(InstallRecord::read(&earlier.paths).unwrap().is_some());
12939 }
12940
12941 #[test]
12942 fn a_readable_foreign_service_keeps_its_diagnoses_and_loses_its_remedies() {
12943 let (owner, earlier) = a_machine_with_a_stale_second_account();
12944 owner.operations().stop().expect("stopped");
12947 record_github_credential_rejected(&owner.paths, Utc::now()).expect("rejected");
12948 record_credential_unreadable(&owner.paths, Utc::now()).expect("unreadable");
12949
12950 let status = second_account(&earlier, &owner).status().expect("a status");
12951 assert!(status.foreign().is_some_and(|foreign| !foreign.is_unread()));
12952 let subjects: Vec<_> = status.problems().iter().map(|p| p.subject).collect();
12953 for subject in [
12954 "runtime",
12955 "github credential",
12956 CREDENTIAL_UNREADABLE_SUBJECT,
12957 ] {
12958 assert!(subjects.contains(&subject), "{subject}: {status}");
12959 }
12960 let rendered = status.to_string();
12961 for advice in DESTRUCTIVE_ADVICE {
12962 assert!(!rendered.contains(advice), "{advice}: {rendered}");
12963 }
12964 for problem in status.problems() {
12965 assert!(problem.detail.contains("Act on it"), "{problem}");
12966 }
12967 }
12968
12969 #[test]
12970 fn no_command_of_this_account_changes_another_installations_service() {
12971 let (owner, earlier) = a_machine_with_a_stale_second_account();
12972 let operations = second_account(&earlier, &owner);
12973 let foreign = |result: Result<(), ServiceError>| {
12974 assert!(
12975 matches!(result, Err(ServiceError::ForeignInstallation { .. })),
12976 "{result:?}"
12977 );
12978 };
12979 foreign(
12980 operations
12981 .install(&request_with_directories(&earlier, StartMode::Boot))
12982 .map(drop),
12983 );
12984 foreign(operations.uninstall().map(drop));
12985 foreign(operations.set_start_mode(StartMode::Boot).map(drop));
12986 foreign(operations.start());
12987 foreign(operations.stop().map(drop));
12988
12989 let registration = owner
12990 .controls
12991 .control(StartMode::Boot)
12992 .unwrap()
12993 .query(&ServiceIdentity::product())
12994 .unwrap()
12995 .expect("the owner's registration is still there");
12996 assert_eq!(
12997 ServiceDirectories::from_command_line(®istration.command_line),
12998 Some(ServiceDirectories::of(&owner.paths)),
12999 "and still runs against the owner's directories"
13000 );
13001 assert!(registration.running, "and was never stopped");
13002 assert!(
13003 InstallRecord::read(&earlier.paths).unwrap().is_some(),
13004 "the refused uninstall kept this account's record"
13005 );
13006 }
13007
13008 #[test]
13009 fn forget_local_leaves_agent_directories_nested_in_the_configuration_alone() {
13010 let root = tempfile::tempdir().unwrap();
13013 let config = root.path().join("Application Support");
13014 let paths = AppPaths::from_directories(
13015 &config,
13016 config.join("state"),
13017 config.join("runtime"),
13018 config.join("logs"),
13019 );
13020 paths.create_all().unwrap();
13021 std::fs::write(config.join(RECORD_FILE), b"an earlier install").unwrap();
13022 std::fs::write(paths.logs_dir().join("runner-manager.log"), b"log").unwrap();
13023 let operations = ServiceOperations::with_controls(
13024 paths.clone(),
13025 ServiceIdentity::product(),
13026 std::sync::Arc::new(RecordingControls::new()),
13027 );
13028
13029 let forgotten = operations.forget_local(Utc::now()).expect("archived");
13030
13031 assert_eq!(forgotten.moved, vec![config.join(RECORD_FILE)]);
13032 for kept in [paths.state_dir(), paths.runtime_dir(), paths.logs_dir()] {
13033 assert!(kept.is_dir(), "{} was moved", kept.display());
13034 }
13035 assert!(paths.logs_dir().join("runner-manager.log").is_file());
13036 }
13037
13038 #[test]
13039 fn a_remedy_is_cut_from_every_detail_a_builder_writes() {
13040 for (detail, diagnosis) in [
13041 (
13042 "x holds an automatic registration, but the daemon is stopped. Run `runner-manager \
13043 service install --start-at boot`; if the service manager denies replacement",
13044 "x holds an automatic registration, but the daemon is stopped",
13045 ),
13046 (
13047 "store -- MISMATCH. Run `auth login` again so the token is stored",
13048 "store -- MISMATCH",
13049 ),
13050 (
13051 "unit differs, so the daemon runs under outdated settings. Re-register it with \
13052 `sudo runner-manager service install --start-at boot`",
13053 "unit differs, so the daemon runs under outdated settings",
13054 ),
13055 (
13056 "it starts no runner; the log has it. Sign in again: `runner-manager auth login`",
13057 "it starts no runner; the log has it",
13058 ),
13059 (
13060 "C:\\a\\runner-manager.exe -- STALE: moved. Run `service uninstall` and `service \
13061 install`",
13062 "C:\\a\\runner-manager.exe -- STALE: moved",
13063 ),
13064 (
13065 "a new builder: run `runner-manager service install` here, or not",
13066 "the installation's own records report this",
13067 ),
13068 ] {
13069 assert_eq!(diagnosis_only(detail), diagnosis, "{detail}");
13070 }
13071 }
13072}