1use std::collections::BTreeMap;
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106 Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109pub const SERVICE_NAME: &str = "runner-manager";
115pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126pub const DESCRIPTION: &str =
128 "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137pub const RECORD_FILE: &str = "service.toml";
145
146pub const CONTACT_FILE: &str = "github-contact.toml";
152
153pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
162
163pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
174
175#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ServiceIdentity {
189 name: String,
190 display_name: String,
191 description: String,
192}
193
194impl ServiceIdentity {
195 #[must_use]
197 pub fn product() -> Self {
198 Self {
199 name: SERVICE_NAME.to_string(),
200 display_name: DISPLAY_NAME.to_string(),
201 description: DESCRIPTION.to_string(),
202 }
203 }
204
205 #[must_use]
212 pub fn fixture(tag: &str) -> Self {
213 let tag: String = tag
214 .chars()
215 .map(|c| {
216 if c.is_ascii_alphanumeric() {
217 c.to_ascii_lowercase()
218 } else {
219 '-'
220 }
221 })
222 .collect();
223 let name = format!("{SERVICE_NAME}-selftest-{tag}");
224 Self {
225 display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
226 description: "Disposable fixture created by runner-manager's own installer tests. \
227 Safe to remove."
228 .to_string(),
229 name,
230 }
231 }
232
233 #[must_use]
238 pub fn is_fixture(&self) -> bool {
239 self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
240 }
241
242 #[must_use]
245 pub fn name(&self) -> &str {
246 &self.name
247 }
248
249 #[must_use]
251 pub fn display_name(&self) -> &str {
252 &self.display_name
253 }
254
255 #[must_use]
257 pub fn description(&self) -> &str {
258 &self.description
259 }
260
261 #[must_use]
267 pub fn launchd_label(&self) -> String {
268 format!(
269 "{}.{}.{}",
270 crate::paths::QUALIFIER,
271 crate::paths::ORGANIZATION,
272 self.name
273 )
274 }
275
276 #[must_use]
278 pub fn systemd_unit(&self) -> String {
279 format!("{}.service", self.name)
280 }
281}
282
283impl fmt::Display for ServiceIdentity {
284 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285 f.write_str(&self.name)
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub struct RestartPolicy {
304 delay: Duration,
305 reset_after: Duration,
306}
307
308impl RestartPolicy {
309 pub const MIN_DELAY: Duration = Duration::from_secs(1);
316
317 pub const MAX_DELAY: Duration = Duration::from_secs(300);
320
321 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
323
324 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
326
327 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
335 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
336 return Err(ServiceError::RestartDelay {
337 requested_secs: delay.as_secs(),
338 min_secs: Self::MIN_DELAY.as_secs(),
339 max_secs: Self::MAX_DELAY.as_secs(),
340 });
341 }
342 if reset_after <= delay {
343 return Err(ServiceError::RestartResetWindow {
344 reset_secs: reset_after.as_secs(),
345 delay_secs: delay.as_secs(),
346 });
347 }
348 Ok(Self { delay, reset_after })
349 }
350
351 #[must_use]
353 pub const fn delay(&self) -> Duration {
354 self.delay
355 }
356
357 #[must_use]
359 pub const fn reset_after(&self) -> Duration {
360 self.reset_after
361 }
362
363 #[must_use]
380 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
381 match kind {
382 DefinitionKind::WindowsScheduledTask => {
383 let seconds = self.delay.as_secs();
384 let minutes = seconds.div_ceil(60);
385 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
386 }
387 _ => self.delay,
388 }
389 }
390}
391
392impl Default for RestartPolicy {
393 fn default() -> Self {
394 Self {
395 delay: Self::DEFAULT_DELAY,
396 reset_after: Self::DEFAULT_RESET_AFTER,
397 }
398 }
399}
400
401impl fmt::Display for RestartPolicy {
402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403 write!(
404 f,
405 "restart after {}s, failure count resets after {}s",
406 self.delay.as_secs(),
407 self.reset_after.as_secs()
408 )
409 }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "snake_case")]
424pub enum ServiceAccount {
425 LocalSystem,
428 Root,
431 InvokingUser,
433}
434
435impl ServiceAccount {
436 #[must_use]
445 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
446 match (kind, mode) {
447 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
448 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
449 (_, StartMode::Boot) => Self::Root,
450 }
451 }
452
453 #[must_use]
456 pub const fn for_start_mode(mode: StartMode) -> Self {
457 Self::for_definition(host_definition_kind(mode), mode)
458 }
459
460 #[must_use]
462 pub const fn as_str(&self) -> &'static str {
463 match self {
464 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
465 Self::Root => "root",
466 Self::InvokingUser => "the invoking user",
467 }
468 }
469
470 #[must_use]
477 pub const fn justification(&self) -> &'static str {
478 match self {
479 Self::LocalSystem => {
480 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
481 NetworkService cannot read it, and widening the DACL to reach them would grant \
482 every service on this host read access to the one credential this product holds"
483 }
484 Self::Root => {
485 "a boot-time registration runs outside every login session: on macOS the System \
486 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
487 machine-scoped store is a 0600 file under /var/lib that only root can open \
488 before a session exists"
489 }
490 Self::InvokingUser => {
491 "a login-mode registration reads the user-scoped store, which is deliberately \
492 readable by exactly one account and needs no elevation at all"
493 }
494 }
495 }
496
497 #[must_use]
499 pub const fn needs_elevation(&self) -> bool {
500 matches!(self, Self::LocalSystem | Self::Root)
501 }
502}
503
504impl fmt::Display for ServiceAccount {
505 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506 f.write_str(self.as_str())
507 }
508}
509
510#[derive(Debug, thiserror::Error)]
521pub enum ServiceError {
522 #[error("cannot install the service while an agent is already running on this host: {source}")]
528 LockHeld {
529 #[source]
531 source: Box<LockError>,
532 },
533
534 #[error(
537 "cannot tell whether an agent is already running on this host, so the service was not \
538 installed: {source}. Fix the reported problem with the state directory and try again."
539 )]
540 LockUnreadable {
541 #[source]
543 source: Box<LockError>,
544 },
545
546 #[error(
554 "this registration would run jobs under the default runner root, and that root could \
555 not be prepared, so nothing was registered: {source}"
556 )]
557 RunnerRoot {
558 #[source]
560 source: Box<RootAccessError>,
561 },
562
563 #[error(
565 "cannot resolve the absolute path of this executable, so there is nothing to register: \
566 {detail}. Run the installer from the installed binary rather than through a shell \
567 function or a wrapper that replaces argv[0]."
568 )]
569 BinaryPath {
570 detail: String,
572 },
573
574 #[error(
576 "{} is not a file, so registering it would create a service that cannot start. Install \
577 the product first, then run `service install` from the installed binary.",
578 path.display()
579 )]
580 BinaryMissing {
581 path: PathBuf,
583 },
584
585 #[error(
587 "a restart delay of {requested_secs}s is outside the supported range \
588 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
589 value, so `service status` would report a delay that is not the one in force."
590 )]
591 RestartDelay {
592 requested_secs: u64,
594 min_secs: u64,
596 max_secs: u64,
598 },
599
600 #[error(
602 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
603 restart delay, so the count would reset between every pair of restarts and no \
604 start limit could ever apply."
605 )]
606 RestartResetWindow {
607 reset_secs: u64,
609 delay_secs: u64,
611 },
612
613 #[error(
619 "{name} is already registered to start at {existing}, and this asks for {requested}. \
620 Changing the start mode moves the registration between two different service \
621 managers, so it is not something an install does by itself: run `service uninstall` \
622 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
623 switches it in place and keeps the registration running throughout."
624 )]
625 AlreadyInstalled {
626 name: String,
628 existing: StartMode,
630 requested: StartMode,
632 },
633
634 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
636 NotInstalled {
637 name: String,
639 operation: &'static str,
641 },
642
643 #[error("cannot {operation} the service record {}: {detail}", path.display())]
645 Record {
646 operation: &'static str,
648 path: PathBuf,
650 detail: String,
652 },
653
654 #[error(
656 "the service record {} was not written by this product, or was written by a version \
657 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
658 neither touches configuration, secrets, or the cache.",
659 path.display()
660 )]
661 RecordUnreadable {
662 path: PathBuf,
664 detail: String,
666 },
667
668 #[error(
682 "the service record {} is there and this account may not read it: {detail}. It was \
683 written by whichever account installed the service -- on a boot-mode host, `sudo \
684 service install`. Running `service install` again rewrites it readable; until then \
685 only what the service manager itself reports is available.",
686 path.display()
687 )]
688 RecordNotPermitted {
689 path: PathBuf,
691 detail: String,
693 },
694
695 #[error("cannot prepare this host's application-data directories: {source}")]
697 Paths {
698 #[source]
700 source: Box<crate::paths::PathsError>,
701 },
702
703 #[error("cannot {operation} {name} through {manager}: {detail}")]
705 Control {
706 operation: &'static str,
708 name: String,
710 manager: &'static str,
712 detail: String,
714 },
715
716 #[error(
718 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
719 Inspect `service status` before retrying."
720 )]
721 Rollback {
722 operation: &'static str,
724 name: String,
726 cause: String,
728 rollback: String,
730 },
731
732 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
734 NeedsElevation {
735 operation: &'static str,
737 name: String,
739 detail: String,
741 remedy: &'static str,
743 },
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
758pub struct ServiceDirectories {
759 pub config: PathBuf,
761 pub state: PathBuf,
763 pub runtime: PathBuf,
765 pub logs: PathBuf,
767}
768
769impl ServiceDirectories {
770 #[must_use]
772 pub fn of(paths: &AppPaths) -> Self {
773 Self {
774 config: paths.config_dir().to_path_buf(),
775 state: paths.state_dir().to_path_buf(),
776 runtime: paths.runtime_dir().to_path_buf(),
777 logs: paths.logs_dir().to_path_buf(),
778 }
779 }
780
781 #[must_use]
783 pub fn all(&self) -> [&Path; 4] {
784 [
785 self.config.as_path(),
786 self.state.as_path(),
787 self.runtime.as_path(),
788 self.logs.as_path(),
789 ]
790 }
791
792 #[must_use]
795 pub fn log_file(&self) -> PathBuf {
796 self.logs.join(LOG_FILE_STEM)
797 }
798}
799
800#[derive(Debug, Clone, PartialEq, Eq)]
812pub struct InstallRequest {
813 start_mode: StartMode,
814 binary: Option<PathBuf>,
815 source_binary: Option<PathBuf>,
816 arguments: Vec<OsString>,
817 restart: RestartPolicy,
818 on_demand: bool,
819}
820
821impl InstallRequest {
822 #[must_use]
824 pub fn new(start_mode: StartMode) -> Self {
825 Self {
826 start_mode,
827 binary: None,
828 source_binary: None,
829 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
830 restart: RestartPolicy::default(),
831 on_demand: false,
832 }
833 }
834
835 #[must_use]
845 pub const fn started_on_demand(mut self) -> Self {
846 self.on_demand = true;
847 self
848 }
849
850 #[must_use]
857 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
858 self.binary = Some(binary.into());
859 self
860 }
861
862 #[must_use]
879 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
880 self.source_binary = Some(source.into());
881 self
882 }
883
884 #[must_use]
886 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
887 where
888 I: IntoIterator<Item = S>,
889 S: Into<OsString>,
890 {
891 self.arguments = arguments.into_iter().map(Into::into).collect();
892 self
893 }
894
895 #[must_use]
898 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
899 self.restart = restart;
900 self
901 }
902
903 #[must_use]
905 pub const fn start_mode(&self) -> StartMode {
906 self.start_mode
907 }
908}
909
910#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct InstallPlan {
918 identity: ServiceIdentity,
919 start_mode: StartMode,
920 binary: PathBuf,
921 source_binary: Option<PathBuf>,
922 arguments: Vec<OsString>,
923 account: ServiceAccount,
924 restart: RestartPolicy,
925 directories: ServiceDirectories,
926 secret_guard: Option<PathBuf>,
927 on_demand: bool,
928}
929
930impl InstallPlan {
931 pub fn resolve(
939 identity: ServiceIdentity,
940 request: &InstallRequest,
941 directories: ServiceDirectories,
942 ) -> Result<Self, ServiceError> {
943 let binary = match &request.binary {
944 Some(named) => absolute(named)?,
945 None => running_executable()?,
946 };
947 if !binary.is_file() {
948 return Err(ServiceError::BinaryMissing { path: binary });
949 }
950 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
955 .ok()
956 .map(|store| store.guard());
957 Ok(Self {
958 identity,
959 start_mode: request.start_mode,
960 binary,
961 source_binary: request.source_binary.clone(),
962 arguments: request.arguments.clone(),
963 account: ServiceAccount::for_start_mode(request.start_mode),
964 restart: request.restart,
965 directories,
966 secret_guard,
967 on_demand: request.on_demand,
968 })
969 }
970
971 #[must_use]
978 pub fn unchecked(
979 identity: ServiceIdentity,
980 start_mode: StartMode,
981 binary: impl Into<PathBuf>,
982 directories: ServiceDirectories,
983 ) -> Self {
984 Self {
985 identity,
986 start_mode,
987 binary: binary.into(),
988 source_binary: None,
989 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
990 account: ServiceAccount::for_start_mode(start_mode),
991 restart: RestartPolicy::default(),
992 directories,
993 secret_guard: None,
994 on_demand: false,
995 }
996 }
997
998 #[must_use]
1001 pub const fn started_on_demand(mut self) -> Self {
1002 self.on_demand = true;
1003 self
1004 }
1005
1006 #[must_use]
1008 pub const fn is_on_demand(&self) -> bool {
1009 self.on_demand
1010 }
1011
1012 #[must_use]
1019 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1020 self.secret_guard = Some(guard.into());
1021 self
1022 }
1023
1024 #[must_use]
1026 pub fn secret_guard(&self) -> Option<&Path> {
1027 self.secret_guard.as_deref()
1028 }
1029
1030 #[must_use]
1032 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1033 self.restart = restart;
1034 self
1035 }
1036
1037 #[must_use]
1039 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1040 where
1041 I: IntoIterator<Item = S>,
1042 S: Into<OsString>,
1043 {
1044 self.arguments = arguments.into_iter().map(Into::into).collect();
1045 self
1046 }
1047
1048 #[must_use]
1050 pub const fn identity(&self) -> &ServiceIdentity {
1051 &self.identity
1052 }
1053
1054 #[must_use]
1056 pub const fn start_mode(&self) -> StartMode {
1057 self.start_mode
1058 }
1059
1060 #[must_use]
1062 pub fn binary(&self) -> &Path {
1063 &self.binary
1064 }
1065
1066 #[must_use]
1072 pub fn source_binary(&self) -> Option<&Path> {
1073 self.source_binary.as_deref()
1074 }
1075
1076 #[must_use]
1078 pub fn arguments(&self) -> &[OsString] {
1079 &self.arguments
1080 }
1081
1082 #[must_use]
1084 pub const fn account(&self) -> &ServiceAccount {
1085 &self.account
1086 }
1087
1088 #[must_use]
1090 pub const fn restart(&self) -> RestartPolicy {
1091 self.restart
1092 }
1093
1094 #[must_use]
1096 pub const fn directories(&self) -> &ServiceDirectories {
1097 &self.directories
1098 }
1099
1100 #[must_use]
1102 pub fn command_line(&self) -> String {
1103 let mut out = quote_argument(&self.binary.to_string_lossy());
1104 for argument in &self.arguments {
1105 out.push(' ');
1106 out.push_str("e_argument(&argument.to_string_lossy()));
1107 }
1108 out
1109 }
1110}
1111
1112fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1120 let reversal = change.revert();
1121 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1122}
1123
1124fn rolled_back<T>(
1137 retained: Option<String>,
1138 rollback: Result<T, ServiceError>,
1139 operation: &'static str,
1140 identity: &ServiceIdentity,
1141 cause: ServiceError,
1142) -> ServiceError {
1143 let left_behind = match (rollback.err(), retained) {
1144 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1145 (Some(rollback), None) => Some(rollback.to_string()),
1146 (None, retained) => retained,
1147 };
1148 match left_behind {
1149 Some(rollback) => ServiceError::Rollback {
1150 operation,
1151 name: identity.name().to_string(),
1152 cause: cause.to_string(),
1153 rollback,
1154 },
1155 None => cause,
1156 }
1157}
1158
1159fn undo_runner_root(
1162 change: &RootAccessChange,
1163 operation: &'static str,
1164 identity: &ServiceIdentity,
1165 cause: ServiceError,
1166) -> ServiceError {
1167 rolled_back(
1168 retained_runner_root(change),
1169 Ok(()),
1170 operation,
1171 identity,
1172 cause,
1173 )
1174}
1175
1176fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1190 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1191 detail: format!("{} could not be made absolute: {error}", path.display()),
1192 })
1193}
1194
1195fn running_executable() -> Result<PathBuf, ServiceError> {
1197 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1198 detail: error.to_string(),
1199 })?;
1200 absolute(&raw)
1201}
1202
1203pub(crate) fn quote_argument(argument: &str) -> String {
1212 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1213 return argument.to_string();
1214 }
1215 let mut out = String::with_capacity(argument.len() + 2);
1216 out.push('"');
1217 let mut backslashes = 0usize;
1218 for c in argument.chars() {
1219 match c {
1220 '\\' => {
1221 backslashes += 1;
1222 out.push('\\');
1223 }
1224 '"' => {
1225 for _ in 0..=backslashes {
1228 out.push('\\');
1229 }
1230 out.push('"');
1231 backslashes = 0;
1232 }
1233 other => {
1234 backslashes = 0;
1235 out.push(other);
1236 }
1237 }
1238 }
1239 for _ in 0..backslashes {
1241 out.push('\\');
1242 }
1243 out.push('"');
1244 out
1245}
1246
1247#[must_use]
1258pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1259 let trimmed = command_line.trim_start();
1260 if trimmed.is_empty() {
1261 return None;
1262 }
1263 let mut out = String::new();
1264 let mut chars = trimmed.chars().peekable();
1265 let quoted = chars.peek() == Some(&'"');
1266 if quoted {
1267 chars.next();
1268 let mut backslashes = 0usize;
1269 for c in chars {
1270 match c {
1271 '\\' => {
1272 backslashes += 1;
1273 }
1274 '"' => {
1275 out.extend(std::iter::repeat_n('\\', backslashes / 2));
1278 if backslashes.is_multiple_of(2) {
1279 break;
1280 }
1281 backslashes = 0;
1282 out.push('"');
1283 }
1284 other => {
1285 out.extend(std::iter::repeat_n('\\', backslashes));
1286 backslashes = 0;
1287 out.push(other);
1288 }
1289 }
1290 }
1291 } else {
1292 for c in chars {
1293 if c == ' ' || c == '\t' {
1294 break;
1295 }
1296 out.push(c);
1297 }
1298 }
1299 if out.is_empty() {
1300 None
1301 } else {
1302 Some(PathBuf::from(out))
1303 }
1304}
1305
1306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1312pub enum DefinitionKind {
1313 WindowsService,
1317 WindowsScheduledTask,
1319 LaunchdPlist,
1321 SystemdUnit,
1323}
1324
1325impl DefinitionKind {
1326 #[must_use]
1328 pub const fn manager(self) -> &'static str {
1329 match self {
1330 Self::WindowsService => "the Windows Service Control Manager",
1331 Self::WindowsScheduledTask => "Windows Task Scheduler",
1332 Self::LaunchdPlist => "launchd",
1333 Self::SystemdUnit => "systemd",
1334 }
1335 }
1336}
1337
1338impl fmt::Display for DefinitionKind {
1339 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1340 f.write_str(self.manager())
1341 }
1342}
1343
1344#[derive(Debug, Clone, PartialEq, Eq)]
1351pub struct ServiceDefinition {
1352 kind: DefinitionKind,
1353 text: String,
1354 install_path: Option<PathBuf>,
1355}
1356
1357impl ServiceDefinition {
1358 #[must_use]
1360 pub const fn kind(&self) -> DefinitionKind {
1361 self.kind
1362 }
1363
1364 #[must_use]
1366 pub fn text(&self) -> &str {
1367 &self.text
1368 }
1369
1370 #[must_use]
1377 pub fn install_path(&self) -> Option<&Path> {
1378 self.install_path.as_deref()
1379 }
1380
1381 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1394 Ok(match host_definition_kind(plan.start_mode()) {
1395 DefinitionKind::WindowsService => Self::windows_service(plan),
1396 DefinitionKind::WindowsScheduledTask => {
1397 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1398 }
1399 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1400 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1401 })
1402 }
1403
1404 #[must_use]
1406 pub fn windows_service(plan: &InstallPlan) -> Self {
1407 Self {
1408 kind: DefinitionKind::WindowsService,
1409 text: windows_service_descriptor(plan),
1410 install_path: None,
1411 }
1412 }
1413
1414 #[must_use]
1416 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1417 Self {
1418 kind: DefinitionKind::WindowsScheduledTask,
1419 text: windows_scheduled_task_xml(plan, principal),
1420 install_path: None,
1421 }
1422 }
1423
1424 #[must_use]
1429 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1430 let file = format!("{}.plist", plan.identity().launchd_label());
1431 let install_path = match plan.start_mode() {
1432 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1433 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1434 };
1435 Self {
1436 kind: DefinitionKind::LaunchdPlist,
1437 text: launchd_plist(plan),
1438 install_path,
1439 }
1440 }
1441
1442 #[must_use]
1450 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1451 Self {
1452 kind,
1453 text: text.into(),
1454 install_path: None,
1455 }
1456 }
1457
1458 #[must_use]
1463 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1464 let file = plan.identity().systemd_unit();
1465 let install_path = match plan.start_mode() {
1466 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1467 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1468 };
1469 Self {
1470 kind: DefinitionKind::SystemdUnit,
1471 text: systemd_unit(plan),
1472 install_path,
1473 }
1474 }
1475}
1476
1477pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1479pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1481pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1483pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1485
1486const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1489
1490pub const START_LIMIT_BURST: u32 = 5;
1498
1499#[must_use]
1520pub fn systemd_unit(plan: &InstallPlan) -> String {
1521 let identity = plan.identity();
1522 let restart = plan.restart();
1523 let directories = plan.directories();
1524
1525 let mut out = String::new();
1526 out.push_str("[Unit]\n");
1527 out.push_str(&format!("Description={}\n", identity.display_name()));
1528 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1529 out.push_str("After=network-online.target\n");
1530 out.push_str("Wants=network-online.target\n");
1531 out.push_str(&format!(
1534 "StartLimitIntervalSec={}\n",
1535 restart.reset_after().as_secs()
1536 ));
1537 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1538
1539 out.push_str("\n[Service]\n");
1540 out.push_str("Type=simple\n");
1541 out.push_str("KillMode=process\n");
1545 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1546 out.push_str(&format!(
1547 "WorkingDirectory={}\n",
1548 directories.state.display()
1549 ));
1550 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1551 out.push_str("Restart=on-failure\n");
1552 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1553
1554 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1555 for directive in SYSTEMD_HARDENING {
1556 out.push_str(directive);
1557 out.push('\n');
1558 }
1559 let mut writable = directories.all().to_vec();
1560 if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1561 writable.push(secret_directory);
1562 }
1563 out.push_str(&format!(
1564 "ReadWritePaths={}\n",
1565 writable
1566 .iter()
1567 .map(|path| quote_argument(&path.to_string_lossy()))
1568 .collect::<Vec<_>>()
1569 .join(" ")
1570 ));
1571
1572 out.push_str("\n[Install]\n");
1573 out.push_str(match plan.start_mode() {
1574 StartMode::Boot => "WantedBy=multi-user.target\n",
1575 StartMode::Login => "WantedBy=default.target\n",
1576 });
1577 out
1578}
1579
1580pub const SYSTEMD_HARDENING: [&str; 13] = [
1587 "NoNewPrivileges=yes",
1588 "CapabilityBoundingSet=",
1589 "AmbientCapabilities=",
1590 "PrivateTmp=yes",
1591 "PrivateDevices=yes",
1592 "ProtectSystem=strict",
1593 "ProtectKernelTunables=yes",
1594 "ProtectKernelModules=yes",
1595 "ProtectControlGroups=yes",
1596 "RestrictNamespaces=yes",
1597 "RestrictRealtime=yes",
1598 "RestrictSUIDSGID=yes",
1599 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1600];
1601
1602pub const LAUNCHD_PROCESS_TYPE: &str = "Interactive";
1626
1627#[must_use]
1634pub fn launchd_plist(plan: &InstallPlan) -> String {
1635 let identity = plan.identity();
1636 let directories = plan.directories();
1637 let mut out = String::new();
1638 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1639 out.push_str(
1640 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1641 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1642 );
1643 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1644 out.push_str(&plist_string("Label", &identity.launchd_label()));
1645
1646 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1647 out.push_str(&format!(
1648 " <string>{}</string>\n",
1649 xml_escape(&plan.binary().to_string_lossy())
1650 ));
1651 for argument in plan.arguments() {
1652 out.push_str(&format!(
1653 " <string>{}</string>\n",
1654 xml_escape(&argument.to_string_lossy())
1655 ));
1656 }
1657 out.push_str(" </array>\n");
1658
1659 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1660 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1661 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1662 out.push_str(" </dict>\n");
1663 out.push_str(&format!(
1664 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1665 plan.restart().delay().as_secs()
1666 ));
1667 out.push_str(&plist_string("ProcessType", LAUNCHD_PROCESS_TYPE));
1668 out.push_str(&plist_string(
1669 "WorkingDirectory",
1670 &directories.state.to_string_lossy(),
1671 ));
1672 out.push_str(&plist_string(
1673 "StandardOutPath",
1674 &directories
1675 .logs
1676 .join("runner-manager.launchd.out.log")
1677 .to_string_lossy(),
1678 ));
1679 out.push_str(&plist_string(
1680 "StandardErrorPath",
1681 &directories
1682 .logs
1683 .join("runner-manager.launchd.err.log")
1684 .to_string_lossy(),
1685 ));
1686
1687 match plan.start_mode() {
1688 StartMode::Boot => {
1689 out.push_str(&plist_string(
1692 "UserName",
1693 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1694 .as_str(),
1695 ));
1696 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1698 }
1699 StartMode::Login => {
1700 }
1704 }
1705
1706 out.push_str("</dict>\n</plist>\n");
1707 out
1708}
1709
1710fn plist_string(key: &str, value: &str) -> String {
1712 format!(
1713 " <key>{}</key>\n <string>{}</string>\n",
1714 xml_escape(key),
1715 xml_escape(value)
1716 )
1717}
1718
1719#[derive(Debug, Clone, PartialEq, Eq)]
1727pub struct TaskPrincipal {
1728 user_id: String,
1729}
1730
1731impl TaskPrincipal {
1732 pub fn current() -> Result<Self, ServiceError> {
1740 let user = std::env::var("USERNAME")
1741 .ok()
1742 .filter(|value| !value.trim().is_empty());
1743 let Some(user) = user else {
1744 return Err(ServiceError::Control {
1745 operation: "identify the account for",
1746 name: SERVICE_NAME.to_string(),
1747 manager: "Windows Task Scheduler",
1748 detail: "this session reports no %USERNAME%, so there is no principal to \
1749 register a logon-triggered task for"
1750 .to_string(),
1751 });
1752 };
1753 let domain = std::env::var("USERDOMAIN")
1754 .ok()
1755 .filter(|value| !value.trim().is_empty());
1756 Ok(Self {
1757 user_id: match domain {
1758 Some(domain) => format!("{domain}\\{user}"),
1759 None => user,
1760 },
1761 })
1762 }
1763
1764 #[must_use]
1766 pub fn named(user_id: impl Into<String>) -> Self {
1767 Self {
1768 user_id: user_id.into(),
1769 }
1770 }
1771
1772 #[must_use]
1774 pub fn user_id(&self) -> &str {
1775 &self.user_id
1776 }
1777}
1778
1779#[must_use]
1786pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1787 let identity = plan.identity();
1788 let user = xml_escape(principal.user_id());
1789 let arguments = plan
1790 .arguments()
1791 .iter()
1792 .map(|argument| quote_argument(&argument.to_string_lossy()))
1793 .collect::<Vec<_>>()
1794 .join(" ");
1795 let mut out = String::new();
1796 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1797 out.push_str(
1798 "<Task version=\"1.4\" \
1799 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1800 );
1801 out.push_str(" <RegistrationInfo>\n");
1802 out.push_str(&format!(
1803 " <Description>{}</Description>\n",
1804 xml_escape(identity.description())
1805 ));
1806 out.push_str(&format!(
1807 " <URI>\\{}</URI>\n",
1808 xml_escape(identity.name())
1809 ));
1810 out.push_str(" </RegistrationInfo>\n");
1811
1812 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1813 out.push_str(" <Enabled>true</Enabled>\n");
1814 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1815 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1816
1817 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1818 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1819 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1820 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1821 out.push_str(" </Principal>\n </Principals>\n");
1822
1823 out.push_str(" <Settings>\n");
1824 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1828 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1829 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1830 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1831 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1832 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1833 out.push_str(" <IdleSettings>\n");
1834 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1835 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1836 out.push_str(" </IdleSettings>\n");
1837 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1838 out.push_str(" <Enabled>true</Enabled>\n");
1839 out.push_str(" <Hidden>false</Hidden>\n");
1840 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1841 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1842 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1844 out.push_str(" <Priority>7</Priority>\n");
1845 out.push_str(" <RestartOnFailure>\n");
1846 out.push_str(&format!(
1847 " <Interval>{}</Interval>\n",
1848 iso8601_minutes(
1849 plan.restart()
1850 .effective_delay(DefinitionKind::WindowsScheduledTask)
1851 )
1852 ));
1853 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1854 out.push_str(" </RestartOnFailure>\n");
1855 out.push_str(" </Settings>\n");
1856
1857 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1858 out.push_str(&format!(
1859 " <Command>{}</Command>\n",
1860 xml_escape(&plan.binary().to_string_lossy())
1861 ));
1862 if !arguments.is_empty() {
1863 out.push_str(&format!(
1864 " <Arguments>{}</Arguments>\n",
1865 xml_escape(&arguments)
1866 ));
1867 }
1868 out.push_str(&format!(
1869 " <WorkingDirectory>{}</WorkingDirectory>\n",
1870 xml_escape(&plan.directories().state.to_string_lossy())
1871 ));
1872 out.push_str(" </Exec>\n </Actions>\n");
1873 out.push_str("</Task>\n");
1874 out
1875}
1876
1877fn iso8601_minutes(duration: Duration) -> String {
1880 format!("PT{}M", duration.as_secs() / 60)
1881}
1882
1883pub(crate) fn xml_escape(value: &str) -> String {
1891 let mut out = String::with_capacity(value.len());
1892 for c in value.chars() {
1893 match c {
1894 '&' => out.push_str("&"),
1895 '<' => out.push_str("<"),
1896 '>' => out.push_str(">"),
1897 '"' => out.push_str("""),
1898 '\'' => out.push_str("'"),
1899 other => out.push(other),
1900 }
1901 }
1902 out
1903}
1904
1905fn xml_unescape(value: &str) -> String {
1911 value
1912 .replace("<", "<")
1913 .replace(">", ">")
1914 .replace(""", "\"")
1915 .replace("'", "'")
1916 .replace("&", "&")
1917}
1918
1919#[derive(Debug, Clone, PartialEq, Eq)]
1928pub struct WindowsServiceSpec {
1929 pub name: String,
1931 pub display_name: String,
1933 pub description: String,
1935 pub automatic_start: bool,
1937 pub account: Option<String>,
1940 pub command_line: String,
1942 pub restart: RestartPolicy,
1944}
1945
1946#[must_use]
1948pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1949 WindowsServiceSpec {
1950 name: plan.identity().name().to_string(),
1951 display_name: plan.identity().display_name().to_string(),
1952 description: plan.identity().description().to_string(),
1953 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1958 account: match ServiceAccount::for_definition(
1959 DefinitionKind::WindowsService,
1960 plan.start_mode(),
1961 ) {
1962 ServiceAccount::LocalSystem => None,
1965 other => Some(other.as_str().to_string()),
1966 },
1967 command_line: plan.command_line(),
1968 restart: plan.restart(),
1969 }
1970}
1971
1972#[must_use]
1975fn windows_service_descriptor(plan: &InstallPlan) -> String {
1976 let spec = windows_service_spec(plan);
1977 let mut out = String::new();
1978 out.push_str("[windows-service]\n");
1979 out.push_str(&format!("Name={}\n", spec.name));
1980 out.push_str(&format!("DisplayName={}\n", spec.display_name));
1981 out.push_str(&format!("Description={}\n", spec.description));
1982 out.push_str("ServiceType=OWN_PROCESS\n");
1986 out.push_str(&format!(
1987 "StartType={}\n",
1988 if spec.automatic_start {
1989 "AutoStart"
1990 } else {
1991 "OnDemand"
1992 }
1993 ));
1994 out.push_str("ErrorControl=Normal\n");
1995 out.push_str(&format!(
1996 "Account={}\n",
1997 spec.account
1998 .as_deref()
1999 .unwrap_or(ServiceAccount::LocalSystem.as_str())
2000 ));
2001 out.push_str(&format!("CommandLine={}\n", spec.command_line));
2002 out.push_str(&format!(
2003 "FailureActionRestartDelaySecs={}\n",
2004 spec.restart.delay().as_secs()
2005 ));
2006 out.push_str(&format!(
2007 "FailureActionsResetPeriodSecs={}\n",
2008 spec.restart.reset_after().as_secs()
2009 ));
2010 out.push_str("FailureActionsOnNonCrashFailures=true\n");
2014 out.push_str(&format!(
2015 "ReadWritePaths={}\n",
2016 plan.directories()
2017 .all()
2018 .iter()
2019 .map(|path| quote_argument(&path.to_string_lossy()))
2020 .collect::<Vec<_>>()
2021 .join(" ")
2022 ));
2023 out
2024}
2025
2026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2032pub enum FindingKind {
2033 Excess,
2036 Shortfall,
2039}
2040
2041impl fmt::Display for FindingKind {
2042 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2043 f.write_str(match self {
2044 Self::Excess => "excess",
2045 Self::Shortfall => "shortfall",
2046 })
2047 }
2048}
2049
2050#[derive(Debug, Clone, PartialEq, Eq)]
2052pub struct PrivilegeFinding {
2053 pub kind: FindingKind,
2055 pub subject: String,
2057 pub detail: String,
2059}
2060
2061impl fmt::Display for PrivilegeFinding {
2062 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2063 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2064 }
2065}
2066
2067#[derive(Debug, Clone, PartialEq, Eq)]
2081pub struct PrivilegeReview {
2082 kind: DefinitionKind,
2083 account: ServiceAccount,
2084 controls: Vec<String>,
2085 findings: Vec<PrivilegeFinding>,
2086}
2087
2088impl PrivilegeReview {
2089 #[must_use]
2091 pub fn is_least_privilege(&self) -> bool {
2092 !self
2093 .findings
2094 .iter()
2095 .any(|finding| finding.kind == FindingKind::Excess)
2096 }
2097
2098 #[must_use]
2100 pub fn findings(&self) -> &[PrivilegeFinding] {
2101 &self.findings
2102 }
2103
2104 #[must_use]
2107 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2108 self.findings
2109 .iter()
2110 .filter(|finding| finding.kind == FindingKind::Excess)
2111 .collect()
2112 }
2113
2114 #[must_use]
2120 pub fn controls(&self) -> &[String] {
2121 &self.controls
2122 }
2123
2124 #[must_use]
2126 pub const fn account(&self) -> &ServiceAccount {
2127 &self.account
2128 }
2129
2130 #[must_use]
2132 pub const fn kind(&self) -> DefinitionKind {
2133 self.kind
2134 }
2135}
2136
2137impl fmt::Display for PrivilegeReview {
2138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2139 writeln!(
2140 f,
2141 "{} runs as {} ({})",
2142 self.kind,
2143 self.account,
2144 self.account.justification()
2145 )?;
2146 for control in &self.controls {
2147 writeln!(f, " confirmed {control}")?;
2148 }
2149 for finding in &self.findings {
2150 writeln!(f, " {finding}")?;
2151 }
2152 if self.is_least_privilege() {
2153 write!(f, " verdict least privilege")
2154 } else {
2155 write!(
2156 f,
2157 " verdict NOT least privilege: {} excess(es)",
2158 self.excesses().len()
2159 )
2160 }
2161 }
2162}
2163
2164#[must_use]
2170pub fn review_least_privilege(
2171 definition: &ServiceDefinition,
2172 plan: &InstallPlan,
2173) -> PrivilegeReview {
2174 let mut controls = Vec::new();
2175 let mut findings = Vec::new();
2176 match definition.kind() {
2177 DefinitionKind::SystemdUnit => {
2178 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2179 }
2180 DefinitionKind::LaunchdPlist => {
2181 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2182 }
2183 DefinitionKind::WindowsScheduledTask => {
2184 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2185 }
2186 DefinitionKind::WindowsService => {
2187 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2188 }
2189 }
2190 PrivilegeReview {
2191 kind: definition.kind(),
2192 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2197 controls,
2198 findings,
2199 }
2200}
2201
2202fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2204 let mut permitted = plan
2205 .directories()
2206 .all()
2207 .iter()
2208 .map(|path| path.to_string_lossy().into_owned())
2209 .collect::<Vec<_>>();
2210 if kind == DefinitionKind::SystemdUnit
2211 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2212 {
2213 permitted.push(secret_directory.to_string_lossy().into_owned());
2214 }
2215 permitted
2216}
2217
2218fn same_path_text(left: &str, right: &str) -> bool {
2225 if cfg!(windows) {
2226 left.eq_ignore_ascii_case(right)
2227 } else {
2228 left == right
2229 }
2230}
2231
2232fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2235 match kind {
2236 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2237 left.eq_ignore_ascii_case(right)
2238 }
2239 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2240 }
2241}
2242
2243fn review_writable_paths(
2245 kind: DefinitionKind,
2246 subject: &str,
2247 listed: &[String],
2248 plan: &InstallPlan,
2249 controls: &mut Vec<String>,
2250 findings: &mut Vec<PrivilegeFinding>,
2251) {
2252 let permitted = permitted_paths(kind, plan);
2253 for entry in listed {
2254 if !permitted
2255 .iter()
2256 .any(|allowed| same_path_for(kind, allowed, entry))
2257 {
2258 findings.push(PrivilegeFinding {
2259 kind: FindingKind::Excess,
2260 subject: subject.to_string(),
2261 detail: format!(
2262 "{entry} is writable but is not one of this registration's required paths"
2263 ),
2264 });
2265 }
2266 }
2267 for allowed in &permitted {
2268 if !listed
2269 .iter()
2270 .any(|entry| same_path_for(kind, allowed, entry))
2271 {
2272 findings.push(PrivilegeFinding {
2273 kind: FindingKind::Shortfall,
2274 subject: subject.to_string(),
2275 detail: format!(
2276 "{allowed} is one of this registration's directories but is not writable, \
2277 so the daemon cannot use it"
2278 ),
2279 });
2280 }
2281 }
2282 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2283 controls.push(format!(
2284 "{subject} names exactly the required application-data and credential paths"
2285 ));
2286 }
2287}
2288
2289fn review_inbound_surface(
2297 text: &str,
2298 markers: &[(&str, &str)],
2299 controls: &mut Vec<String>,
2300 findings: &mut Vec<PrivilegeFinding>,
2301) {
2302 let mut clean = true;
2303 for (marker, detail) in markers {
2304 if text.contains(marker) {
2305 clean = false;
2306 findings.push(PrivilegeFinding {
2307 kind: FindingKind::Excess,
2308 subject: (*marker).to_string(),
2309 detail: (*detail).to_string(),
2310 });
2311 }
2312 }
2313 if clean {
2314 controls.push(
2315 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2316 );
2317 }
2318}
2319
2320fn review_systemd(
2321 text: &str,
2322 plan: &InstallPlan,
2323 controls: &mut Vec<String>,
2324 findings: &mut Vec<PrivilegeFinding>,
2325) {
2326 let directives = ini_directives(text, "Service");
2327 for expected in SYSTEMD_HARDENING {
2328 let (key, value) = expected
2329 .split_once('=')
2330 .expect("every hardening directive is written as key=value");
2331 match directives.get(key) {
2332 Some(actual) if actual == value => controls.push((*expected).to_string()),
2333 Some(actual) => findings.push(PrivilegeFinding {
2334 kind: FindingKind::Excess,
2335 subject: key.to_string(),
2336 detail: format!(
2337 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2338 requirement does not ask for"
2339 ),
2340 }),
2341 None => findings.push(PrivilegeFinding {
2342 kind: FindingKind::Excess,
2343 subject: key.to_string(),
2344 detail: format!(
2345 "is absent, so the unit inherits systemd's default rather than `{value}`"
2346 ),
2347 }),
2348 }
2349 }
2350
2351 match directives.get("ReadWritePaths") {
2352 Some(value) => {
2353 let listed = split_quoted(value);
2354 review_writable_paths(
2355 DefinitionKind::SystemdUnit,
2356 "ReadWritePaths",
2357 &listed,
2358 plan,
2359 controls,
2360 findings,
2361 );
2362 }
2363 None => findings.push(PrivilegeFinding {
2364 kind: FindingKind::Shortfall,
2365 subject: "ReadWritePaths".to_string(),
2366 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2367 .to_string(),
2368 }),
2369 }
2370
2371 if directives.contains_key("PrivateUsers")
2374 && directives.get("PrivateUsers") == Some(&"no".to_string())
2375 {
2376 findings.push(PrivilegeFinding {
2377 kind: FindingKind::Excess,
2378 subject: "PrivateUsers".to_string(),
2379 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2380 .to_string(),
2381 });
2382 }
2383
2384 review_inbound_surface(
2385 text,
2386 &[
2387 (
2388 "ListenStream=",
2389 "asks systemd to open a listening socket for this service, which \
2390 07-security.md rule 2 forbids the product to have",
2391 ),
2392 (
2393 "ListenDatagram=",
2394 "asks systemd to open a listening socket for this service, which \
2395 07-security.md rule 2 forbids the product to have",
2396 ),
2397 ],
2398 controls,
2399 findings,
2400 );
2401}
2402
2403fn review_launchd(
2404 text: &str,
2405 plan: &InstallPlan,
2406 controls: &mut Vec<String>,
2407 findings: &mut Vec<PrivilegeFinding>,
2408) {
2409 match plist_string_value(text, "ProcessType") {
2413 Some(value) if value == LAUNCHD_PROCESS_TYPE => {
2414 controls.push(format!("ProcessType={LAUNCHD_PROCESS_TYPE}"));
2415 }
2416 found => findings.push(PrivilegeFinding {
2417 kind: FindingKind::Shortfall,
2418 subject: "ProcessType".to_string(),
2419 detail: format!(
2420 "is {}, and launchd applies it to every runner the daemon starts, so their jobs \
2421 run below normal priority. The daemon rewrites it to `{LAUNCHD_PROCESS_TYPE}` \
2422 when it next starts with no runner; `service status` says how to restart it",
2423 found.map_or_else(
2424 || "absent (launchd's `Standard`)".to_string(),
2425 |value| format!("`{value}`")
2426 )
2427 ),
2428 }),
2429 }
2430
2431 match plan.start_mode() {
2432 StartMode::Boot => {
2433 if plist_bool_value(text, "SessionCreate") == Some(true) {
2434 findings.push(PrivilegeFinding {
2435 kind: FindingKind::Excess,
2436 subject: "SessionCreate".to_string(),
2437 detail: "asks launchd to create a security session for a job that runs \
2438 outside every login session and has no use for one"
2439 .to_string(),
2440 });
2441 } else {
2442 controls.push("SessionCreate is not requested".to_string());
2443 }
2444 match plist_string_value(text, "UserName").as_deref() {
2445 Some("root") => {
2446 controls.push("UserName=root, stated rather than inherited".to_string())
2447 }
2448 Some(other) => findings.push(PrivilegeFinding {
2449 kind: FindingKind::Shortfall,
2450 subject: "UserName".to_string(),
2451 detail: format!(
2452 "is `{other}`, which cannot unlock the System Keychain: \
2453 /var/db/SystemKey is root-only, so the daemon would start and then \
2454 find no credential"
2455 ),
2456 }),
2457 None => findings.push(PrivilegeFinding {
2458 kind: FindingKind::Shortfall,
2459 subject: "UserName".to_string(),
2460 detail: "is absent, so the account is launchd's implicit default and this \
2461 review cannot confirm it"
2462 .to_string(),
2463 }),
2464 }
2465 }
2466 StartMode::Login => {
2467 if let Some(named) = plist_string_value(text, "UserName") {
2468 findings.push(PrivilegeFinding {
2469 kind: FindingKind::Excess,
2470 subject: "UserName".to_string(),
2471 detail: format!(
2472 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2473 naming an account here asks launchd for a switch a login-mode \
2474 registration has no reason to want"
2475 ),
2476 });
2477 } else {
2478 controls
2479 .push("no UserName: the agent runs as the operator and no other".to_string());
2480 }
2481 }
2482 }
2483
2484 review_inbound_surface(
2485 text,
2486 &[
2487 (
2488 "<key>Sockets</key>",
2489 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2490 forbids the product to have",
2491 ),
2492 (
2493 "<key>MachServices</key>",
2494 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2495 forbids the product to have",
2496 ),
2497 ],
2498 controls,
2499 findings,
2500 );
2501}
2502
2503fn review_scheduled_task(
2504 text: &str,
2505 controls: &mut Vec<String>,
2506 findings: &mut Vec<PrivilegeFinding>,
2507) {
2508 match xml_value(text, "RunLevel").as_deref() {
2509 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2510 Some(other) => findings.push(PrivilegeFinding {
2511 kind: FindingKind::Excess,
2512 subject: "RunLevel".to_string(),
2513 detail: format!(
2514 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2515 an administrator"
2516 ),
2517 }),
2518 None => findings.push(PrivilegeFinding {
2519 kind: FindingKind::Excess,
2520 subject: "RunLevel".to_string(),
2521 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2522 .to_string(),
2523 }),
2524 }
2525
2526 match xml_value(text, "LogonType").as_deref() {
2527 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2528 Some(other) => findings.push(PrivilegeFinding {
2529 kind: FindingKind::Excess,
2530 subject: "LogonType".to_string(),
2531 detail: format!(
2532 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2533 task; an interactive token needs neither"
2534 ),
2535 }),
2536 None => findings.push(PrivilegeFinding {
2537 kind: FindingKind::Shortfall,
2538 subject: "LogonType".to_string(),
2539 detail: "is absent, so this review cannot confirm that no credential is stored"
2540 .to_string(),
2541 }),
2542 }
2543}
2544
2545fn review_windows_service(
2546 text: &str,
2547 plan: &InstallPlan,
2548 controls: &mut Vec<String>,
2549 findings: &mut Vec<PrivilegeFinding>,
2550) {
2551 let directives = ini_directives(text, "windows-service");
2552
2553 match directives.get("ServiceType").map(String::as_str) {
2554 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2555 Some(other) => findings.push(PrivilegeFinding {
2556 kind: FindingKind::Excess,
2557 subject: "ServiceType".to_string(),
2558 detail: format!(
2559 "is `{other}`; an interactive or shared-process service reaches further than a \
2560 daemon that only talks to GitHub over HTTPS"
2561 ),
2562 }),
2563 None => findings.push(PrivilegeFinding {
2564 kind: FindingKind::Shortfall,
2565 subject: "ServiceType".to_string(),
2566 detail: "is absent, so this review cannot confirm the service is not interactive"
2567 .to_string(),
2568 }),
2569 }
2570
2571 match directives.get("Account").map(String::as_str) {
2575 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2576 controls.push(format!(
2577 "Account={account}: the only stock account the machine-scoped store's DACL \
2578 (SY, BA, OW) admits"
2579 ));
2580 }
2581 Some(other) => findings.push(PrivilegeFinding {
2582 kind: FindingKind::Shortfall,
2583 subject: "Account".to_string(),
2584 detail: format!(
2585 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2586 daemon would start and then find no credential. Widening that DACL is not this \
2587 registration's to do: an ACE reaching `{other}` would also reach every other \
2588 service running under it"
2589 ),
2590 }),
2591 None => findings.push(PrivilegeFinding {
2592 kind: FindingKind::Shortfall,
2593 subject: "Account".to_string(),
2594 detail: "is absent, so this review cannot confirm which account was registered"
2595 .to_string(),
2596 }),
2597 }
2598
2599 match directives.get("ReadWritePaths") {
2600 Some(value) => {
2601 let listed = split_quoted(value);
2602 review_writable_paths(
2603 DefinitionKind::WindowsService,
2604 "ReadWritePaths",
2605 &listed,
2606 plan,
2607 controls,
2608 findings,
2609 );
2610 }
2611 None => findings.push(PrivilegeFinding {
2612 kind: FindingKind::Shortfall,
2613 subject: "ReadWritePaths".to_string(),
2614 detail: "is absent, so the directories the service was installed against are not \
2615 recorded"
2616 .to_string(),
2617 }),
2618 }
2619}
2620
2621fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2629 let mut out = BTreeMap::new();
2630 let mut inside = false;
2631 for line in text.lines() {
2632 let line = line.trim();
2633 if line.starts_with('[') && line.ends_with(']') {
2634 inside = &line[1..line.len() - 1] == section;
2635 continue;
2636 }
2637 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2638 continue;
2639 }
2640 if let Some((key, value)) = line.split_once('=') {
2641 out.insert(key.trim().to_string(), value.trim().to_string());
2642 }
2643 }
2644 out
2645}
2646
2647fn split_quoted(value: &str) -> Vec<String> {
2650 let mut out = Vec::new();
2651 let mut rest = value.trim();
2652 while !rest.is_empty() {
2653 if rest.starts_with('"') {
2654 if let Some(parsed) = executable_from_command_line(rest) {
2657 out.push(parsed.to_string_lossy().into_owned());
2658 }
2659 let mut depth = 0usize;
2661 let mut end = rest.len();
2662 for (index, c) in rest.char_indices() {
2663 match c {
2664 '\\' => depth += 1,
2665 '"' => {
2666 if depth.is_multiple_of(2) && index > 0 {
2667 end = index + 1;
2668 break;
2669 }
2670 depth = 0;
2671 }
2672 _ => depth = 0,
2673 }
2674 }
2675 rest = rest[end..].trim_start();
2676 } else {
2677 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2678 out.push(rest[..end].to_string());
2679 rest = rest[end..].trim_start();
2680 }
2681 }
2682 out
2683}
2684
2685pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2697 let open = format!("<{tag}>");
2698 let close = format!("</{tag}>");
2699 let start = text.find(&open)? + open.len();
2700 let end = text[start..].find(&close)? + start;
2701 Some(xml_unescape(text[start..end].trim()))
2702}
2703
2704#[cfg(any(windows, test))]
2710fn windows_login_task_starts_automatically(document: &str) -> bool {
2711 document.contains("<LogonTrigger>")
2712 && xml_value(document, "Enabled").as_deref() != Some("false")
2713}
2714
2715fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2717 let marker = format!("<key>{key}</key>");
2718 let start = text.find(&marker)? + marker.len();
2719 Some(text[start..].trim_start())
2720}
2721
2722fn plist_string_value(text: &str, key: &str) -> Option<String> {
2723 let rest = plist_value_after_key(text, key)?;
2724 if !rest.starts_with("<string>") {
2725 return None;
2726 }
2727 xml_value(rest, "string")
2728}
2729
2730fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2731 let rest = plist_value_after_key(text, key)?;
2732 if rest.starts_with("<true/>") {
2733 Some(true)
2734 } else if rest.starts_with("<false/>") {
2735 Some(false)
2736 } else {
2737 None
2738 }
2739}
2740
2741pub const RECORD_SCHEMA_VERSION: u32 = 1;
2752
2753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2765pub struct InstallRecord {
2766 pub schema_version: u32,
2768 pub service_name: String,
2770 pub manager: String,
2772 pub start_mode: StartMode,
2774 pub account: ServiceAccount,
2776 pub binary: PathBuf,
2778 #[serde(default)]
2786 pub source_binary: Option<PathBuf>,
2787 pub arguments: Vec<String>,
2789 pub restart_delay_secs: u64,
2791 pub restart_reset_secs: u64,
2793 pub log_file: PathBuf,
2795 #[serde(default)]
2803 pub starts_on_demand: bool,
2804 pub definition_path: Option<PathBuf>,
2806 pub installed_at: DateTime<Utc>,
2808 pub installed_by_version: String,
2810 pub directories: ServiceDirectories,
2815}
2816
2817impl InstallRecord {
2818 #[must_use]
2820 pub fn path(paths: &AppPaths) -> PathBuf {
2821 paths.config_dir().join(RECORD_FILE)
2822 }
2823
2824 #[must_use]
2826 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2827 Self {
2828 schema_version: RECORD_SCHEMA_VERSION,
2829 service_name: plan.identity().name().to_string(),
2830 manager: definition.kind().manager().to_string(),
2831 start_mode: plan.start_mode(),
2832 account: plan.account().clone(),
2833 binary: plan.binary().to_path_buf(),
2834 arguments: plan
2835 .arguments()
2836 .iter()
2837 .map(|argument| argument.to_string_lossy().into_owned())
2838 .collect(),
2839 restart_delay_secs: plan.restart().delay().as_secs(),
2840 restart_reset_secs: plan.restart().reset_after().as_secs(),
2841 starts_on_demand: plan.is_on_demand(),
2842 source_binary: plan.source_binary().map(Path::to_path_buf),
2843 log_file: plan.directories().log_file(),
2844 definition_path: definition.install_path().map(Path::to_path_buf),
2845 installed_at: at,
2846 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2847 directories: plan.directories().clone(),
2848 }
2849 }
2850
2851 #[must_use]
2859 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2860 let plan = InstallPlan::unchecked(
2861 identity,
2862 self.start_mode,
2863 self.binary.clone(),
2864 self.directories.clone(),
2865 )
2866 .with_arguments(self.arguments.clone())
2867 .with_restart(self.restart());
2868 let plan = if self.starts_on_demand {
2869 plan.started_on_demand()
2870 } else {
2871 plan
2872 };
2873 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2874 Ok(store) => plan.with_secret_guard(store.guard()),
2875 Err(_) => plan,
2876 }
2877 }
2878
2879 #[must_use]
2883 pub fn restart(&self) -> RestartPolicy {
2884 RestartPolicy::new(
2885 Duration::from_secs(self.restart_delay_secs),
2886 Duration::from_secs(self.restart_reset_secs),
2887 )
2888 .unwrap_or_default()
2889 }
2890
2891 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2902 let path = Self::path(paths);
2903 let text = match std::fs::read_to_string(&path) {
2904 Ok(text) => text,
2905 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2906 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2909 return Err(ServiceError::RecordNotPermitted {
2910 path,
2911 detail: error.to_string(),
2912 });
2913 }
2914 Err(error) => {
2915 return Err(ServiceError::Record {
2916 operation: "read",
2917 path,
2918 detail: error.to_string(),
2919 });
2920 }
2921 };
2922 let record: Self =
2923 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2924 path: path.clone(),
2925 detail: error.to_string(),
2926 })?;
2927 if record.schema_version != RECORD_SCHEMA_VERSION {
2928 return Err(ServiceError::RecordUnreadable {
2929 path,
2930 detail: format!(
2931 "it declares schema version {} and this build reads version {}",
2932 record.schema_version, RECORD_SCHEMA_VERSION
2933 ),
2934 });
2935 }
2936 Ok(Some(record))
2937 }
2938
2939 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2945 use std::io::Write as _;
2946
2947 let path = Self::path(paths);
2948 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2949 operation: "encode",
2950 path: path.clone(),
2951 detail: error.to_string(),
2952 })?;
2953 if let Some(parent) = path.parent() {
2954 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2955 operation: "write",
2956 path: path.clone(),
2957 detail: error.to_string(),
2958 })?;
2959 }
2960 let parent = path.parent().ok_or_else(|| ServiceError::Record {
2961 operation: "write",
2962 path: path.clone(),
2963 detail: "the record path has no parent directory".to_string(),
2964 })?;
2965 let mut temporary =
2966 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2967 operation: "write",
2968 path: path.clone(),
2969 detail: error.to_string(),
2970 })?;
2971 temporary
2972 .write_all(text.as_bytes())
2973 .and_then(|()| temporary.as_file().sync_all())
2974 .map_err(|error| ServiceError::Record {
2975 operation: "write",
2976 path: path.clone(),
2977 detail: error.to_string(),
2978 })?;
2979 #[cfg(unix)]
2995 {
2996 use std::os::unix::fs::PermissionsExt as _;
2997
2998 temporary
2999 .as_file()
3000 .set_permissions(std::fs::Permissions::from_mode(0o644))
3001 .map_err(|error| ServiceError::Record {
3002 operation: "write",
3003 path: path.clone(),
3004 detail: error.to_string(),
3005 })?;
3006 }
3007 temporary
3008 .persist(&path)
3009 .map(|_| ())
3010 .map_err(|error| ServiceError::Record {
3011 operation: "write",
3012 path,
3013 detail: error.error.to_string(),
3014 })
3015 }
3016
3017 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
3023 let path = Self::path(paths);
3024 match std::fs::remove_file(&path) {
3025 Ok(()) => Ok(true),
3026 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3027 Err(error) => Err(ServiceError::Record {
3028 operation: "remove",
3029 path,
3030 detail: error.to_string(),
3031 }),
3032 }
3033 }
3034}
3035
3036const CONTACT_SCHEMA_VERSION: u32 = 1;
3042
3043#[derive(Debug, Clone, Serialize, Deserialize)]
3044struct ContactRecord {
3045 schema_version: u32,
3046 last_success: DateTime<Utc>,
3047}
3048
3049pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3070 let path = contact_path(paths);
3071 let record = ContactRecord {
3072 schema_version: CONTACT_SCHEMA_VERSION,
3073 last_success: at,
3074 };
3075 let failed = |detail: String| ServiceError::Record {
3076 operation: "write",
3077 path: path.clone(),
3078 detail,
3079 };
3080 let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3081 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3082 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3083 let temporary = path.with_extension("toml.new");
3084 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3085 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3086}
3087
3088pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3100 let path = contact_path(paths);
3101 let text = match std::fs::read_to_string(&path) {
3102 Ok(text) => text,
3103 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3104 Err(error) => {
3105 return Err(ServiceError::Record {
3106 operation: "read",
3107 path,
3108 detail: error.to_string(),
3109 });
3110 }
3111 };
3112 let record: ContactRecord = toml::from_str(&text).map_err(|error| ServiceError::Record {
3113 operation: "read",
3114 path,
3115 detail: error.to_string(),
3116 })?;
3117 Ok(Some(record.last_success))
3118}
3119
3120#[must_use]
3122pub fn contact_path(paths: &AppPaths) -> PathBuf {
3123 paths.state_dir().join(CONTACT_FILE)
3124}
3125
3126#[derive(Debug, Serialize, Deserialize)]
3131#[serde(deny_unknown_fields)]
3132struct CredentialRejectionRecord {
3133 schema_version: u32,
3134 rejected_since: DateTime<Utc>,
3135}
3136
3137pub fn record_github_credential_rejected(
3153 paths: &AppPaths,
3154 at: DateTime<Utc>,
3155) -> Result<(), ServiceError> {
3156 if github_credential_rejected_since(paths)
3157 .ok()
3158 .flatten()
3159 .is_some()
3160 {
3161 return Ok(());
3162 }
3163 let path = credential_rejection_path(paths);
3164 let record = CredentialRejectionRecord {
3165 schema_version: CONTACT_SCHEMA_VERSION,
3166 rejected_since: at,
3167 };
3168 let failed = |detail: String| ServiceError::Record {
3169 operation: "write",
3170 path: path.clone(),
3171 detail,
3172 };
3173 let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3174 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3175 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3176 let temporary = path.with_extension("toml.new");
3177 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3178 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3179}
3180
3181pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3187 let path = credential_rejection_path(paths);
3188 match std::fs::remove_file(&path) {
3189 Ok(()) => Ok(()),
3190 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3191 Err(error) => Err(ServiceError::Record {
3192 operation: "remove",
3193 path,
3194 detail: error.to_string(),
3195 }),
3196 }
3197}
3198
3199pub fn github_credential_rejected_since(
3205 paths: &AppPaths,
3206) -> Result<Option<DateTime<Utc>>, ServiceError> {
3207 let path = credential_rejection_path(paths);
3208 let text = match std::fs::read_to_string(&path) {
3209 Ok(text) => text,
3210 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3211 Err(error) => {
3212 return Err(ServiceError::Record {
3213 operation: "read",
3214 path,
3215 detail: error.to_string(),
3216 });
3217 }
3218 };
3219 let record: CredentialRejectionRecord =
3220 toml::from_str(&text).map_err(|error| ServiceError::Record {
3221 operation: "read",
3222 path,
3223 detail: error.to_string(),
3224 })?;
3225 Ok(Some(record.rejected_since))
3226}
3227
3228#[must_use]
3230pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3231 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3232}
3233
3234#[derive(Debug, Clone, PartialEq, Eq)]
3251pub struct DefinitionDrift {
3252 pub path: PathBuf,
3254 pub installed_by_version: String,
3256 pub rendered: String,
3258 pub start_mode: StartMode,
3260 pub manager: DefinitionKind,
3262}
3263
3264fn definition_drift(
3272 controls: &dyn ControlFactory,
3273 identity: &ServiceIdentity,
3274 record: &InstallRecord,
3275) -> Option<DefinitionDrift> {
3276 let compare = match host_definition_kind(record.start_mode) {
3277 DefinitionKind::SystemdUnit => systemd_definition_drift,
3278 DefinitionKind::LaunchdPlist => launchd_definition_drift,
3279 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => return None,
3280 };
3281 let path = record.definition_path.clone()?;
3282 let installed = controls.installed_definition(&path)?;
3283 compare(identity, record, path, &installed)
3284}
3285
3286fn launchd_definition_drift(
3295 identity: &ServiceIdentity,
3296 record: &InstallRecord,
3297 path: PathBuf,
3298 installed: &str,
3299) -> Option<DefinitionDrift> {
3300 (plist_string_value(installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE)).then(
3301 || DefinitionDrift {
3302 path,
3303 installed_by_version: record.installed_by_version.clone(),
3304 rendered: launchd_plist(&record.plan(identity.clone())),
3305 start_mode: record.start_mode,
3306 manager: DefinitionKind::LaunchdPlist,
3307 },
3308 )
3309}
3310
3311fn systemd_definition_drift(
3312 identity: &ServiceIdentity,
3313 record: &InstallRecord,
3314 path: PathBuf,
3315 installed: &str,
3316) -> Option<DefinitionDrift> {
3317 let rendered = systemd_unit(&record.plan(identity.clone()));
3318 (installed != rendered).then(|| DefinitionDrift {
3319 path,
3320 installed_by_version: record.installed_by_version.clone(),
3321 rendered,
3322 start_mode: record.start_mode,
3323 manager: DefinitionKind::SystemdUnit,
3324 })
3325}
3326
3327const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3332
3333#[derive(Debug, Clone, Serialize, Deserialize)]
3334struct RootRefusalFile {
3335 schema_version: u32,
3336 #[serde(default)]
3338 refusals: BTreeMap<String, RootRefusalEntry>,
3339}
3340
3341#[derive(Debug, Clone, Serialize, Deserialize)]
3342struct RootRefusalEntry {
3343 at: DateTime<Utc>,
3344 kind: String,
3345 root: String,
3346 detail: String,
3347}
3348
3349#[derive(Debug, Clone, PartialEq, Eq)]
3351pub struct RunnerRootRefusal {
3352 pub policy: String,
3354 pub at: DateTime<Utc>,
3356 pub kind: String,
3358 pub root: String,
3360 pub detail: String,
3363}
3364
3365pub fn record_runner_root_refusal(
3399 paths: &AppPaths,
3400 policy: &str,
3401 at: DateTime<Utc>,
3402 kind: &str,
3403 root: &str,
3404 detail: &str,
3405) -> Result<(), ServiceError> {
3406 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3407 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3408 refusals: BTreeMap::new(),
3409 });
3410 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3411 file.refusals.insert(
3412 policy.to_owned(),
3413 RootRefusalEntry {
3414 at,
3415 kind: kind.to_owned(),
3416 root: root.to_owned(),
3417 detail: detail.to_owned(),
3418 },
3419 );
3420 write_refusal_file(paths, &file)
3421}
3422
3423pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3434 let Some(mut file) = read_refusal_file(paths)? else {
3435 return Ok(());
3436 };
3437 if file.refusals.remove(policy).is_none() {
3438 return Ok(());
3439 }
3440 if file.refusals.is_empty() {
3441 let path = root_refusal_path(paths);
3442 return match std::fs::remove_file(&path) {
3443 Ok(()) => Ok(()),
3444 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3445 Err(error) => Err(ServiceError::Record {
3446 operation: "remove",
3447 path,
3448 detail: error.to_string(),
3449 }),
3450 };
3451 }
3452 write_refusal_file(paths, &file)
3453}
3454
3455pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3467 Ok(read_refusal_file(paths)?
3468 .map(|file| {
3469 file.refusals
3470 .into_iter()
3471 .map(|(policy, entry)| RunnerRootRefusal {
3472 policy,
3473 at: entry.at,
3474 kind: entry.kind,
3475 root: entry.root,
3476 detail: entry.detail,
3477 })
3478 .collect()
3479 })
3480 .unwrap_or_default())
3481}
3482
3483fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3484 let path = root_refusal_path(paths);
3485 let text = match std::fs::read_to_string(&path) {
3486 Ok(text) => text,
3487 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3488 Err(error) => {
3489 return Err(ServiceError::Record {
3490 operation: "read",
3491 path,
3492 detail: error.to_string(),
3493 });
3494 }
3495 };
3496 toml::from_str(&text)
3497 .map(Some)
3498 .map_err(|error| ServiceError::Record {
3499 operation: "read",
3500 path,
3501 detail: error.to_string(),
3502 })
3503}
3504
3505fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3506 let path = root_refusal_path(paths);
3507 let failed = |detail: String| ServiceError::Record {
3508 operation: "write",
3509 path: path.clone(),
3510 detail,
3511 };
3512 let text = toml::to_string_pretty(file).map_err(|error| failed(error.to_string()))?;
3513 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3514 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3515 let temporary = path.with_extension("toml.new");
3516 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3517 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3518}
3519
3520#[must_use]
3522pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3523 paths.state_dir().join(ROOT_REFUSAL_FILE)
3524}
3525
3526#[derive(Debug, Clone, PartialEq, Eq)]
3537pub enum BinaryPath {
3538 Current {
3540 path: PathBuf,
3542 },
3543 Missing {
3550 recorded: PathBuf,
3552 },
3553 NotExecutable {
3556 recorded: PathBuf,
3558 detail: String,
3560 },
3561 Diverged {
3563 recorded: PathBuf,
3565 registered: PathBuf,
3567 },
3568}
3569
3570impl BinaryPath {
3571 #[must_use]
3573 pub const fn is_error(&self) -> bool {
3574 !matches!(self, Self::Current { .. })
3575 }
3576
3577 #[must_use]
3579 pub fn recorded(&self) -> &Path {
3580 match self {
3581 Self::Current { path } => path,
3582 Self::Missing { recorded }
3583 | Self::NotExecutable { recorded, .. }
3584 | Self::Diverged { recorded, .. } => recorded,
3585 }
3586 }
3587}
3588
3589impl fmt::Display for BinaryPath {
3590 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3591 match self {
3592 Self::Current { path } => write!(f, "{}", path.display()),
3593 Self::Missing { recorded } => write!(
3594 f,
3595 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3596 package manager that moved the binary is the usual cause; an `npm i -g` \
3597 installation moves with the active Node version. Run `service install` again \
3598 from the binary that is now installed.",
3599 recorded.display()
3600 ),
3601 Self::NotExecutable { recorded, detail } => write!(
3602 f,
3603 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3604 from the installed binary.",
3605 recorded.display()
3606 ),
3607 Self::Diverged {
3608 recorded,
3609 registered,
3610 } => write!(
3611 f,
3612 "{} -- STALE: the service manager is registered to start {} instead. Something \
3613 has edited the registration since it was installed. Run `service uninstall` and \
3614 `service install`; neither touches configuration, secrets, or the cache.",
3615 recorded.display(),
3616 registered.display()
3617 ),
3618 }
3619 }
3620}
3621
3622#[must_use]
3630pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3631 match std::fs::metadata(recorded) {
3632 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3633 return BinaryPath::Missing {
3634 recorded: recorded.to_path_buf(),
3635 };
3636 }
3637 Err(error) => {
3638 return BinaryPath::NotExecutable {
3639 recorded: recorded.to_path_buf(),
3640 detail: format!("it cannot be inspected ({error})"),
3641 };
3642 }
3643 Ok(metadata) if !metadata.is_file() => {
3644 return BinaryPath::NotExecutable {
3645 recorded: recorded.to_path_buf(),
3646 detail: "what is there is not a file".to_string(),
3647 };
3648 }
3649 Ok(_) => {}
3650 }
3651 if let Some(registered) = registered
3652 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3653 {
3654 return BinaryPath::Diverged {
3655 recorded: recorded.to_path_buf(),
3656 registered: registered.to_path_buf(),
3657 };
3658 }
3659 BinaryPath::Current {
3660 path: recorded.to_path_buf(),
3661 }
3662}
3663
3664#[derive(Debug, Clone, PartialEq, Eq)]
3670pub struct Registration {
3671 pub manager: DefinitionKind,
3676 pub start_mode: StartMode,
3678 pub command_line: String,
3680 pub account: Option<String>,
3682 pub running: bool,
3684 pub starts_automatically: bool,
3691 pub restart_delay: Option<Duration>,
3700}
3701
3702impl Registration {
3703 #[must_use]
3705 pub fn binary(&self) -> Option<PathBuf> {
3706 executable_from_command_line(&self.command_line)
3707 }
3708}
3709
3710pub trait ServiceControl: fmt::Debug {
3716 fn manager(&self) -> DefinitionKind;
3718
3719 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3725
3726 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3737
3738 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3744
3745 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3752
3753 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3759}
3760
3761pub trait ControlFactory: fmt::Debug + Send + Sync {
3767 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3773
3774 fn installed_definition(&self, _path: &Path) -> Option<String> {
3778 None
3779 }
3780}
3781
3782#[derive(Debug, Clone, Copy, Default)]
3784pub struct HostControls;
3785
3786#[derive(Debug, Clone)]
3792pub struct Installed {
3793 pub plan: InstallPlan,
3795 pub definition: ServiceDefinition,
3797 pub record: InstallRecord,
3799 pub review: PrivilegeReview,
3801 pub runner_root: RootAccessSummary,
3806 pub replaced_existing: bool,
3814}
3815
3816#[derive(Debug, Clone, PartialEq, Eq)]
3818pub struct Uninstalled {
3819 pub removed_registration: bool,
3821 pub removed_record: bool,
3823 pub removed_definition: Option<PathBuf>,
3825 pub preserved: Vec<PathBuf>,
3832}
3833
3834impl fmt::Display for Uninstalled {
3835 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3836 if self.removed_registration {
3837 writeln!(f, "The service registration was removed.")?;
3838 } else {
3839 writeln!(f, "There was no service registration to remove.")?;
3840 }
3841 writeln!(f, "Nothing else was deleted. These are untouched:")?;
3842 for path in &self.preserved {
3843 writeln!(f, " {}", path.display())?;
3844 }
3845 write!(
3846 f,
3847 "The stored GitHub token is untouched too; `auth logout` is what purges it."
3848 )
3849 }
3850}
3851
3852#[derive(Debug, Clone, PartialEq, Eq)]
3854pub struct StartModeChange {
3855 pub from: StartMode,
3857 pub to: StartMode,
3859 pub changed: bool,
3861 pub store_scope: crate::secrets::SecretScope,
3864 pub runner_root: RootAccessSummary,
3870}
3871
3872impl fmt::Display for StartModeChange {
3873 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3874 if !self.changed {
3875 return write!(f, "The service already starts at {}.", self.to);
3876 }
3877 write!(
3878 f,
3879 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3880 if the token was stored under the other scope, run `auth login` again. {}",
3881 self.to, self.from, self.store_scope, self.runner_root
3882 )
3883 }
3884}
3885
3886#[derive(Debug, Clone)]
3894pub struct ServiceOperations {
3895 paths: AppPaths,
3896 identity: ServiceIdentity,
3897 controls: std::sync::Arc<dyn ControlFactory>,
3898 runner_root: Option<LocalAbsolutePath>,
3899}
3900
3901impl ServiceOperations {
3902 #[must_use]
3904 pub fn on_this_host(paths: AppPaths) -> Self {
3905 Self::with_controls(
3906 paths,
3907 ServiceIdentity::product(),
3908 std::sync::Arc::new(HostControls),
3909 )
3910 }
3911
3912 #[must_use]
3920 pub fn with_controls(
3921 paths: AppPaths,
3922 identity: ServiceIdentity,
3923 controls: std::sync::Arc<dyn ControlFactory>,
3924 ) -> Self {
3925 Self {
3926 paths,
3927 identity,
3928 controls,
3929 runner_root: None,
3930 }
3931 }
3932
3933 #[must_use]
3952 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
3953 self.runner_root = Some(root);
3954 self
3955 }
3956
3957 #[must_use]
3959 pub const fn paths(&self) -> &AppPaths {
3960 &self.paths
3961 }
3962
3963 #[must_use]
3965 pub const fn identity(&self) -> &ServiceIdentity {
3966 &self.identity
3967 }
3968
3969 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
3989 self.paths
3990 .create_all()
3991 .map_err(|source| ServiceError::Paths {
3992 source: Box::new(source),
3993 })?;
3994
3995 let _guard = self.refuse_while_an_agent_runs()?;
3998
3999 let (replacing, previous_was_running) = match self.find_registration()? {
4028 Some((existing, registration)) if existing == request.start_mode() => {
4029 (true, registration.running)
4030 }
4031 Some((existing, _)) => {
4032 return Err(ServiceError::AlreadyInstalled {
4033 name: self.identity.name().to_string(),
4034 existing,
4035 requested: request.start_mode(),
4036 });
4037 }
4038 None => (false, false),
4039 };
4040
4041 let plan = InstallPlan::resolve(
4042 self.identity.clone(),
4043 request,
4044 ServiceDirectories::of(&self.paths),
4045 )?;
4046
4047 let control = self.controls.control(plan.start_mode())?;
4052
4053 let root = self.prepare_runner_root(plan.start_mode())?;
4059
4060 let previous = if replacing {
4065 InstallRecord::read(&self.paths).ok().flatten()
4066 } else {
4067 None
4068 };
4069 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4070 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4071 }
4072
4073 let definition = match control.install(&plan) {
4074 Ok(definition) => definition,
4075 Err(cause) => {
4076 let restored =
4079 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4080 return Err(rolled_back(
4081 retained_runner_root(&root),
4082 restored,
4083 "install",
4084 &self.identity,
4085 cause,
4086 ));
4087 }
4088 };
4089 let review = review_least_privilege(&definition, &plan);
4090 let record = InstallRecord::of(&plan, &definition, Utc::now());
4091 if let Err(cause) = record.write(&self.paths) {
4092 let rollback = control
4093 .uninstall(&self.identity)
4094 .map(|_| ())
4095 .and_then(|()| {
4096 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4097 });
4098 return Err(rolled_back(
4099 retained_runner_root(&root),
4100 rollback,
4101 "install",
4102 &self.identity,
4103 cause,
4104 ));
4105 }
4106
4107 if let Err(cause) = control.start(&self.identity) {
4114 let remove_new = control.uninstall(&self.identity).map(|_| ());
4115 let restore_registration = remove_new.and_then(|()| {
4116 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4117 });
4118 let restore_record = restore_registration.and_then(|()| match &previous {
4119 Some(previous) => previous.write(&self.paths),
4120 None => InstallRecord::remove(&self.paths).map(|_| ()),
4121 });
4122 return Err(rolled_back(
4123 retained_runner_root(&root),
4124 restore_record,
4125 "install and start",
4126 &self.identity,
4127 cause,
4128 ));
4129 }
4130 Ok(Installed {
4131 plan,
4132 definition,
4133 record,
4134 review,
4135 runner_root: root.summary().clone(),
4136 replaced_existing: replacing,
4137 })
4138 }
4139
4140 fn reinstate(
4151 &self,
4152 control: &dyn ServiceControl,
4153 previous: Option<&InstallRecord>,
4154 was_running: bool,
4155 ) -> Result<(), ServiceError> {
4156 let Some(record) = previous else {
4157 return Ok(());
4158 };
4159 control.install(&record.plan(self.identity.clone()))?;
4160 if was_running {
4161 control.start(&self.identity)?;
4162 }
4163 Ok(())
4164 }
4165
4166 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4175 let record = InstallRecord::read(&self.paths).ok().flatten();
4176 let removed_definition = record
4177 .as_ref()
4178 .and_then(|record| record.definition_path.clone());
4179
4180 let mut removed_registration = false;
4181 for mode in [StartMode::Boot, StartMode::Login] {
4185 let control = self.controls.control(mode)?;
4186 if control.uninstall(&self.identity)? {
4187 removed_registration = true;
4188 }
4189 }
4190 let removed_record = InstallRecord::remove(&self.paths)?;
4191 Ok(Uninstalled {
4192 removed_registration,
4193 removed_record,
4194 removed_definition: removed_definition.filter(|_| removed_registration),
4195 preserved: self
4196 .paths
4197 .all()
4198 .iter()
4199 .map(|(_, path)| (*path).to_path_buf())
4200 .collect(),
4201 })
4202 }
4203
4204 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4226 let Some(record) = InstallRecord::read(&self.paths)? else {
4227 return Err(ServiceError::NotInstalled {
4228 name: self.identity.name().to_string(),
4229 operation: "switch the start mode of",
4230 });
4231 };
4232 let from = record.start_mode;
4233 if from == to {
4234 return Ok(StartModeChange {
4238 from,
4239 to,
4240 changed: false,
4241 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4242 runner_root: RootAccessSummary::NotApplicable,
4243 });
4244 }
4245
4246 #[cfg(windows)]
4247 let arguments = {
4248 let mut arguments = record.arguments.clone();
4249 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4250 if to == StartMode::Boot {
4251 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4252 }
4253 arguments
4254 };
4255 #[cfg(not(windows))]
4256 let arguments = record.arguments.clone();
4257 let plan = InstallPlan::unchecked(
4258 self.identity.clone(),
4259 to,
4260 record.binary.clone(),
4261 record.directories.clone(),
4262 )
4263 .with_arguments(arguments)
4264 .with_restart(record.restart());
4265 let plan = if record.starts_on_demand {
4266 plan.started_on_demand()
4267 } else {
4268 plan
4269 };
4270 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4271 Ok(store) => plan.with_secret_guard(store.guard()),
4272 Err(_) => plan,
4273 };
4274
4275 let target = self.controls.control(to)?;
4282 let previous = self.controls.control(from)?;
4287
4288 let root = self.prepare_runner_root(to)?;
4296
4297 let definition = match target.install(&plan) {
4298 Ok(definition) => definition,
4299 Err(cause) => {
4300 return Err(undo_runner_root(
4301 &root,
4302 "switch start mode",
4303 &self.identity,
4304 cause,
4305 ));
4306 }
4307 };
4308 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4309 if let Err(cause) = next_record.write(&self.paths) {
4310 return Err(rolled_back(
4311 retained_runner_root(&root),
4312 target.uninstall(&self.identity),
4313 "switch start mode",
4314 &self.identity,
4315 cause,
4316 ));
4317 }
4318
4319 if let Err(cause) = previous.uninstall(&self.identity) {
4323 let target_rollback = target.uninstall(&self.identity);
4324 let record_rollback = record.write(&self.paths);
4325 return Err(rolled_back(
4326 retained_runner_root(&root),
4327 target_rollback.and(record_rollback),
4328 "switch start mode",
4329 &self.identity,
4330 cause,
4331 ));
4332 }
4333 Ok(StartModeChange {
4334 from,
4335 to,
4336 changed: true,
4337 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4338 runner_root: root.summary().clone(),
4339 })
4340 }
4341
4342 pub fn start(&self) -> Result<(), ServiceError> {
4348 let Some((mode, _)) = self.find_registration()? else {
4349 return Err(ServiceError::NotInstalled {
4350 name: self.identity.name().to_string(),
4351 operation: "start",
4352 });
4353 };
4354 self.controls.control(mode)?.start(&self.identity)
4355 }
4356
4357 pub fn stop(&self) -> Result<bool, ServiceError> {
4363 let Some((mode, _)) = self.find_registration()? else {
4364 return Err(ServiceError::NotInstalled {
4365 name: self.identity.name().to_string(),
4366 operation: "stop",
4367 });
4368 };
4369 self.controls.control(mode)?.stop(&self.identity)
4370 }
4371
4372 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4391 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4392 Ok(record) => (record, None),
4393 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4394 Err(error) => return Err(error),
4395 };
4396 let found = self.find_registration()?;
4397 let last_github_contact = last_github_contact(&self.paths)?;
4398 let drift = record
4399 .as_ref()
4400 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4401 let credential_rejected_since =
4402 github_credential_rejected_since(&self.paths).ok().flatten();
4403 Ok(ServiceStatus::compose(
4404 self.identity.clone(),
4405 record,
4406 record_refused.as_ref(),
4407 found.map(|(_, registration)| registration),
4408 last_github_contact,
4409 &self.paths,
4410 )
4411 .with_definition_drift(drift)
4412 .with_credential_rejection(credential_rejected_since))
4413 }
4414
4415 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4422 Ok(InstallRecord::read(&self.paths)?
4423 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4424 }
4425
4426 #[cfg(target_os = "macos")]
4448 pub fn reload_launchd_definition(&self, drift: &DefinitionDrift) -> Result<(), ServiceError> {
4449 let root = unsafe { libc::getuid() } == 0;
4455 if root != (drift.start_mode == StartMode::Boot) {
4456 return Err(ServiceError::Control {
4457 operation: "rewrite",
4458 name: self.identity.name().to_string(),
4459 manager: "launchd",
4460 detail: format!(
4461 "the install record says --start-at {}, but this daemon runs as {}; the plist \
4462 is left as it is",
4463 drift.start_mode,
4464 if root { "root" } else { "the operator" }
4465 ),
4466 });
4467 }
4468 replace_definition(self.identity.name(), drift)?;
4469 if let Ok(Some(mut record)) = InstallRecord::read(&self.paths) {
4473 record.installed_by_version = env!("CARGO_PKG_VERSION").to_string();
4474 let _ = record.write(&self.paths);
4475 }
4476 sys::reload_detached(&self.identity, drift.start_mode, &drift.path)
4477 }
4478
4479 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4489 #[cfg(not(windows))]
4490 {
4491 let _ = (mode, &self.runner_root);
4494 Ok(RootAccessChange::not_applicable())
4495 }
4496 #[cfg(windows)]
4497 {
4498 let wrap = |source| ServiceError::RunnerRoot {
4499 source: Box::new(source),
4500 };
4501 if let Some(root) = self
4519 .runner_root
4520 .as_ref()
4521 .filter(|_| self.identity.is_fixture() || cfg!(test))
4522 {
4523 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4524 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4525 .map_err(wrap);
4526 }
4527
4528 let admission = match ServiceAccount::for_start_mode(mode) {
4529 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4532 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4536 RootAdmission::of_this_account().map_err(wrap)?
4537 }
4538 };
4539 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4540 }
4541 }
4542
4543 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4545 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4546 |source| match source {
4547 held @ LockError::Held { .. } => ServiceError::LockHeld {
4548 source: Box::new(held),
4549 },
4550 other => ServiceError::LockUnreadable {
4551 source: Box::new(other),
4552 },
4553 },
4554 )
4555 }
4556
4557 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4559 for mode in [StartMode::Boot, StartMode::Login] {
4560 let control = self.controls.control(mode)?;
4561 if let Some(registration) = control.query(&self.identity)? {
4562 return Ok(Some((registration.start_mode, registration)));
4563 }
4564 }
4565 Ok(None)
4566 }
4567}
4568
4569#[derive(Debug, Clone, PartialEq, Eq)]
4575pub struct StatusProblem {
4576 pub subject: &'static str,
4578 pub detail: String,
4580}
4581
4582impl fmt::Display for StatusProblem {
4583 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4584 write!(f, "{}: {}", self.subject, self.detail)
4585 }
4586}
4587
4588#[derive(Debug, Clone)]
4597pub struct ServiceStatus {
4598 identity: ServiceIdentity,
4599 record: Option<InstallRecord>,
4600 registration: Option<Registration>,
4601 binary: Option<BinaryPath>,
4602 log_file: PathBuf,
4603 store: Option<crate::secrets::ActiveStore>,
4604 last_github_contact: Option<DateTime<Utc>>,
4605 runner_root: Option<(PathBuf, RootAccessReport)>,
4606 credential_rejected_since: Option<DateTime<Utc>>,
4607 problems: Vec<StatusProblem>,
4608 notes: Vec<String>,
4609}
4610
4611impl ServiceStatus {
4612 fn compose(
4617 identity: ServiceIdentity,
4618 record: Option<InstallRecord>,
4619 record_refused: Option<&ServiceError>,
4620 registration: Option<Registration>,
4621 last_github_contact: Option<DateTime<Utc>>,
4622 paths: &AppPaths,
4623 ) -> Self {
4624 let mut problems = Vec::new();
4625 let mut notes = Vec::new();
4626
4627 if let Some(refusal) = record_refused {
4628 problems.push(StatusProblem {
4633 subject: "install record",
4634 detail: refusal.to_string(),
4635 });
4636 }
4637
4638 let log_file = record.as_ref().map_or_else(
4639 || ServiceDirectories::of(paths).log_file(),
4640 |record| record.log_file.clone(),
4641 );
4642
4643 let binary = record.as_ref().map(|record| {
4644 inspect_binary(
4645 &record.binary,
4646 registration
4647 .as_ref()
4648 .and_then(Registration::binary)
4649 .as_deref(),
4650 )
4651 });
4652 if let Some(state) = &binary
4653 && state.is_error()
4654 {
4655 problems.push(StatusProblem {
4656 subject: "binary",
4657 detail: state.to_string(),
4658 });
4659 }
4660
4661 match (&record, ®istration) {
4662 (Some(_), None) => problems.push(StatusProblem {
4663 subject: "registration",
4664 detail: "this host has a service record but no service manager knows the \
4665 registration. Run `service install` again; it deletes nothing."
4666 .to_string(),
4667 }),
4668 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4673 subject: "record",
4674 detail: format!(
4675 "{} holds a registration for this service but there is no install record, so \
4676 the path it was installed from and the directories it was installed against \
4677 are unknown. Run `service uninstall` and `service install`.",
4678 found.manager
4679 ),
4680 }),
4681 (None, Some(_)) => {}
4682 (Some(record), Some(found)) => {
4683 if record.start_mode != found.start_mode {
4684 problems.push(StatusProblem {
4685 subject: "start mode",
4686 detail: format!(
4687 "the record says {} and {} holds a {} registration. Switch the start \
4688 mode again to make them agree.",
4689 record.start_mode, found.manager, found.start_mode
4690 ),
4691 });
4692 }
4693 if record.start_mode == StartMode::Boot && !found.starts_automatically {
4694 problems.push(StatusProblem {
4695 subject: "start mode",
4696 detail: format!(
4697 "{} holds the registration but will not start it by itself, so this \
4698 host does not resume work after a reboot.",
4699 found.manager
4700 ),
4701 });
4702 }
4703 if let Some(actual) = found.restart_delay {
4704 let expected = record.restart().effective_delay(found.manager);
4705 if actual != expected {
4706 problems.push(StatusProblem {
4707 subject: "restart policy",
4708 detail: format!(
4709 "the record says the service restarts after {}s and {} reports \
4710 {}s. Something has edited the registration since it was \
4711 installed.",
4712 expected.as_secs(),
4713 found.manager,
4714 actual.as_secs()
4715 ),
4716 });
4717 } else if expected != record.restart().delay() {
4718 notes.push(format!(
4723 "{} expresses the restart delay in whole minutes, so the {}s asked \
4724 for is enforced as {}s. The service therefore never restarts faster \
4725 than the configured bound.",
4726 found.manager,
4727 record.restart().delay().as_secs(),
4728 expected.as_secs()
4729 ));
4730 }
4731 }
4732 if found.starts_automatically && !found.running {
4733 problems.push(StatusProblem {
4734 subject: "runtime",
4735 detail: format!(
4736 "{} holds an automatic registration, but the daemon is stopped. Run \
4737 `runner-manager service install --start-at {}`; if the service \
4738 manager denies replacement, retry from an elevated terminal. If it \
4739 stops again, inspect {}.",
4740 found.manager,
4741 found.start_mode,
4742 log_file.display()
4743 ),
4744 });
4745 }
4746 }
4747 (None, None) => {}
4748 }
4749
4750 let store = record.as_ref().and_then(|record| {
4755 let registered_mode = registration
4756 .as_ref()
4757 .map_or(record.start_mode, |found| found.start_mode);
4758 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4759 .ok()
4760 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4761 });
4762 if let Some(store) = &store
4763 && !store.agrees_with_start_mode()
4764 {
4765 problems.push(StatusProblem {
4766 subject: "secret store",
4767 detail: format!(
4768 "{store}. Run `auth login` again so the token is stored where the registered \
4769 start mode can read it."
4770 ),
4771 });
4772 }
4773
4774 if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4775 notes.push(
4776 "This registration starts at login, so the agent does not run until the operator \
4777 signs in; this host does not resume work after an unattended reboot."
4778 .to_string(),
4779 );
4780 }
4781 if last_github_contact.is_none() {
4782 notes.push(
4783 "GitHub has not been reached successfully since this host's state directory was \
4784 created."
4785 .to_string(),
4786 );
4787 }
4788
4789 let runner_root = crate::runner_root::default_runner_root(paths)
4796 .ok()
4797 .map(|root| {
4798 let path = root.as_path().to_path_buf();
4799 let report = crate::runner_root_access::report(&path);
4800 (path, report)
4801 });
4802 if let Some((
4812 path,
4813 RootAccessReport::Present {
4814 broad_write: true, ..
4815 },
4816 )) = &runner_root
4817 {
4818 notes.push(format!(
4819 "the platform default runner root {} can be written by ordinary local users, so \
4820 it is not a safe place to run jobs. `service install` refuses it rather than \
4821 tightening it, because the contents of a directory anybody could write cannot \
4822 be trusted: remove or empty it, or choose another root with `runner-manager \
4823 host set-runtime-root --path <PATH>`.",
4824 path.display()
4825 ));
4826 }
4827
4828 match runner_root_refusals(paths) {
4851 Ok(refusals) => {
4852 for refusal in refusals {
4853 notes.push(format!(
4854 "policy {} started no runner: its runner root {} refused the launch \
4855 ({}), last at {}. {} This clears when that policy next places a \
4856 runner.",
4857 refusal.policy,
4858 refusal.root,
4859 refusal.kind,
4860 refusal.at.to_rfc3339(),
4861 refusal.detail,
4862 ));
4863 }
4864 }
4865 Err(error) => notes.push(format!(
4866 "whether the agent could use its runner roots could not be read: {error}"
4867 )),
4868 }
4869
4870 Self {
4871 identity,
4872 record,
4873 registration,
4874 binary,
4875 log_file,
4876 store,
4877 last_github_contact,
4878 runner_root,
4879 credential_rejected_since: None,
4880 problems,
4881 notes,
4882 }
4883 }
4884
4885 #[must_use]
4887 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
4888 if let Some(drift) = drift {
4889 if drift.manager == DefinitionKind::LaunchdPlist {
4890 let restart = match drift.start_mode {
4894 StartMode::Boot => format!(
4895 "sudo launchctl kickstart -k system/{}",
4896 self.identity.launchd_label()
4897 ),
4898 StartMode::Login => format!(
4899 "launchctl kickstart -k gui/$(id -u)/{}",
4900 self.identity.launchd_label()
4901 ),
4902 };
4903 self.problems.push(StatusProblem {
4904 subject: "definition",
4905 detail: format!(
4906 "{} was written by runner-manager {} with a `ProcessType` other than \
4907 `{LAUNCHD_PROCESS_TYPE}`, which launchd applies to every runner the \
4908 daemon starts, so their jobs run below normal priority. The daemon \
4909 rewrites it and reloads itself when it starts with no runner, which it \
4910 does after every upgrade; to do it now, restart the service while no \
4911 job is running: `{restart}`. Configuration and credentials are kept.",
4912 drift.path.display(),
4913 drift.installed_by_version,
4914 ),
4915 });
4916 return self;
4917 }
4918 let repair = match drift.start_mode {
4919 StartMode::Boot => {
4920 "`sudo systemctl stop runner-manager && sudo runner-manager service install \
4921 --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
4922 --distribution <name>` from Windows)"
4923 }
4924 StartMode::Login => {
4925 "`systemctl --user stop runner-manager && runner-manager service install \
4926 --start-at login`"
4927 }
4928 };
4929 self.problems.push(StatusProblem {
4930 subject: "definition",
4931 detail: format!(
4932 "{} was written by runner-manager {} and differs from what {} renders, so \
4933 the daemon runs under outdated settings (for example a credential frozen \
4934 at service start). Re-register it with {repair}; configuration and \
4935 credentials are kept.",
4936 drift.path.display(),
4937 drift.installed_by_version,
4938 env!("CARGO_PKG_VERSION"),
4939 ),
4940 });
4941 }
4942 self
4943 }
4944
4945 #[must_use]
4947 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
4948 if let Some(since) = since {
4949 self.credential_rejected_since = Some(since);
4950 self.problems.push(StatusProblem {
4951 subject: "github credential",
4952 detail: format!(
4953 "GitHub has rejected the service's credential since {}, so the service \
4954 cannot see queued jobs or start runners. Run `runner-manager auth login` \
4955 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
4956 from Windows).",
4957 since.to_rfc3339()
4958 ),
4959 });
4960 }
4961 self
4962 }
4963
4964 #[must_use]
4966 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
4967 self.credential_rejected_since
4968 }
4969
4970 #[must_use]
4978 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
4979 self.runner_root
4980 .as_ref()
4981 .map(|(path, report)| (path.as_path(), report))
4982 }
4983
4984 #[must_use]
4986 pub const fn is_installed(&self) -> bool {
4987 self.registration.is_some() || self.record.is_some()
4988 }
4989
4990 #[must_use]
4992 pub fn is_running(&self) -> bool {
4993 self.registration
4994 .as_ref()
4995 .is_some_and(|registration| registration.running)
4996 }
4997
4998 #[must_use]
5000 pub fn is_healthy(&self) -> bool {
5001 self.problems.is_empty()
5002 }
5003
5004 #[must_use]
5006 pub fn problems(&self) -> &[StatusProblem] {
5007 &self.problems
5008 }
5009
5010 #[must_use]
5013 pub fn notes(&self) -> &[String] {
5014 &self.notes
5015 }
5016
5017 #[must_use]
5019 pub fn start_mode(&self) -> Option<StartMode> {
5020 self.record.as_ref().map(|record| record.start_mode)
5021 }
5022
5023 #[must_use]
5026 pub const fn binary(&self) -> Option<&BinaryPath> {
5027 self.binary.as_ref()
5028 }
5029
5030 #[must_use]
5032 pub fn log_file(&self) -> &Path {
5033 &self.log_file
5034 }
5035
5036 #[must_use]
5038 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
5039 self.last_github_contact
5040 }
5041
5042 #[must_use]
5045 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
5046 self.store.as_ref()
5047 }
5048
5049 #[must_use]
5051 pub const fn record(&self) -> Option<&InstallRecord> {
5052 self.record.as_ref()
5053 }
5054
5055 #[must_use]
5057 pub const fn registration(&self) -> Option<&Registration> {
5058 self.registration.as_ref()
5059 }
5060}
5061
5062impl fmt::Display for ServiceStatus {
5063 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5064 writeln!(f, "Service: {}", self.identity)?;
5065 match (&self.record, &self.registration) {
5066 (None, None) => {
5067 writeln!(
5068 f,
5069 " installed no. `service install` registers `{} {}`.",
5070 SERVICE_NAME,
5071 DAEMON_ARGUMENTS.join(" ")
5072 )?;
5073 }
5074 _ => {
5075 let manager = self
5076 .registration
5077 .as_ref()
5078 .map(|registration| registration.manager.manager());
5079 writeln!(
5080 f,
5081 " installed {}",
5082 manager.unwrap_or("yes, but no service manager knows it")
5083 )?;
5084 writeln!(
5085 f,
5086 " state {}",
5087 if self.is_running() {
5088 "running"
5089 } else {
5090 "not running"
5091 }
5092 )?;
5093 }
5094 }
5095 if let Some(record) = &self.record {
5096 writeln!(f, " start mode {}", record.start_mode)?;
5097 writeln!(f, " account {}", record.account)?;
5098 writeln!(f, " restart on failure {}", record.restart())?;
5099 writeln!(
5100 f,
5101 " arguments {}",
5102 record.arguments.join(" ")
5103 )?;
5104 }
5105 if let Some(binary) = &self.binary {
5106 writeln!(f, " binary {binary}")?;
5107 }
5108 writeln!(f, " diagnostic log {}", self.log_file.display())?;
5109 if let Some((path, report)) = &self.runner_root {
5110 writeln!(f, " default runner root {}", path.display())?;
5117 if *report != RootAccessReport::NotApplicable {
5118 writeln!(f, " default root access {report}")?;
5119 }
5120 }
5121 if let Some(store) = &self.store {
5122 writeln!(f, " secret store {store}")?;
5123 }
5124 writeln!(
5125 f,
5126 " last GitHub contact {}",
5127 match self.last_github_contact {
5128 Some(at) => at.to_rfc3339(),
5129 None => "never".to_string(),
5130 }
5131 )?;
5132 for note in &self.notes {
5133 writeln!(f, " note {note}")?;
5134 }
5135 for problem in &self.problems {
5136 writeln!(f, " ERROR {problem}")?;
5137 }
5138 write!(
5139 f,
5140 " verdict {}",
5141 if self.is_healthy() {
5142 "healthy"
5143 } else {
5144 "NOT healthy"
5145 }
5146 )
5147 }
5148}
5149
5150#[derive(Debug, Clone, Default)]
5167pub struct RecordingControls {
5168 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5169}
5170
5171#[derive(Debug, Default)]
5172struct RecordingState {
5173 registrations: BTreeMap<(StartMode, String), Registration>,
5174 definitions: BTreeMap<String, ServiceDefinition>,
5175 calls: Vec<String>,
5176 #[cfg(test)]
5177 install_failures: BTreeMap<StartMode, String>,
5178 #[cfg(test)]
5179 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5180}
5181
5182#[cfg(test)]
5183#[derive(Debug, Clone)]
5184enum TestInstallSideEffect {
5185 HideDirectory { directory: PathBuf, hidden: PathBuf },
5186}
5187
5188impl RecordingControls {
5189 #[must_use]
5191 pub fn new() -> Self {
5192 Self::default()
5193 }
5194
5195 #[must_use]
5198 pub fn calls(&self) -> Vec<String> {
5199 self.state.lock().expect("not poisoned").calls.clone()
5200 }
5201
5202 #[must_use]
5204 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5205 self.state
5206 .lock()
5207 .expect("not poisoned")
5208 .registrations
5209 .iter()
5210 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5211 .collect()
5212 }
5213
5214 #[must_use]
5216 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5217 self.state
5218 .lock()
5219 .expect("not poisoned")
5220 .definitions
5221 .get(name)
5222 .cloned()
5223 }
5224
5225 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5235 let mut state = self.state.lock().expect("not poisoned");
5236 if let Some((_, registration)) = state
5237 .registrations
5238 .iter_mut()
5239 .find(|((_, held), _)| held == name)
5240 {
5241 edit(registration);
5242 }
5243 }
5244
5245 #[cfg(test)]
5246 fn fail_next_install(&self, mode: StartMode, detail: &str) {
5247 self.state
5248 .lock()
5249 .expect("not poisoned")
5250 .install_failures
5251 .insert(mode, detail.to_string());
5252 }
5253
5254 #[cfg(test)]
5255 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5256 self.state
5257 .lock()
5258 .expect("not poisoned")
5259 .after_install
5260 .insert(
5261 mode,
5262 TestInstallSideEffect::HideDirectory { directory, hidden },
5263 );
5264 }
5265}
5266
5267impl ControlFactory for RecordingControls {
5268 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5269 Ok(Box::new(RecordingControl {
5270 mode,
5271 state: std::sync::Arc::clone(&self.state),
5272 }))
5273 }
5274}
5275
5276#[derive(Debug)]
5277struct RecordingControl {
5278 mode: StartMode,
5279 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5280}
5281
5282impl RecordingControl {
5283 fn note(&self, operation: &str, name: &str) {
5284 self.state
5285 .lock()
5286 .expect("not poisoned")
5287 .calls
5288 .push(format!("{operation} {name} ({})", self.mode));
5289 }
5290}
5291
5292impl ServiceControl for RecordingControl {
5293 fn manager(&self) -> DefinitionKind {
5294 host_definition_kind(self.mode)
5298 }
5299
5300 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5301 self.note("install", plan.identity().name());
5302 #[cfg(test)]
5303 if let Some(detail) = self
5304 .state
5305 .lock()
5306 .expect("not poisoned")
5307 .install_failures
5308 .remove(&self.mode)
5309 {
5310 return Err(ServiceError::Control {
5311 operation: "install",
5312 name: plan.identity().name().to_string(),
5313 manager: "recording control",
5314 detail,
5315 });
5316 }
5317 let definition = ServiceDefinition::for_host(plan)?;
5322 let mut state = self.state.lock().expect("not poisoned");
5323 state.registrations.insert(
5324 (self.mode, plan.identity().name().to_string()),
5325 Registration {
5326 manager: host_definition_kind(self.mode),
5327 start_mode: self.mode,
5328 command_line: plan.command_line(),
5329 account: Some(plan.account().as_str().to_string()),
5330 running: false,
5331 starts_automatically: true,
5332 restart_delay: Some(plan.restart().delay()),
5333 },
5334 );
5335 state
5336 .definitions
5337 .insert(plan.identity().name().to_string(), definition.clone());
5338 #[cfg(test)]
5339 let side_effect = state.after_install.remove(&self.mode);
5340 drop(state);
5341 #[cfg(test)]
5342 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5343 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5344 std::fs::write(&directory, b"blocks recreation")
5345 .expect("test fault can block record directory recreation");
5346 }
5347 Ok(definition)
5348 }
5349
5350 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5351 self.note("uninstall", identity.name());
5352 let mut state = self.state.lock().expect("not poisoned");
5353 state.definitions.remove(identity.name());
5354 Ok(state
5355 .registrations
5356 .remove(&(self.mode, identity.name().to_string()))
5357 .is_some())
5358 }
5359
5360 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5361 self.note("query", identity.name());
5362 Ok(self
5363 .state
5364 .lock()
5365 .expect("not poisoned")
5366 .registrations
5367 .get(&(self.mode, identity.name().to_string()))
5368 .cloned())
5369 }
5370
5371 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5372 self.note("start", identity.name());
5373 let mut state = self.state.lock().expect("not poisoned");
5374 match state
5375 .registrations
5376 .get_mut(&(self.mode, identity.name().to_string()))
5377 {
5378 Some(registration) => {
5379 registration.running = true;
5380 Ok(())
5381 }
5382 None => Err(ServiceError::NotInstalled {
5383 name: identity.name().to_string(),
5384 operation: "start",
5385 }),
5386 }
5387 }
5388
5389 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5390 self.note("stop", identity.name());
5391 let mut state = self.state.lock().expect("not poisoned");
5392 match state
5393 .registrations
5394 .get_mut(&(self.mode, identity.name().to_string()))
5395 {
5396 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5397 None => Err(ServiceError::NotInstalled {
5398 name: identity.name().to_string(),
5399 operation: "stop",
5400 }),
5401 }
5402 }
5403}
5404
5405impl ControlFactory for HostControls {
5406 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5407 sys::control(mode)
5408 }
5409
5410 fn installed_definition(&self, path: &Path) -> Option<String> {
5411 std::fs::read_to_string(path).ok()
5412 }
5413}
5414
5415fn host_home() -> Option<PathBuf> {
5422 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5423}
5424
5425#[cfg(unix)]
5427fn home_directory() -> Option<PathBuf> {
5428 host_home()
5429}
5430
5431fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5440 let output = std::process::Command::new(program)
5441 .args(arguments)
5442 .output()?;
5443 Ok((
5444 output.status.success(),
5445 String::from_utf8_lossy(&output.stdout).into_owned(),
5446 String::from_utf8_lossy(&output.stderr).into_owned(),
5447 ))
5448}
5449
5450#[must_use]
5453pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5454 if cfg!(windows) {
5455 match mode {
5456 StartMode::Boot => DefinitionKind::WindowsService,
5457 StartMode::Login => DefinitionKind::WindowsScheduledTask,
5458 }
5459 } else if cfg!(target_os = "macos") {
5460 DefinitionKind::LaunchdPlist
5461 } else {
5462 DefinitionKind::SystemdUnit
5463 }
5464}
5465
5466#[cfg(any(target_os = "macos", test))]
5473fn enable_launchd_registration(
5474 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5475 domain: &str,
5476 service_target: &str,
5477 plist: &Path,
5478 name: &str,
5479 elevation_remedy: &'static str,
5480) -> Result<(), ServiceError> {
5481 let (enabled, cause) = launchctl(&[
5482 std::ffi::OsStr::new("enable"),
5483 std::ffi::OsStr::new(service_target),
5484 ]);
5485 if enabled {
5486 return Ok(());
5487 }
5488
5489 let (booted_out, bootout_detail) = launchctl(&[
5490 std::ffi::OsStr::new("bootout"),
5491 std::ffi::OsStr::new(service_target),
5492 ]);
5493 let removed = std::fs::remove_file(plist);
5494 if !booted_out || removed.is_err() {
5495 return Err(ServiceError::Rollback {
5496 operation: "enable launchd registration",
5497 name: name.to_string(),
5498 cause,
5499 rollback: format!(
5500 "launchctl bootout {domain}: {}; remove {}: {}",
5501 if booted_out {
5502 "succeeded".to_string()
5503 } else {
5504 bootout_detail
5505 },
5506 plist.display(),
5507 removed
5508 .err()
5509 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5510 ),
5511 });
5512 }
5513
5514 if cause.to_ascii_lowercase().contains("permission denied") {
5515 Err(ServiceError::NeedsElevation {
5516 operation: "enable",
5517 name: name.to_string(),
5518 detail: cause,
5519 remedy: elevation_remedy,
5520 })
5521 } else {
5522 Err(ServiceError::Control {
5523 operation: "enable",
5524 name: name.to_string(),
5525 manager: "launchd",
5526 detail: cause,
5527 })
5528 }
5529}
5530
5531#[derive(Debug, Clone)]
5540pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5541
5542impl ServiceShutdown {
5543 pub async fn wait(mut self) {
5545 if !*self.0.borrow() {
5546 let _ = self.0.changed().await;
5547 }
5548 }
5549}
5550
5551#[cfg(windows)]
5558pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5559where
5560 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5561{
5562 windows_host::run(Box::new(run))
5563}
5564
5565#[cfg(windows)]
5566mod windows_host {
5567 use std::ffi::OsString;
5568 use std::sync::{Arc, Mutex, OnceLock, mpsc};
5569 use std::time::Duration;
5570
5571 use windows_service::service::{
5572 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5573 ServiceType,
5574 };
5575 use windows_service::service_control_handler::{
5576 self, ServiceControlHandlerResult, ServiceStatusHandle,
5577 };
5578
5579 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5580
5581 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5582
5583 struct Invocation {
5584 run: Runner,
5585 result: mpsc::SyncSender<Result<u8, String>>,
5586 }
5587
5588 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5589
5590 windows_service::define_windows_service!(ffi_service_main, service_main);
5591
5592 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5593 let (result_tx, result_rx) = mpsc::sync_channel(1);
5594 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5595 let mut invocation = slot
5596 .lock()
5597 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5598 if invocation.is_some() {
5599 return Err(host_error(
5600 "prepare",
5601 "the service-host slot was already used",
5602 ));
5603 }
5604 *invocation = Some(Invocation {
5605 run,
5606 result: result_tx,
5607 });
5608 drop(invocation);
5609
5610 windows_service::service_dispatcher::start("", ffi_service_main)
5611 .map_err(|error| host_error("connect", &error.to_string()))?;
5612 result_rx
5613 .recv()
5614 .map_err(|error| host_error("finish", &error.to_string()))?
5615 .map_err(|detail| host_error("run", &detail))
5616 }
5617
5618 fn service_main(_arguments: Vec<OsString>) {
5619 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5620 return;
5621 };
5622 let result = run_service(invocation.run);
5623 let _ = invocation.result.send(result);
5624 }
5625
5626 fn run_service(run: Runner) -> Result<u8, String> {
5627 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5628 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5629 let handler_status = Arc::clone(&status);
5630 let handler = move |control| match control {
5631 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5632 ServiceControl::Stop | ServiceControl::Shutdown => {
5633 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5634 let _ = handle.set_service_status(service_status(
5635 ServiceState::StopPending,
5636 ServiceControlAccept::empty(),
5637 1,
5638 Duration::from_secs(300),
5639 0,
5640 ));
5641 }
5642 let _ = stop_tx.send(true);
5643 ServiceControlHandlerResult::NoError
5644 }
5645 _ => ServiceControlHandlerResult::NotImplemented,
5646 };
5647 let handle = service_control_handler::register("", handler)
5648 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5649 *status
5650 .lock()
5651 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5652 handle
5653 .set_service_status(service_status(
5654 ServiceState::Running,
5655 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5656 0,
5657 Duration::default(),
5658 0,
5659 ))
5660 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5661
5662 let exit = run(ServiceShutdown(stop_rx));
5663 handle
5664 .set_service_status(service_status(
5665 ServiceState::Stopped,
5666 ServiceControlAccept::empty(),
5667 0,
5668 Duration::default(),
5669 u32::from(exit),
5670 ))
5671 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5672 Ok(exit)
5673 }
5674
5675 fn service_status(
5676 state: ServiceState,
5677 accepted: ServiceControlAccept,
5678 checkpoint: u32,
5679 wait_hint: Duration,
5680 exit: u32,
5681 ) -> ServiceStatus {
5682 ServiceStatus {
5683 service_type: ServiceType::OWN_PROCESS,
5684 current_state: state,
5685 controls_accepted: accepted,
5686 exit_code: if exit == 0 {
5687 ServiceExitCode::Win32(0)
5688 } else {
5689 ServiceExitCode::ServiceSpecific(exit)
5690 },
5691 checkpoint,
5692 wait_hint,
5693 process_id: None,
5694 }
5695 }
5696
5697 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5698 ServiceError::Control {
5699 operation,
5700 name: SERVICE_NAME.to_string(),
5701 manager: "the Windows Service Control Manager",
5702 detail: detail.to_string(),
5703 }
5704 }
5705}
5706
5707#[cfg(windows)]
5708mod sys {
5709 use std::ffi::{OsStr, OsString};
5719 use std::time::{Duration, Instant};
5720
5721 use runner_manager_domain::model::StartMode;
5722 use windows_service::service::{
5723 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5724 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5725 ServiceState, ServiceType,
5726 };
5727 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5728
5729 use super::{
5730 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5731 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5732 };
5733
5734 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5737 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5741 const ACCESS_DENIED: i32 = 5;
5743 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5744 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5745
5746 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5747 choose \"Run as administrator\".";
5748
5749 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5750 Ok(match mode {
5751 StartMode::Boot => Box::new(ScmControl),
5752 StartMode::Login => Box::new(TaskControl),
5753 })
5754 }
5755
5756 #[derive(Debug)]
5759 struct ScmControl;
5760
5761 fn scm_error(
5765 operation: &'static str,
5766 name: &str,
5767 error: &windows_service::Error,
5768 ) -> ServiceError {
5769 let raw = match error {
5770 windows_service::Error::Winapi(io) => io.raw_os_error(),
5771 _ => None,
5772 };
5773 let detail = match error {
5774 windows_service::Error::Winapi(io) => io.to_string(),
5775 other => other.to_string(),
5776 };
5777 if raw == Some(ACCESS_DENIED) {
5778 return ServiceError::NeedsElevation {
5779 operation,
5780 name: name.to_string(),
5781 detail,
5782 remedy: ELEVATION_REMEDY,
5783 };
5784 }
5785 ServiceError::Control {
5786 operation,
5787 name: name.to_string(),
5788 manager: "the Windows Service Control Manager",
5789 detail,
5790 }
5791 }
5792
5793 fn open_manager(
5794 access: ServiceManagerAccess,
5795 operation: &'static str,
5796 name: &str,
5797 ) -> Result<ServiceManager, ServiceError> {
5798 ServiceManager::local_computer(None::<&OsStr>, access)
5799 .map_err(|error| scm_error(operation, name, &error))
5800 }
5801
5802 impl ServiceControl for ScmControl {
5803 fn manager(&self) -> DefinitionKind {
5804 DefinitionKind::WindowsService
5805 }
5806
5807 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5808 let spec = windows_service_spec(plan);
5809 let manager = open_manager(
5810 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5811 "install",
5812 &spec.name,
5813 )?;
5814 let info = ServiceInfo {
5815 name: OsString::from(&spec.name),
5816 display_name: OsString::from(&spec.display_name),
5817 service_type: ServiceType::OWN_PROCESS,
5819 start_type: if spec.automatic_start {
5820 ServiceStartType::AutoStart
5821 } else {
5822 ServiceStartType::OnDemand
5823 },
5824 error_control: ServiceErrorControl::Normal,
5825 executable_path: plan.binary().to_path_buf(),
5826 launch_arguments: plan.arguments().to_vec(),
5827 dependencies: Vec::new(),
5828 account_name: spec.account.as_ref().map(OsString::from),
5830 account_password: None,
5833 };
5834 let service = manager
5835 .create_service(
5836 &info,
5837 ServiceAccess::CHANGE_CONFIG
5838 | ServiceAccess::QUERY_CONFIG
5839 | ServiceAccess::QUERY_STATUS
5840 | ServiceAccess::START
5841 | ServiceAccess::STOP
5842 | ServiceAccess::DELETE,
5843 )
5844 .map_err(|error| scm_error("install", &spec.name, &error))?;
5845 service
5846 .set_description(&spec.description)
5847 .map_err(|error| scm_error("describe", &spec.name, &error))?;
5848 service
5849 .update_failure_actions(ServiceFailureActions {
5850 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5851 reboot_msg: None,
5852 command: None,
5853 actions: Some(vec![
5860 ServiceAction {
5861 action_type: ServiceActionType::Restart,
5862 delay: spec.restart.delay(),
5863 };
5864 3
5865 ]),
5866 })
5867 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5868 service
5871 .set_failure_actions_on_non_crash_failures(true)
5872 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5873 Ok(ServiceDefinition::windows_service(plan))
5874 }
5875
5876 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5877 let manager =
5878 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
5879 let service = match manager.open_service(
5880 identity.name(),
5881 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
5882 ) {
5883 Ok(service) => Some(service),
5884 Err(error) if is_missing(&error) => return Ok(false),
5885 Err(error) if is_marked_for_delete(&error) => None,
5890 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
5891 };
5892 if let Some(service) = service {
5893 if let Ok(status) = service.query_status()
5897 && status.current_state != ServiceState::Stopped
5898 {
5899 let _ = service.stop();
5900 }
5901 match service.delete() {
5902 Ok(()) => {}
5903 Err(error) if is_marked_for_delete(&error) => {}
5907 Err(error) => {
5908 return Err(scm_error("uninstall", identity.name(), &error));
5909 }
5910 }
5911 drop(service);
5912 }
5913
5914 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
5921 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
5922 Ok(service) => {
5923 drop(service);
5924 Ok(false)
5925 }
5926 Err(error) if is_missing(&error) => Ok(true),
5927 Err(error) if is_marked_for_delete(&error) => Ok(false),
5928 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
5929 }
5930 })?;
5931 if !absent {
5932 return Err(ServiceError::Control {
5933 operation: "verify uninstall of",
5934 name: identity.name().to_string(),
5935 manager: "the Windows Service Control Manager",
5936 detail: format!(
5937 "the registration was still visible {} seconds after DeleteService; \
5938 retry `service uninstall` from an elevated prompt",
5939 DELETE_TIMEOUT.as_secs()
5940 ),
5941 });
5942 }
5943 Ok(true)
5944 }
5945
5946 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5947 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
5948 let service = match manager.open_service(
5949 identity.name(),
5950 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
5951 ) {
5952 Ok(service) => service,
5953 Err(error) if is_missing(&error) => return Ok(None),
5954 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
5955 };
5956 let config = service
5957 .query_config()
5958 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5959 let status = service
5960 .query_status()
5961 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5962 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
5963 actions
5964 .actions
5965 .and_then(|actions| actions.into_iter().next())
5966 .filter(|action| action.action_type == ServiceActionType::Restart)
5967 .map(|action| action.delay)
5968 });
5969 Ok(Some(Registration {
5970 manager: DefinitionKind::WindowsService,
5971 start_mode: StartMode::Boot,
5972 command_line: config.executable_path.to_string_lossy().into_owned(),
5976 account: config
5977 .account_name
5978 .map(|account| account.to_string_lossy().into_owned()),
5979 running: status.current_state == ServiceState::Running,
5980 starts_automatically: config.start_type == ServiceStartType::AutoStart,
5981 restart_delay,
5982 }))
5983 }
5984
5985 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5986 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
5987 let service = manager
5988 .open_service(identity.name(), ServiceAccess::START)
5989 .map_err(|error| scm_error("start", identity.name(), &error))?;
5990 service
5991 .start::<&OsStr>(&[])
5992 .map_err(|error| scm_error("start", identity.name(), &error))
5993 }
5994
5995 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5996 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
5997 let service = manager
5998 .open_service(
5999 identity.name(),
6000 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
6001 )
6002 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6003 let status = service
6004 .query_status()
6005 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6006 if status.current_state == ServiceState::Stopped {
6007 return Ok(false);
6008 }
6009 service
6010 .stop()
6011 .map_err(|error| scm_error("stop", identity.name(), &error))?;
6012 Ok(true)
6013 }
6014 }
6015
6016 fn is_missing(error: &windows_service::Error) -> bool {
6017 matches!(error, windows_service::Error::Winapi(io)
6018 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
6019 }
6020
6021 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
6022 matches!(error, windows_service::Error::Winapi(io)
6023 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
6024 }
6025
6026 pub(super) fn wait_until_scm_absent(
6027 timeout: Duration,
6028 poll_interval: Duration,
6029 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
6030 ) -> Result<bool, ServiceError> {
6031 let deadline = Instant::now() + timeout;
6032 loop {
6033 if probe_absent()? {
6034 return Ok(true);
6035 }
6036 if Instant::now() >= deadline {
6037 return Ok(false);
6038 }
6039 std::thread::sleep(poll_interval);
6040 }
6041 }
6042
6043 #[derive(Debug)]
6046 struct TaskControl;
6047
6048 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
6049 if detail.to_ascii_lowercase().contains("access is denied") {
6050 return ServiceError::NeedsElevation {
6051 operation,
6052 name: name.to_string(),
6053 detail,
6054 remedy: ELEVATION_REMEDY,
6055 };
6056 }
6057 ServiceError::Control {
6058 operation,
6059 name: name.to_string(),
6060 manager: "Windows Task Scheduler",
6061 detail,
6062 }
6063 }
6064
6065 fn schtasks(
6066 operation: &'static str,
6067 name: &str,
6068 arguments: &[&OsStr],
6069 ) -> Result<(bool, String), ServiceError> {
6070 match run("schtasks.exe", arguments) {
6071 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
6072 Err(error) => Err(task_error(operation, name, error.to_string())),
6073 }
6074 }
6075
6076 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
6079 let mut bytes = vec![0xFF, 0xFE];
6080 for unit in text.encode_utf16() {
6081 bytes.extend_from_slice(&unit.to_le_bytes());
6082 }
6083 std::fs::write(path, bytes)
6084 }
6085
6086 impl ServiceControl for TaskControl {
6087 fn manager(&self) -> DefinitionKind {
6088 DefinitionKind::WindowsScheduledTask
6089 }
6090
6091 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6092 let name = plan.identity().name().to_string();
6093 let principal = TaskPrincipal::current()?;
6094 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
6095 let directory = tempfile::tempdir()
6096 .map_err(|error| task_error("install", &name, error.to_string()))?;
6097 let document = directory.path().join("task.xml");
6098 write_utf16(&document, definition.text())
6099 .map_err(|error| task_error("install", &name, error.to_string()))?;
6100 let (ok, message) = schtasks(
6101 "install",
6102 &name,
6103 &[
6104 OsStr::new("/Create"),
6105 OsStr::new("/TN"),
6106 OsStr::new(&name),
6107 OsStr::new("/XML"),
6108 document.as_os_str(),
6109 ],
6110 )?;
6111 if !ok {
6112 return Err(task_error("install", &name, message));
6113 }
6114 Ok(definition)
6115 }
6116
6117 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6118 if self.query(identity)?.is_none() {
6119 return Ok(false);
6120 }
6121 let name = identity.name().to_string();
6122 let (ok, message) = schtasks(
6123 "uninstall",
6124 &name,
6125 &[
6126 OsStr::new("/Delete"),
6127 OsStr::new("/TN"),
6128 OsStr::new(&name),
6129 OsStr::new("/F"),
6130 ],
6131 )?;
6132 if !ok {
6133 return Err(task_error("uninstall", &name, message));
6134 }
6135 Ok(true)
6136 }
6137
6138 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6139 let name = identity.name().to_string();
6140 let (ok, document) = schtasks(
6141 "inspect",
6142 &name,
6143 &[
6144 OsStr::new("/Query"),
6145 OsStr::new("/TN"),
6146 OsStr::new(&name),
6147 OsStr::new("/XML"),
6148 OsStr::new("ONE"),
6149 ],
6150 )?;
6151 if !ok {
6152 return Ok(None);
6158 }
6159 let command = xml_value(&document, "Command").unwrap_or_default();
6160 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6161 let command_line = if arguments.is_empty() {
6162 super::quote_argument(&command)
6163 } else {
6164 format!("{} {arguments}", super::quote_argument(&command))
6165 };
6166 Ok(Some(Registration {
6167 manager: DefinitionKind::WindowsScheduledTask,
6168 start_mode: StartMode::Login,
6169 command_line,
6170 account: xml_value(&document, "UserId"),
6171 running: task_is_running(&name),
6172 starts_automatically: super::windows_login_task_starts_automatically(&document),
6173 restart_delay: xml_value(&document, "Interval")
6174 .as_deref()
6175 .and_then(parse_iso8601),
6176 }))
6177 }
6178
6179 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6180 let name = identity.name().to_string();
6181 let (ok, message) = schtasks(
6182 "start",
6183 &name,
6184 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6185 )?;
6186 if ok {
6187 Ok(())
6188 } else {
6189 Err(task_error("start", &name, message))
6190 }
6191 }
6192
6193 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6194 let running = self
6195 .query(identity)?
6196 .is_some_and(|registration| registration.running);
6197 if !running {
6198 return Ok(false);
6199 }
6200 let name = identity.name().to_string();
6201 let (ok, message) = schtasks(
6202 "stop",
6203 &name,
6204 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6205 )?;
6206 if ok {
6207 Ok(true)
6208 } else {
6209 Err(task_error("stop", &name, message))
6210 }
6211 }
6212 }
6213
6214 fn task_is_running(name: &str) -> bool {
6228 let Ok((true, stdout, _)) = run(
6229 "schtasks.exe",
6230 &[
6231 OsStr::new("/Query"),
6232 OsStr::new("/TN"),
6233 OsStr::new(name),
6234 OsStr::new("/FO"),
6235 OsStr::new("CSV"),
6236 OsStr::new("/NH"),
6237 ],
6238 ) else {
6239 return false;
6240 };
6241 stdout
6242 .lines()
6243 .filter_map(|line| line.rsplit(',').next())
6244 .any(|status| {
6245 status
6246 .trim()
6247 .trim_matches('"')
6248 .eq_ignore_ascii_case("running")
6249 })
6250 }
6251
6252 fn parse_iso8601(value: &str) -> Option<Duration> {
6256 let rest = value.strip_prefix("PT")?;
6257 if let Some(minutes) = rest.strip_suffix('M') {
6258 return minutes
6259 .parse::<u64>()
6260 .ok()
6261 .map(|minutes| Duration::from_secs(minutes * 60));
6262 }
6263 rest.strip_suffix('S')?
6264 .parse::<u64>()
6265 .ok()
6266 .map(Duration::from_secs)
6267 }
6268}
6269
6270#[cfg(unix)]
6282fn write_definition(
6283 operation: &'static str,
6284 name: &str,
6285 path: &Path,
6286 text: &str,
6287 remedy: &'static str,
6288) -> Result<(), ServiceError> {
6289 if let Some(parent) = path.parent()
6290 && let Err(error) = std::fs::create_dir_all(parent)
6291 && error.kind() != std::io::ErrorKind::AlreadyExists
6292 {
6293 return Err(definition_error(operation, name, error, remedy, parent));
6294 }
6295 std::fs::write(path, text)
6296 .map_err(|error| definition_error(operation, name, error, remedy, path))
6297}
6298
6299#[cfg(all(unix, any(target_os = "macos", test)))]
6306fn replace_definition(name: &str, drift: &DefinitionDrift) -> Result<(), ServiceError> {
6307 use std::io::Write as _;
6308
6309 let path = drift.path.as_path();
6310 let directory = path.parent().unwrap_or_else(|| Path::new("/"));
6311 let failed = |error: std::io::Error, at: &Path| {
6312 definition_error("rewrite", name, error, SUDO_REMEDY, at)
6313 };
6314 let mut staged =
6315 tempfile::NamedTempFile::new_in(directory).map_err(|error| failed(error, directory))?;
6316 staged
6317 .write_all(drift.rendered.as_bytes())
6318 .and_then(|()| staged.as_file().sync_all())
6319 .map_err(|error| failed(error, staged.path()))?;
6320 std::fs::set_permissions(
6321 staged.path(),
6322 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
6323 )
6324 .map_err(|error| failed(error, path))?;
6325 staged
6326 .persist(path)
6327 .map(drop)
6328 .map_err(|error| failed(error.error, path))
6329}
6330
6331#[cfg(unix)]
6332fn definition_error(
6333 operation: &'static str,
6334 name: &str,
6335 error: std::io::Error,
6336 remedy: &'static str,
6337 path: &Path,
6338) -> ServiceError {
6339 let detail = format!("{}: {error}", path.display());
6340 if error.kind() == std::io::ErrorKind::PermissionDenied {
6341 ServiceError::NeedsElevation {
6342 operation,
6343 name: name.to_string(),
6344 detail,
6345 remedy,
6346 }
6347 } else {
6348 ServiceError::Control {
6349 operation,
6350 name: name.to_string(),
6351 manager: "the local service manager",
6352 detail,
6353 }
6354 }
6355}
6356
6357#[cfg(unix)]
6359const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6360 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6361 agent not running until you sign in.";
6362
6363#[cfg(target_os = "macos")]
6368mod sys {
6369 use std::ffi::OsStr;
6382 use std::path::PathBuf;
6383 use std::time::Duration;
6384
6385 use runner_manager_domain::model::StartMode;
6386
6387 use super::{
6388 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6389 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6390 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6391 write_definition, xml_value,
6392 };
6393
6394 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6395 Ok(Box::new(LaunchdControl { mode }))
6396 }
6397
6398 #[derive(Debug)]
6399 struct LaunchdControl {
6400 mode: StartMode,
6401 }
6402
6403 impl LaunchdControl {
6404 fn domain(&self) -> String {
6406 match self.mode {
6407 StartMode::Boot => "system".to_string(),
6408 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6411 }
6412 }
6413
6414 fn service_target(&self, identity: &ServiceIdentity) -> String {
6415 format!("{}/{}", self.domain(), identity.launchd_label())
6416 }
6417
6418 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6421 let file = format!("{}.plist", identity.launchd_label());
6422 match self.mode {
6423 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6424 StartMode::Login => {
6425 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6426 }
6427 }
6428 }
6429
6430 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6431 ServiceError::Control {
6432 operation,
6433 name: name.to_string(),
6434 manager: "launchd",
6435 detail,
6436 }
6437 }
6438
6439 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6440 match run("launchctl", arguments) {
6441 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6442 Err(error) => (false, error.to_string()),
6443 }
6444 }
6445 }
6446
6447 impl ServiceControl for LaunchdControl {
6448 fn manager(&self) -> DefinitionKind {
6449 DefinitionKind::LaunchdPlist
6450 }
6451
6452 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6453 let name = plan.identity().name().to_string();
6454 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6455 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6456 return Err(self.failed(
6457 "install",
6458 &name,
6459 "this account has no home directory, so there is nowhere to put a \
6460 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6461 /Library/LaunchDaemons."
6462 .to_string(),
6463 ));
6464 };
6465 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6466 let target = self.domain();
6467 let (ok, message) = self.launchctl(&[
6468 OsStr::new("bootstrap"),
6469 OsStr::new(&target),
6470 path.as_os_str(),
6471 ]);
6472 if !ok {
6473 let _ = std::fs::remove_file(&path);
6477 if message.to_ascii_lowercase().contains("permission denied") {
6478 return Err(ServiceError::NeedsElevation {
6479 operation: "install",
6480 name,
6481 detail: message,
6482 remedy: SUDO_REMEDY,
6483 });
6484 }
6485 return Err(self.failed("install", &name, message));
6486 }
6487 let service_target = self.service_target(plan.identity());
6490 enable_launchd_registration(
6491 |arguments| self.launchctl(arguments),
6492 &target,
6493 &service_target,
6494 &path,
6495 &name,
6496 SUDO_REMEDY,
6497 )?;
6498 Ok(definition)
6499 }
6500
6501 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6502 let Some(path) = self.plist_path(identity) else {
6503 return Ok(false);
6504 };
6505 if !path.exists() {
6506 return Ok(false);
6507 }
6508 let target = self.service_target(identity);
6509 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6510 if !ok
6511 && !message.to_ascii_lowercase().contains("no such process")
6512 && !message.contains("113")
6513 {
6514 if message.to_ascii_lowercase().contains("permission denied") {
6515 return Err(ServiceError::NeedsElevation {
6516 operation: "uninstall",
6517 name: identity.name().to_string(),
6518 detail: message,
6519 remedy: SUDO_REMEDY,
6520 });
6521 }
6522 return Err(self.failed("uninstall", identity.name(), message));
6523 }
6524 std::fs::remove_file(&path).map_err(|error| {
6527 super::definition_error(
6528 "uninstall",
6529 identity.name(),
6530 error,
6531 SUDO_REMEDY,
6532 path.as_path(),
6533 )
6534 })?;
6535 Ok(true)
6536 }
6537
6538 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6539 let Some(path) = self.plist_path(identity) else {
6540 return Ok(None);
6541 };
6542 let Ok(document) = std::fs::read_to_string(&path) else {
6543 return Ok(None);
6544 };
6545 let target = self.service_target(identity);
6546 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6547 Ok(Some(Registration {
6548 manager: DefinitionKind::LaunchdPlist,
6549 start_mode: self.mode,
6550 command_line: program_arguments(&document),
6551 account: plist_string_value(&document, "UserName")
6552 .or_else(|| Some("the invoking user".to_string())),
6553 running: loaded && printed.contains("state = running"),
6554 starts_automatically: document.contains("<key>RunAtLoad</key>")
6555 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6556 restart_delay: xml_value(
6557 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6558 "integer",
6559 )
6560 .and_then(|value| value.parse::<u64>().ok())
6561 .map(Duration::from_secs),
6562 }))
6563 }
6564
6565 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6566 let target = self.service_target(identity);
6567 let (ok, message) = self.launchctl(&[
6568 OsStr::new("kickstart"),
6569 OsStr::new("-k"),
6570 OsStr::new(&target),
6571 ]);
6572 if ok {
6573 Ok(())
6574 } else {
6575 Err(self.failed("start", identity.name(), message))
6576 }
6577 }
6578
6579 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6580 let running = self
6581 .query(identity)?
6582 .is_some_and(|registration| registration.running);
6583 if !running {
6584 return Ok(false);
6585 }
6586 let target = self.service_target(identity);
6587 let (ok, message) = self.launchctl(&[
6588 OsStr::new("kill"),
6589 OsStr::new("SIGTERM"),
6590 OsStr::new(&target),
6591 ]);
6592 if ok {
6593 Ok(true)
6594 } else {
6595 Err(self.failed("stop", identity.name(), message))
6596 }
6597 }
6598 }
6599
6600 pub(super) fn reload_detached(
6615 identity: &ServiceIdentity,
6616 mode: StartMode,
6617 plist: &std::path::Path,
6618 ) -> Result<(), ServiceError> {
6619 use std::os::unix::process::CommandExt;
6620
6621 const SCRIPT: &str = r#"(
6622 sleep 1
6623 /bin/launchctl bootout "$1"
6624 tries=0
6625 until /bin/launchctl bootstrap "$2" "$3"; do
6626 tries=$((tries + 1))
6627 if [ "$tries" -ge 60 ]; then
6628 echo "runner-manager: launchd did not load $3 again; run: $4" >&2
6629 exit 1
6630 fi
6631 sleep 1
6632 done
6633) </dev/null &"#;
6634
6635 let control = LaunchdControl { mode };
6636 let reinstall = match mode {
6639 StartMode::Boot => "sudo runner-manager service install --start-at boot",
6640 StartMode::Login => "runner-manager service install --start-at login",
6641 };
6642 let mut command = std::process::Command::new("/bin/sh");
6643 command
6644 .args(["-c", SCRIPT, "runner-manager-reload"])
6645 .arg(control.service_target(identity))
6646 .arg(control.domain())
6647 .arg(plist)
6648 .arg(reinstall)
6649 .stdin(std::process::Stdio::null());
6650 unsafe {
6653 command.pre_exec(|| {
6654 if libc::setsid() == -1 {
6655 return Err(std::io::Error::last_os_error());
6656 }
6657 Ok(())
6658 });
6659 }
6660 let status = command.status().map_err(|error| {
6661 control.failed("reload", identity.name(), format!("/bin/sh: {error}"))
6662 })?;
6663 if status.success() {
6664 Ok(())
6665 } else {
6666 Err(control.failed(
6667 "reload",
6668 identity.name(),
6669 format!("the reload helper exited with {status}"),
6670 ))
6671 }
6672 }
6673
6674 fn program_arguments(document: &str) -> String {
6676 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6677 return String::new();
6678 };
6679 let Some(end) = rest.find("</array>") else {
6680 return String::new();
6681 };
6682 let mut out = Vec::new();
6683 let mut cursor = &rest[..end];
6684 while let Some(open) = cursor.find("<string>") {
6685 let after = &cursor[open + "<string>".len()..];
6686 let Some(close) = after.find("</string>") else {
6687 break;
6688 };
6689 out.push(quote_argument(&super::xml_unescape(&after[..close])));
6690 cursor = &after[close..];
6691 }
6692 out.join(" ")
6693 }
6694}
6695
6696#[cfg(all(unix, not(target_os = "macos")))]
6701mod sys {
6702 use std::ffi::OsStr;
6712 use std::path::PathBuf;
6713 use std::time::Duration;
6714
6715 use runner_manager_domain::model::StartMode;
6716
6717 use super::{
6718 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6719 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6720 home_directory, ini_directives, run, write_definition,
6721 };
6722
6723 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6724 Ok(Box::new(SystemdControl { mode }))
6725 }
6726
6727 #[derive(Debug)]
6728 struct SystemdControl {
6729 mode: StartMode,
6730 }
6731
6732 impl SystemdControl {
6733 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6734 let file = identity.systemd_unit();
6735 match self.mode {
6736 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6737 StartMode::Login => {
6738 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6739 }
6740 }
6741 }
6742
6743 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6745 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6746 if self.mode == StartMode::Login {
6747 all.push(OsStr::new("--user"));
6748 }
6749 all.extend(arguments.iter().map(OsStr::new));
6750 match run("systemctl", &all) {
6751 Ok((ok, stdout, stderr)) => (
6752 ok,
6753 if stdout.trim().is_empty() {
6754 stderr
6755 } else {
6756 stdout
6757 },
6758 ),
6759 Err(error) => (false, error.to_string()),
6760 }
6761 }
6762
6763 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6764 ServiceError::Control {
6765 operation,
6766 name: name.to_string(),
6767 manager: "systemd",
6768 detail,
6769 }
6770 }
6771 }
6772
6773 impl ServiceControl for SystemdControl {
6774 fn manager(&self) -> DefinitionKind {
6775 DefinitionKind::SystemdUnit
6776 }
6777
6778 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6779 let name = plan.identity().name().to_string();
6780 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6781 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6782 return Err(self.failed(
6783 "install",
6784 &name,
6785 "this account has no home directory, so there is nowhere to put a systemd \
6786 user unit. Use --start-at boot, which installs a system unit under \
6787 /etc/systemd/system."
6788 .to_string(),
6789 ));
6790 };
6791 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6792 let unit = plan.identity().systemd_unit();
6793 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6794 if !reloaded {
6795 let _ = std::fs::remove_file(&path);
6796 return Err(self.failed("install", &name, message));
6797 }
6798 let (enabled, message) = self.systemctl(&["enable", &unit]);
6799 if !enabled {
6800 let _ = std::fs::remove_file(&path);
6801 let _ = self.systemctl(&["daemon-reload"]);
6802 return Err(self.failed("install", &name, message));
6803 }
6804 Ok(definition)
6805 }
6806
6807 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6808 let Some(path) = self.unit_path(identity) else {
6809 return Ok(false);
6810 };
6811 if !path.exists() {
6812 return Ok(false);
6813 }
6814 let unit = identity.systemd_unit();
6815 let _ = self.systemctl(&["disable", "--now", &unit]);
6820 std::fs::remove_file(&path).map_err(|error| {
6821 super::definition_error(
6822 "uninstall",
6823 identity.name(),
6824 error,
6825 SUDO_REMEDY,
6826 path.as_path(),
6827 )
6828 })?;
6829 let _ = self.systemctl(&["daemon-reload"]);
6830 Ok(true)
6831 }
6832
6833 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6834 let Some(path) = self.unit_path(identity) else {
6835 return Ok(None);
6836 };
6837 let Ok(document) = std::fs::read_to_string(&path) else {
6838 return Ok(None);
6839 };
6840 let unit = identity.systemd_unit();
6841 let directives = ini_directives(&document, "Service");
6842 let (_, active) = self.systemctl(&["is-active", &unit]);
6843 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6844 Ok(Some(Registration {
6845 manager: DefinitionKind::SystemdUnit,
6846 start_mode: self.mode,
6847 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6848 account: directives.get("User").cloned().or_else(|| {
6849 Some(match self.mode {
6850 StartMode::Boot => "root".to_string(),
6851 StartMode::Login => "the invoking user".to_string(),
6852 })
6853 }),
6854 running: active.trim() == "active",
6855 starts_automatically: enabled.trim() == "enabled",
6856 restart_delay: directives
6857 .get("RestartSec")
6858 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6859 .map(Duration::from_secs),
6860 }))
6861 }
6862
6863 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6864 let unit = identity.systemd_unit();
6865 let (ok, message) = self.systemctl(&["start", &unit]);
6866 if ok {
6867 Ok(())
6868 } else {
6869 Err(self.failed("start", identity.name(), message))
6870 }
6871 }
6872
6873 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6874 let running = self
6875 .query(identity)?
6876 .is_some_and(|registration| registration.running);
6877 if !running {
6878 return Ok(false);
6879 }
6880 let unit = identity.systemd_unit();
6881 let (ok, message) = self.systemctl(&["stop", &unit]);
6882 if ok {
6883 Ok(true)
6884 } else {
6885 Err(self.failed("stop", identity.name(), message))
6886 }
6887 }
6888 }
6889}
6890
6891#[cfg(all(test, target_os = "macos"))]
6893pub(crate) mod launchd_fixture {
6894 use std::path::{Path, PathBuf};
6895
6896 const ACCEPTANCE: &str = "RUNNER_MANAGER_LAUNCHD_ACCEPTANCE";
6900
6901 pub(crate) fn enabled() -> bool {
6904 let enabled = std::env::var_os(ACCEPTANCE).is_some_and(|value| value == "1");
6905 if !enabled {
6906 eprintln!("skipped: set {ACCEPTANCE}=1 to load test launch agents");
6907 }
6908 enabled
6909 }
6910
6911 pub(crate) fn job_binary(directory: &Path) -> PathBuf {
6919 let binary = directory.join("launch-agent-job");
6920 std::fs::copy(std::env::current_exe().expect("test binary"), &binary)
6921 .expect("test binary copied");
6922 binary
6923 }
6924
6925 pub(crate) struct LoadedAgent(String);
6927
6928 impl LoadedAgent {
6929 pub(crate) fn bootstrap(label: &str, plist: &Path) -> Self {
6932 let domain = format!("gui/{}", unsafe { libc::getuid() });
6935 let loaded = Self(format!("{domain}/{label}"));
6937 let bootstrap = std::process::Command::new("launchctl")
6938 .arg("bootstrap")
6939 .arg(&domain)
6940 .arg(plist)
6941 .output()
6942 .expect("launchctl runs");
6943 assert!(
6944 bootstrap.status.success(),
6945 "launchctl bootstrap {domain} refused {label}, so nothing was measured: {}",
6946 String::from_utf8_lossy(&bootstrap.stderr)
6947 );
6948 loaded
6949 }
6950
6951 pub(crate) fn target(&self) -> &str {
6953 &self.0
6954 }
6955 }
6956
6957 impl Drop for LoadedAgent {
6958 fn drop(&mut self) {
6959 let _ = std::process::Command::new("launchctl")
6960 .args(["bootout", &self.0])
6961 .output();
6962 }
6963 }
6964}
6965
6966#[cfg(test)]
6967mod tests {
6968 use super::*;
6969
6970 use std::collections::BTreeMap;
6971
6972 #[test]
6973 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
6974 assert!(windows_login_task_starts_automatically(
6975 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
6976 ));
6977 assert!(windows_login_task_starts_automatically(
6978 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
6979 ));
6980 assert!(!windows_login_task_starts_automatically(
6981 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
6982 ));
6983 assert!(!windows_login_task_starts_automatically(
6984 "<Task><BootTrigger></BootTrigger></Task>"
6985 ));
6986 }
6987
6988 fn linux_plan(mode: StartMode) -> InstallPlan {
6995 InstallPlan::unchecked(
6996 ServiceIdentity::product(),
6997 mode,
6998 "/opt/runner-manager/bin/runner-manager",
6999 ServiceDirectories {
7000 config: PathBuf::from("/var/lib/runner-manager/config"),
7001 state: PathBuf::from("/var/lib/runner-manager/state"),
7002 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
7003 logs: PathBuf::from("/var/log/runner-manager"),
7004 },
7005 )
7006 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
7007 }
7008
7009 fn windows_plan(mode: StartMode) -> InstallPlan {
7010 InstallPlan::unchecked(
7011 ServiceIdentity::product(),
7012 mode,
7013 "C:\\Program Files\\runner-manager\\runner-manager.exe",
7014 ServiceDirectories {
7015 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
7016 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
7017 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
7018 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
7019 },
7020 )
7021 }
7022
7023 fn edited(text: &str, from: &str, to: &str) -> String {
7031 assert!(
7032 text.contains(from),
7033 "the rendered definition does not contain `{from}`, so this test would assert \
7034 nothing about a widened one"
7035 );
7036 text.replace(from, to)
7037 }
7038
7039 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
7041 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
7042 let Ok(entries) = std::fs::read_dir(directory) else {
7043 return;
7044 };
7045 for entry in entries.flatten() {
7046 let path = entry.path();
7047 if path.is_dir() {
7048 walk(&path, out);
7049 } else if let Ok(bytes) = std::fs::read(&path) {
7050 out.insert(path, bytes);
7051 }
7052 }
7053 }
7054 let mut out = BTreeMap::new();
7055 for root in roots {
7056 walk(root, &mut out);
7057 }
7058 out
7059 }
7060
7061 struct Host {
7062 _root: tempfile::TempDir,
7063 paths: AppPaths,
7064 binary: PathBuf,
7065 runner_root: LocalAbsolutePath,
7074 controls: RecordingControls,
7075 }
7076
7077 impl Host {
7078 fn new() -> Self {
7079 let root = tempfile::tempdir().expect("a temporary directory");
7080 let paths = AppPaths::rooted_at(root.path());
7081 paths.create_all().expect("the four directories");
7082 let binary = root.path().join(if cfg!(windows) {
7083 "runner-manager.exe"
7084 } else {
7085 "runner-manager"
7086 });
7087 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
7088 let runner_root = LocalAbsolutePath::new(
7089 root.path()
7090 .join("runner-root")
7091 .to_str()
7092 .expect("a unicode temporary path"),
7093 )
7094 .expect("a local absolute path");
7095 Self {
7096 _root: root,
7097 paths,
7098 binary,
7099 runner_root,
7100 controls: RecordingControls::new(),
7101 }
7102 }
7103
7104 fn operations(&self) -> ServiceOperations {
7105 ServiceOperations::with_controls(
7106 self.paths.clone(),
7107 ServiceIdentity::product(),
7108 std::sync::Arc::new(self.controls.clone()),
7109 )
7110 .with_runner_root(self.runner_root.clone())
7111 }
7112
7113 fn request(&self, mode: StartMode) -> InstallRequest {
7114 InstallRequest::new(mode).for_binary(&self.binary)
7115 }
7116 }
7117
7118 #[cfg(windows)]
7119 #[test]
7120 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
7121 let probes = std::cell::Cell::new(0);
7122 let absent =
7123 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
7124 let next = probes.get() + 1;
7125 probes.set(next);
7126 Ok(next == 3)
7127 })
7128 .expect("the simulated SCM probe succeeds");
7129
7130 assert!(absent);
7131 assert_eq!(
7132 probes.get(),
7133 3,
7134 "uninstall must recheck after transient presence instead of treating it as a leak"
7135 );
7136 }
7137
7138 #[cfg(windows)]
7139 #[test]
7140 fn windows_recognises_an_already_pending_service_deletion() {
7141 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
7142
7143 assert!(super::sys::is_marked_for_delete(&error));
7144 }
7145
7146 #[test]
7147 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
7148 let root = tempfile::tempdir().expect("a temporary directory");
7149 let plist = root.path().join("fixture.plist");
7150 std::fs::write(&plist, b"fixture").expect("a plist fixture");
7151 let calls = std::cell::RefCell::new(Vec::new());
7152
7153 let error = enable_launchd_registration(
7154 |arguments| {
7155 let call = arguments
7156 .iter()
7157 .map(|argument| argument.to_string_lossy().into_owned())
7158 .collect::<Vec<_>>();
7159 let operation = call[0].clone();
7160 calls.borrow_mut().push(call);
7161 if operation == "enable" {
7162 (false, "label remains disabled".to_string())
7163 } else {
7164 (true, String::new())
7165 }
7166 },
7167 "system",
7168 "system/com.openai.runner-manager-selftest",
7169 &plist,
7170 "runner-manager-selftest",
7171 "rerun with administrative rights",
7172 )
7173 .expect_err("enable failure must fail the install");
7174
7175 assert!(
7176 matches!(
7177 error,
7178 ServiceError::Control {
7179 operation: "enable",
7180 ..
7181 }
7182 ),
7183 "{error}"
7184 );
7185 assert_eq!(calls.borrow().len(), 2);
7186 assert_eq!(calls.borrow()[0][0], "enable");
7187 assert_eq!(calls.borrow()[1][0], "bootout");
7188 assert!(!plist.exists(), "rollback must remove the plist");
7189 }
7190
7191 #[test]
7196 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
7197 let plan = windows_plan(StartMode::Boot);
7198 let command_line = plan.command_line();
7199 assert!(
7200 command_line.starts_with('"'),
7201 "a path with a space must be quoted, got {command_line}"
7202 );
7203 assert_eq!(
7204 executable_from_command_line(&command_line).as_deref(),
7205 Some(plan.binary())
7206 );
7207 }
7208
7209 #[test]
7210 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
7211 let plan = linux_plan(StartMode::Boot);
7212 let command_line = plan.command_line();
7213 assert!(!command_line.starts_with('"'), "got {command_line}");
7214 assert_eq!(
7215 executable_from_command_line(&command_line).as_deref(),
7216 Some(plan.binary())
7217 );
7218 }
7219
7220 #[test]
7221 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
7222 let awkward = r#"C:\odd "name"\rm.exe"#;
7225 let quoted = quote_argument(awkward);
7226 assert_eq!(
7227 executable_from_command_line(&format!("{quoted} daemon run"))
7228 .as_deref()
7229 .map(Path::to_string_lossy)
7230 .as_deref(),
7231 Some(awkward)
7232 );
7233 }
7234
7235 #[test]
7236 fn an_empty_command_line_has_no_executable() {
7237 assert_eq!(executable_from_command_line(" "), None);
7238 assert_eq!(executable_from_command_line(""), None);
7239 }
7240
7241 #[test]
7242 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
7243 let awkward = r#"DOMAIN\R&D <team> "ops""#;
7244 assert_eq!(
7245 xml_escape(awkward),
7246 "DOMAIN\\R&D <team> "ops""
7247 );
7248 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
7249 }
7250
7251 #[test]
7256 fn a_restart_delay_under_the_floor_is_refused() {
7257 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
7258 .expect_err("half a second is under the one-second floor");
7259 assert!(
7260 matches!(error, ServiceError::RestartDelay { .. }),
7261 "{error}"
7262 );
7263 }
7264
7265 #[test]
7266 fn a_restart_delay_over_the_ceiling_is_refused() {
7267 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
7268 .expect_err("an hour is over the five-minute ceiling");
7269 assert!(
7270 matches!(error, ServiceError::RestartDelay { .. }),
7271 "{error}"
7272 );
7273 }
7274
7275 #[test]
7276 fn a_reset_window_no_longer_than_the_delay_is_refused() {
7277 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
7278 .expect_err("a window equal to the delay can never elapse between restarts");
7279 assert!(
7280 matches!(error, ServiceError::RestartResetWindow { .. }),
7281 "{error}"
7282 );
7283 }
7284
7285 #[test]
7286 fn a_delay_inside_the_bound_is_accepted() {
7287 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
7288 .expect("twenty seconds is inside the bound");
7289 assert_eq!(policy.delay(), Duration::from_secs(20));
7290 assert_eq!(policy.reset_after(), Duration::from_secs(300));
7291 }
7292
7293 #[test]
7298 fn a_fixture_identity_can_never_be_the_product_identity() {
7299 let fixture = ServiceIdentity::fixture("abc123");
7300 assert!(fixture.is_fixture());
7301 assert!(!ServiceIdentity::product().is_fixture());
7302 assert_ne!(fixture.name(), ServiceIdentity::product().name());
7303 assert_ne!(
7304 fixture.launchd_label(),
7305 ServiceIdentity::product().launchd_label()
7306 );
7307 assert_ne!(
7308 fixture.systemd_unit(),
7309 ServiceIdentity::product().systemd_unit()
7310 );
7311 }
7312
7313 #[test]
7314 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
7315 let fixture = ServiceIdentity::fixture("A b/c:\\d");
7316 assert!(
7317 fixture
7318 .name()
7319 .chars()
7320 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7321 "got {}",
7322 fixture.name()
7323 );
7324 }
7325
7326 #[test]
7331 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7332 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7333 assert!(unit.contains("KillMode=process\n"), "{unit}");
7334 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7335 assert!(unit.contains("RestartSec=15\n"), "{unit}");
7336 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7337 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7338 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7339 }
7340
7341 #[test]
7342 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7343 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7344 assert!(
7345 !unit.contains("LoadCredential="),
7346 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7347 );
7348 }
7349
7350 #[test]
7351 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7352 let unit = systemd_unit(&linux_plan(StartMode::Login));
7353 assert!(
7354 !unit.contains("LoadCredential="),
7355 "a user unit must not name a root-owned credential file, got:\n{unit}"
7356 );
7357 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7358 }
7359
7360 #[test]
7361 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7362 let plan = linux_plan(StartMode::Boot);
7363 let unit = systemd_unit(&plan);
7364 let directives = ini_directives(&unit, "Service");
7365 let listed = split_quoted(
7366 directives
7367 .get("ReadWritePaths")
7368 .expect("the unit names its writable paths"),
7369 );
7370 assert_eq!(listed.len(), 5, "{listed:?}");
7371 for path in plan.directories().all() {
7372 assert!(
7373 listed.iter().any(|entry| entry == &path.to_string_lossy()),
7374 "{} is missing from {listed:?}",
7375 path.display()
7376 );
7377 }
7378 assert!(
7379 listed
7380 .iter()
7381 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7382 "atomic credential replacement needs its parent directory in {listed:?}"
7383 );
7384 }
7385
7386 #[test]
7387 fn the_unit_records_the_absolute_binary_path() {
7388 let plan = linux_plan(StartMode::Boot);
7389 let unit = systemd_unit(&plan);
7390 assert!(
7391 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7392 "{unit}"
7393 );
7394 }
7395
7396 #[test]
7401 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7402 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7403 assert!(
7406 plist.contains(
7407 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
7408 ),
7409 "{plist}"
7410 );
7411 assert!(
7412 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
7413 "{plist}"
7414 );
7415 }
7416
7417 #[test]
7418 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7419 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7420 assert_eq!(
7421 plist_string_value(&daemon, "UserName").as_deref(),
7422 Some("root")
7423 );
7424 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7425
7426 let agent = launchd_plist(&linux_plan(StartMode::Login));
7427 assert_eq!(
7428 plist_string_value(&agent, "UserName"),
7429 None,
7430 "a LaunchAgent already runs as the operator:\n{agent}"
7431 );
7432 }
7433
7434 #[test]
7435 fn the_plist_runs_the_daemon_and_its_runners_as_an_interactive_job() {
7436 for mode in [StartMode::Boot, StartMode::Login] {
7437 let plan = linux_plan(mode);
7438 let plist = launchd_plist(&plan);
7439 assert_eq!(
7440 plist_string_value(&plist, "ProcessType").as_deref(),
7441 Some("Interactive"),
7442 "launchd applies ProcessType to every runner the daemon starts:\n{plist}"
7443 );
7444 let review = review_least_privilege(
7445 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, plist),
7446 &plan,
7447 );
7448 assert!(
7449 review
7450 .controls()
7451 .iter()
7452 .any(|control| control == "ProcessType=Interactive"),
7453 "{review}"
7454 );
7455 assert!(
7456 !review
7457 .findings()
7458 .iter()
7459 .any(|finding| finding.subject == "ProcessType"),
7460 "{review}"
7461 );
7462 }
7463 }
7464
7465 #[test]
7469 fn a_plist_that_throttles_its_runners_is_a_shortfall() {
7470 let plan = linux_plan(StartMode::Login);
7471 let rendered = launchd_plist(&plan);
7472 let interactive = "<key>ProcessType</key>\n <string>Interactive</string>\n";
7473 for (label, throttled) in [
7474 (
7475 "Background",
7476 edited(
7477 &rendered,
7478 interactive,
7479 "<key>ProcessType</key>\n <string>Background</string>\n",
7480 ),
7481 ),
7482 ("absent", edited(&rendered, interactive, "")),
7483 ] {
7484 let review = review_least_privilege(
7485 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, throttled),
7486 &plan,
7487 );
7488 assert!(review.is_least_privilege(), "{label}: {review}");
7489 assert!(
7490 review.findings().iter().any(|finding| {
7491 finding.kind == FindingKind::Shortfall
7492 && finding.subject == "ProcessType"
7493 && finding.detail.contains(label)
7494 }),
7495 "{label}: {review}"
7496 );
7497 }
7498 }
7499
7500 #[test]
7501 fn a_plist_with_an_old_process_type_is_drift_and_an_operator_edit_is_not() {
7502 let host = Host::new();
7503 let identity = ServiceIdentity::product();
7504 let plan = InstallPlan::resolve(
7505 identity.clone(),
7506 &host.request(StartMode::Login),
7507 ServiceDirectories::of(&host.paths),
7508 )
7509 .expect("a resolvable plan");
7510 let installed = launchd_plist(&plan);
7511 let definition =
7512 ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, installed.clone());
7513 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7514 record.installed_by_version = "0.4.31".into();
7515 let path = PathBuf::from("/Users/op/Library/LaunchAgents/runner-manager.plist");
7516
7517 assert_eq!(
7518 launchd_definition_drift(&identity, &record, path.clone(), &installed),
7519 None
7520 );
7521 let edited_by_operator = edited(
7522 &installed,
7523 "</dict>\n</plist>\n",
7524 " <key>Nice</key>\n <integer>0</integer>\n</dict>\n</plist>\n",
7525 );
7526 assert_eq!(
7527 launchd_definition_drift(&identity, &record, path.clone(), &edited_by_operator),
7528 None,
7529 "the daemon repairs this drift itself, so it must not key on edits it did not cause"
7530 );
7531
7532 let written_by_0_4_31 = edited(
7533 &installed,
7534 "<string>Interactive</string>",
7535 "<string>Background</string>",
7536 );
7537 let drift = launchd_definition_drift(&identity, &record, path, &written_by_0_4_31)
7538 .expect("a Background plist is drift");
7539 assert_eq!(drift.manager, DefinitionKind::LaunchdPlist);
7540 assert_eq!(drift.start_mode, StartMode::Login);
7541 assert_eq!(
7542 drift.rendered, installed,
7543 "the repair must write exactly what `service install` writes"
7544 );
7545
7546 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7547 .with_definition_drift(Some(drift));
7548 let problem = status
7549 .problems()
7550 .iter()
7551 .find(|problem| problem.subject == "definition")
7552 .expect("the drift is reported");
7553 assert!(
7556 problem.detail.contains("0.4.31")
7557 && problem.detail.contains(
7558 "launchctl kickstart -k gui/$(id -u)/io.github.IvanMurzak.runner-manager`"
7559 )
7560 && !problem.detail.contains("service install")
7561 && !problem.detail.contains("systemctl")
7562 && !problem.detail.contains("sudo"),
7563 "{}",
7564 problem.detail
7565 );
7566 }
7567
7568 #[cfg(unix)]
7569 #[test]
7570 fn a_replaced_definition_is_the_rendering_and_readable_by_launchd() {
7571 use std::os::unix::fs::PermissionsExt as _;
7572
7573 let root = tempfile::tempdir().expect("a temporary directory");
7574 let path = root
7575 .path()
7576 .join("io.github.IvanMurzak.runner-manager.plist");
7577 std::fs::write(&path, "<string>Background</string>").expect("old plist");
7578 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
7579 let drift = DefinitionDrift {
7580 path: path.clone(),
7581 installed_by_version: "0.4.31".into(),
7582 rendered: launchd_plist(&linux_plan(StartMode::Login)),
7583 start_mode: StartMode::Login,
7584 manager: DefinitionKind::LaunchdPlist,
7585 };
7586
7587 replace_definition("runner-manager", &drift).expect("replaced");
7588
7589 assert_eq!(std::fs::read_to_string(&path).unwrap(), drift.rendered);
7590 assert_eq!(
7591 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
7592 0o644
7593 );
7594 assert_eq!(
7595 std::fs::read_dir(root.path()).unwrap().count(),
7596 1,
7597 "no staging file is left beside the plist"
7598 );
7599 }
7600
7601 #[cfg(target_os = "macos")]
7610 #[test]
7611 #[ignore = "run only by launchd, as the job of the detached-reload test"]
7612 fn detached_reload_job() {
7613 let label = std::env::var("XPC_SERVICE_NAME").unwrap_or_default();
7614 let tag = label
7615 .split_once("-selftest-")
7616 .map(|(_, tag)| tag.to_string())
7617 .unwrap_or_else(|| panic!("run only by launchd for the reload test, not {label:?}"));
7618 let identity = ServiceIdentity::fixture(&tag);
7619 assert_eq!(identity.launchd_label(), label);
7620 let path = std::env::current_dir()
7621 .expect("working directory")
7622 .join(format!("{label}.plist"));
7623 let installed = std::fs::read_to_string(&path).expect("own plist");
7624 if plist_string_value(&installed, "ProcessType").as_deref() != Some(LAUNCHD_PROCESS_TYPE) {
7625 let drift = DefinitionDrift {
7626 path: path.clone(),
7627 installed_by_version: "0.4.31".into(),
7628 rendered: installed.replace(
7629 "<string>Background</string>",
7630 &format!("<string>{LAUNCHD_PROCESS_TYPE}</string>"),
7631 ),
7632 start_mode: StartMode::Login,
7633 manager: DefinitionKind::LaunchdPlist,
7634 };
7635 replace_definition(identity.name(), &drift).expect("plist replaced");
7636 super::sys::reload_detached(&identity, StartMode::Login, &path)
7637 .expect("helper started");
7638 }
7639 std::thread::sleep(Duration::from_secs(120));
7640 }
7641
7642 #[cfg(target_os = "macos")]
7652 #[test]
7653 fn a_job_reloads_itself_from_its_rewritten_plist() {
7654 if !super::launchd_fixture::enabled() {
7655 return;
7656 }
7657 let root = tempfile::tempdir().expect("a temporary directory");
7658 let identity = ServiceIdentity::fixture(&format!("reload-{}", std::process::id()));
7659 let label = identity.launchd_label();
7660 let path = root.path().join(format!("{label}.plist"));
7661 let binary = super::launchd_fixture::job_binary(root.path());
7662 let mut plist = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
7663 plist.push_str("<plist version=\"1.0\">\n<dict>\n");
7664 plist.push_str(&plist_string("Label", &label));
7665 plist.push_str(" <key>ProgramArguments</key>\n <array>\n");
7666 for argument in [
7667 binary.to_string_lossy().as_ref(),
7668 "--ignored",
7669 "--exact",
7670 "service::tests::detached_reload_job",
7671 "--test-threads=1",
7672 ] {
7673 plist.push_str(&format!(" <string>{}</string>\n", xml_escape(argument)));
7674 }
7675 plist.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
7676 plist.push_str(&plist_string(
7677 "WorkingDirectory",
7678 &root.path().to_string_lossy(),
7679 ));
7680 plist.push_str(&plist_string("ProcessType", "Background"));
7681 plist.push_str("</dict>\n</plist>\n");
7682 std::fs::write(&path, plist).expect("plist written");
7683
7684 let loaded = super::launchd_fixture::LoadedAgent::bootstrap(&label, &path);
7685 let spawn_type = || {
7686 let printed = std::process::Command::new("launchctl")
7687 .args(["print", loaded.target()])
7688 .output()
7689 .expect("launchctl runs");
7690 String::from_utf8_lossy(&printed.stdout)
7691 .lines()
7692 .find_map(|line| {
7693 line.trim()
7694 .strip_prefix("spawn type = ")
7695 .map(str::to_string)
7696 })
7697 };
7698 assert_eq!(spawn_type().as_deref(), Some("background (5)"));
7699
7700 let deadline = std::time::Instant::now() + Duration::from_secs(90);
7701 while spawn_type().as_deref() != Some("interactive (4)") {
7702 assert!(
7703 std::time::Instant::now() < deadline,
7704 "the job did not come back under its rewritten plist; launchd reports {:?}",
7705 spawn_type()
7706 );
7707 std::thread::sleep(Duration::from_millis(250));
7708 }
7709 let rewritten = std::fs::read_to_string(&path).expect("plist readable");
7710 assert_eq!(
7711 plist_string_value(&rewritten, "ProcessType").as_deref(),
7712 Some(LAUNCHD_PROCESS_TYPE)
7713 );
7714 }
7715
7716 #[test]
7717 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
7718 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7719 assert!(
7720 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
7721 "{plist}"
7722 );
7723 assert!(plist.contains("<string>daemon</string>"), "{plist}");
7724 assert!(plist.contains("<string>run</string>"), "{plist}");
7725 }
7726
7727 #[test]
7728 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
7729 assert_eq!(
7730 ServiceIdentity::product().launchd_label(),
7731 "io.github.IvanMurzak.runner-manager"
7732 );
7733 }
7734
7735 #[test]
7740 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
7741 let xml = windows_scheduled_task_xml(
7742 &windows_plan(StartMode::Login),
7743 &TaskPrincipal::named("HOST\\operator"),
7744 );
7745 assert!(xml.contains("<LogonTrigger>"), "{xml}");
7746 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
7747 assert!(
7748 xml.contains("<LogonType>InteractiveToken</LogonType>"),
7749 "{xml}"
7750 );
7751 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
7752 assert!(
7753 xml.contains("<Interval>PT1M</Interval>"),
7754 "Task Scheduler takes whole minutes only, and rejects the registration outright \
7755 for anything finer:\n{xml}"
7756 );
7757 }
7758
7759 #[test]
7760 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
7761 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
7766 let policy =
7767 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
7768 .expect("inside the supported range");
7769 assert_eq!(
7770 policy
7771 .effective_delay(DefinitionKind::WindowsScheduledTask)
7772 .as_secs(),
7773 enforced,
7774 "a {asked}s delay must be enforced as {enforced}s"
7775 );
7776 assert!(
7777 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
7778 "rounding must never shorten the bound"
7779 );
7780 }
7781 }
7782
7783 #[test]
7784 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
7785 let policy = RestartPolicy::default();
7786 for kind in [
7787 DefinitionKind::WindowsService,
7788 DefinitionKind::LaunchdPlist,
7789 DefinitionKind::SystemdUnit,
7790 ] {
7791 assert_eq!(
7792 policy.effective_delay(kind),
7793 policy.delay(),
7794 "{kind:?} takes seconds and enforces exactly what it is given"
7795 );
7796 }
7797 }
7798
7799 #[test]
7800 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
7801 let host = Host::new();
7802 let operations = host.operations();
7803 operations
7804 .install(&host.request(StartMode::Login))
7805 .expect("an install at login");
7806 host.controls.edit("runner-manager", |registration| {
7808 registration.manager = DefinitionKind::WindowsScheduledTask;
7809 registration.restart_delay = Some(Duration::from_secs(60));
7810 });
7811
7812 let status = operations.status().expect("a status");
7813 assert!(
7814 status.is_healthy(),
7815 "minute granularity is the manager's, not a mis-registration: {status}"
7816 );
7817 assert!(
7818 status
7819 .notes()
7820 .iter()
7821 .any(|note| note.contains("whole minutes")),
7822 "but the operator must be told why 15 became 60: {status}"
7823 );
7824
7825 host.controls.edit("runner-manager", |registration| {
7828 registration.restart_delay = Some(Duration::from_secs(1));
7829 });
7830 assert!(
7831 !operations.status().expect("a status").is_healthy(),
7832 "a one-second delay is not what any manager was asked for"
7833 );
7834 }
7835
7836 #[test]
7837 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
7838 let plan = windows_plan(StartMode::Login);
7839 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
7840 assert_eq!(
7841 xml_value(&xml, "Command").as_deref(),
7842 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
7843 "{xml}"
7844 );
7845 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
7846 }
7847
7848 #[test]
7849 fn an_account_name_holding_xml_punctuation_is_escaped() {
7850 let xml = windows_scheduled_task_xml(
7851 &windows_plan(StartMode::Login),
7852 &TaskPrincipal::named("R&D\\ops"),
7853 );
7854 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
7855 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
7856 }
7857
7858 #[test]
7863 fn the_service_starts_automatically_under_the_account_the_store_admits() {
7864 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
7865 let directives = ini_directives(&text, "windows-service");
7866 assert_eq!(
7867 directives.get("StartType").map(String::as_str),
7868 Some("AutoStart")
7869 );
7870 assert_eq!(
7871 directives.get("Account").map(String::as_str),
7872 Some("NT AUTHORITY\\SYSTEM")
7873 );
7874 assert_eq!(
7875 directives.get("ServiceType").map(String::as_str),
7876 Some("OWN_PROCESS")
7877 );
7878 assert_eq!(
7879 directives
7880 .get("FailureActionRestartDelaySecs")
7881 .map(String::as_str),
7882 Some("15")
7883 );
7884 assert_eq!(
7885 directives
7886 .get("FailureActionsOnNonCrashFailures")
7887 .map(String::as_str),
7888 Some("true"),
7889 "without this flag a non-zero exit is not a failure the manager restarts"
7890 );
7891 }
7892
7893 #[test]
7894 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
7895 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
7896 assert_eq!(spec.account, None);
7897 assert!(spec.automatic_start);
7898 assert!(
7899 spec.command_line.contains("daemon run"),
7900 "{}",
7901 spec.command_line
7902 );
7903 }
7904
7905 #[test]
7910 fn each_definition_goes_where_its_platform_expects_it() {
7911 let home = PathBuf::from("/home/op");
7912 assert_eq!(
7913 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
7914 Some(Path::new(
7915 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
7916 ))
7917 );
7918 assert_eq!(
7919 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
7920 Some(Path::new(
7921 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
7922 ))
7923 );
7924 assert_eq!(
7925 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
7926 Some(Path::new("/etc/systemd/system/runner-manager.service"))
7927 );
7928 assert_eq!(
7929 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
7930 Some(Path::new(
7931 "/home/op/.config/systemd/user/runner-manager.service"
7932 ))
7933 );
7934 assert_eq!(
7935 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
7936 None,
7937 "the Service Control Manager has no file"
7938 );
7939 }
7940
7941 #[test]
7942 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
7943 assert_eq!(
7944 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
7945 None
7946 );
7947 assert_eq!(
7948 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
7949 None
7950 );
7951 }
7952
7953 #[test]
7958 fn the_rendered_definitions_are_all_least_privilege() {
7959 let linux = linux_plan(StartMode::Boot);
7960 let windows = windows_plan(StartMode::Boot);
7961 for (definition, plan) in [
7962 (ServiceDefinition::systemd(&linux, None), &linux),
7963 (ServiceDefinition::launchd(&linux, None), &linux),
7964 (ServiceDefinition::windows_service(&windows), &windows),
7965 ] {
7966 let review = review_least_privilege(&definition, plan);
7967 assert!(
7968 review.is_least_privilege(),
7969 "{:?} should be least privilege, got:\n{review}",
7970 definition.kind()
7971 );
7972 assert!(
7973 !review.controls().is_empty(),
7974 "a review that confirms nothing proves nothing: {:?}",
7975 definition.kind()
7976 );
7977 }
7978 }
7979
7980 #[test]
7981 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
7982 let plan = windows_plan(StartMode::Login);
7983 let definition =
7984 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
7985 let review = review_least_privilege(&definition, &plan);
7986 assert!(review.is_least_privilege(), "{review}");
7987 assert!(
7988 review
7989 .controls()
7990 .iter()
7991 .any(|control| control.contains("LeastPrivilege")),
7992 "{review}"
7993 );
7994 }
7995
7996 #[test]
7997 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
7998 let plan = linux_plan(StartMode::Boot);
7999 let rendered = systemd_unit(&plan);
8000 let widened = edited(
8001 &rendered,
8002 "ReadWritePaths=/var/lib/runner-manager/config",
8003 "ReadWritePaths=/etc /var/lib/runner-manager/config",
8004 );
8005 let review = review_least_privilege(
8006 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8007 &plan,
8008 );
8009 assert!(!review.is_least_privilege(), "{review}");
8010 assert!(
8011 review
8012 .excesses()
8013 .iter()
8014 .any(|finding| finding.detail.contains("/etc")),
8015 "the review must name the directory it objects to: {review}"
8016 );
8017 }
8018
8019 #[test]
8020 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
8021 let plan = linux_plan(StartMode::Boot);
8022 let rendered = systemd_unit(&plan);
8023 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
8024 let review = review_least_privilege(
8025 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
8026 &plan,
8027 );
8028 assert!(!review.is_least_privilege(), "{review}");
8029 assert!(
8030 review
8031 .excesses()
8032 .iter()
8033 .any(|finding| finding.subject == "NoNewPrivileges"),
8034 "{review}"
8035 );
8036 }
8037
8038 #[test]
8039 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
8040 let plan = linux_plan(StartMode::Boot);
8041 let rendered = systemd_unit(&plan);
8042 let widened = edited(
8043 &rendered,
8044 "CapabilityBoundingSet=\n",
8045 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
8046 );
8047 let review = review_least_privilege(
8048 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8049 &plan,
8050 );
8051 assert!(!review.is_least_privilege(), "{review}");
8052 assert!(
8053 review
8054 .excesses()
8055 .iter()
8056 .any(|finding| finding.subject == "CapabilityBoundingSet"),
8057 "{review}"
8058 );
8059 }
8060
8061 #[test]
8062 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
8063 let plan = linux_plan(StartMode::Boot);
8064 let rendered = systemd_unit(&plan);
8065 let widened = edited(
8066 &rendered,
8067 "[Install]",
8068 "ListenStream=127.0.0.1:9000\n\n[Install]",
8069 );
8070 let review = review_least_privilege(
8071 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
8072 &plan,
8073 );
8074 assert!(
8075 !review.is_least_privilege(),
8076 "07-security.md rule 2 forbids any inbound surface: {review}"
8077 );
8078 }
8079
8080 #[test]
8081 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
8082 let plan = linux_plan(StartMode::Boot);
8083 let rendered = systemd_unit(&plan);
8084 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
8085 let review = review_least_privilege(
8086 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
8087 &plan,
8088 );
8089 assert!(
8090 review.is_least_privilege(),
8091 "too little authority is not an excess: {review}"
8092 );
8093 assert!(
8094 review
8095 .findings()
8096 .iter()
8097 .any(|finding| finding.kind == FindingKind::Shortfall
8098 && finding.detail.contains("runtime")),
8099 "{review}"
8100 );
8101 }
8102
8103 #[test]
8104 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
8105 let plan = linux_plan(StartMode::Login);
8106 let rendered = launchd_plist(&plan);
8107 let widened = edited(
8108 &rendered,
8109 "<key>ProcessType</key>",
8110 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
8111 );
8112 let review = review_least_privilege(
8113 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8114 &plan,
8115 );
8116 assert!(!review.is_least_privilege(), "{review}");
8117 assert!(
8118 review
8119 .excesses()
8120 .iter()
8121 .any(|finding| finding.subject == "UserName"),
8122 "{review}"
8123 );
8124 }
8125
8126 #[test]
8127 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
8128 let plan = linux_plan(StartMode::Boot);
8129 let rendered = launchd_plist(&plan);
8130 let widened = edited(
8131 &rendered,
8132 "<key>SessionCreate</key>\n <false/>",
8133 "<key>SessionCreate</key>\n <true/>",
8134 );
8135 let review = review_least_privilege(
8136 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8137 &plan,
8138 );
8139 assert!(!review.is_least_privilege(), "{review}");
8140 assert!(
8141 review
8142 .excesses()
8143 .iter()
8144 .any(|finding| finding.subject == "SessionCreate"),
8145 "{review}"
8146 );
8147 }
8148
8149 #[test]
8150 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
8151 let plan = linux_plan(StartMode::Boot);
8152 let rendered = launchd_plist(&plan);
8153 let widened = edited(
8154 &rendered,
8155 "<key>ProcessType</key>",
8156 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
8157 );
8158 let review = review_least_privilege(
8159 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
8160 &plan,
8161 );
8162 assert!(!review.is_least_privilege(), "{review}");
8163 }
8164
8165 #[test]
8166 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
8167 let plan = windows_plan(StartMode::Login);
8168 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8169 let widened = edited(
8170 &rendered,
8171 "<RunLevel>LeastPrivilege</RunLevel>",
8172 "<RunLevel>HighestAvailable</RunLevel>",
8173 );
8174 let review = review_least_privilege(
8175 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8176 &plan,
8177 );
8178 assert!(!review.is_least_privilege(), "{review}");
8179 assert!(
8180 review
8181 .excesses()
8182 .iter()
8183 .any(|finding| finding.subject == "RunLevel"),
8184 "{review}"
8185 );
8186 }
8187
8188 #[test]
8189 fn a_task_that_would_store_a_password_is_not_least_privilege() {
8190 let plan = windows_plan(StartMode::Login);
8191 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
8192 let widened = edited(
8193 &rendered,
8194 "<LogonType>InteractiveToken</LogonType>",
8195 "<LogonType>Password</LogonType>",
8196 );
8197 let review = review_least_privilege(
8198 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
8199 &plan,
8200 );
8201 assert!(!review.is_least_privilege(), "{review}");
8202 }
8203
8204 #[test]
8205 fn an_interactive_windows_service_is_not_least_privilege() {
8206 let plan = windows_plan(StartMode::Boot);
8207 let rendered = windows_service_descriptor(&plan);
8208 let widened = edited(
8209 &rendered,
8210 "ServiceType=OWN_PROCESS",
8211 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
8212 );
8213 let review = review_least_privilege(
8214 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
8215 &plan,
8216 );
8217 assert!(!review.is_least_privilege(), "{review}");
8218 assert!(
8219 review
8220 .excesses()
8221 .iter()
8222 .any(|finding| finding.subject == "ServiceType"),
8223 "{review}"
8224 );
8225 }
8226
8227 #[test]
8228 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
8229 let plan = windows_plan(StartMode::Boot);
8230 let rendered = windows_service_descriptor(&plan);
8231 let changed = edited(
8232 &rendered,
8233 "Account=NT AUTHORITY\\SYSTEM",
8234 "Account=NT AUTHORITY\\LocalService",
8235 );
8236 let review = review_least_privilege(
8237 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
8238 &plan,
8239 );
8240 assert!(review.is_least_privilege(), "{review}");
8246 assert!(
8247 review.findings().iter().any(|finding| {
8248 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
8249 }),
8250 "{review}"
8251 );
8252 }
8253
8254 #[test]
8259 fn a_recorded_path_that_is_still_there_is_current() {
8260 let host = Host::new();
8261 let state = inspect_binary(&host.binary, Some(&host.binary));
8262 assert!(!state.is_error(), "{state}");
8263 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
8264 }
8265
8266 #[test]
8267 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
8268 let host = Host::new();
8269 let recorded = host.binary.clone();
8273 let healthy = inspect_binary(&recorded, Some(&recorded));
8274 assert!(
8275 !healthy.is_error(),
8276 "the discriminator: before the binary moves, this must be healthy"
8277 );
8278
8279 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
8280
8281 let state = inspect_binary(&recorded, Some(&recorded));
8282 assert!(state.is_error(), "{state}");
8283 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
8284 assert!(
8285 state.to_string().contains("npm"),
8286 "the message must name the cause an operator will not otherwise connect: {state}"
8287 );
8288 }
8289
8290 #[test]
8291 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
8292 let root = tempfile::tempdir().expect("a temporary directory");
8293 let state = inspect_binary(root.path(), None);
8294 assert!(state.is_error(), "{state}");
8295 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
8296 }
8297
8298 #[test]
8299 fn a_registration_naming_a_different_binary_is_a_divergence() {
8300 let host = Host::new();
8301 let other = host.binary.with_file_name("something-else");
8302 let state = inspect_binary(&host.binary, Some(&other));
8303 assert!(state.is_error(), "{state}");
8304 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
8305 }
8306
8307 #[test]
8308 fn absence_is_reported_before_divergence() {
8309 let host = Host::new();
8310 let recorded = host.binary.clone();
8311 std::fs::remove_file(&recorded).expect("removable");
8312 let other = recorded.with_file_name("something-else");
8313 assert!(matches!(
8317 inspect_binary(&recorded, Some(&other)),
8318 BinaryPath::Missing { .. }
8319 ));
8320 }
8321
8322 #[test]
8327 fn the_record_round_trips_through_toml() {
8328 let host = Host::new();
8329 let plan = InstallPlan::resolve(
8330 ServiceIdentity::product(),
8331 &host.request(StartMode::Boot),
8332 ServiceDirectories::of(&host.paths),
8333 )
8334 .expect("a resolvable plan");
8335 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8336 let record = InstallRecord::of(&plan, &definition, Utc::now());
8337 record.write(&host.paths).expect("a writable record");
8338 let read = InstallRecord::read(&host.paths)
8339 .expect("a readable record")
8340 .expect("a record is there");
8341 assert_eq!(read, record);
8342 assert_eq!(read.binary, host.binary);
8343 assert!(read.binary.is_absolute());
8344 }
8345
8346 #[test]
8347 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
8348 let host = Host::new();
8349 let identity = ServiceIdentity::product();
8350 let plan = InstallPlan::resolve(
8351 identity.clone(),
8352 &host.request(StartMode::Boot),
8353 ServiceDirectories::of(&host.paths),
8354 )
8355 .expect("a resolvable plan");
8356 let installed = systemd_unit(&plan);
8357 let definition =
8358 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
8359 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
8360 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
8361
8362 assert_eq!(
8363 systemd_definition_drift(&identity, &record, path.clone(), &installed),
8364 None,
8365 "the record must rebuild exactly the unit `service install` wrote"
8366 );
8367
8368 record.installed_by_version = "0.4.7".into();
8369 let frozen = installed.replace(
8370 "[Service]\n",
8371 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
8372 );
8373 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
8374 .expect("an outdated unit is drift");
8375 assert_eq!(drift.installed_by_version, "0.4.7");
8376 assert_eq!(drift.rendered, installed);
8377
8378 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
8379 .with_definition_drift(Some(drift));
8380 assert!(!status.is_healthy());
8381 assert!(
8382 status
8383 .problems()
8384 .iter()
8385 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
8386 "{status}"
8387 );
8388 }
8389
8390 #[test]
8391 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
8392 let host = Host::new();
8393 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8394 let first = Utc::now() - chrono::Duration::hours(9);
8395 record_github_credential_rejected(&host.paths, first).unwrap();
8396 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
8397 let since = github_credential_rejected_since(&host.paths).unwrap();
8398 assert_eq!(since, Some(first));
8399
8400 let status = ServiceStatus::compose(
8401 ServiceIdentity::product(),
8402 None,
8403 None,
8404 None,
8405 None,
8406 &host.paths,
8407 )
8408 .with_credential_rejection(since);
8409 assert!(!status.is_healthy());
8410 assert_eq!(status.credential_rejected_since(), Some(first));
8411 assert!(status.to_string().contains("auth login"), "{status}");
8412
8413 clear_github_credential_rejection(&host.paths).unwrap();
8414 clear_github_credential_rejection(&host.paths).unwrap();
8415 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
8416 }
8417
8418 #[cfg(unix)]
8427 #[test]
8428 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
8429 use std::os::unix::fs::PermissionsExt as _;
8430
8431 let host = Host::new();
8432 let plan = InstallPlan::resolve(
8433 ServiceIdentity::product(),
8434 &host.request(StartMode::Boot),
8435 ServiceDirectories::of(&host.paths),
8436 )
8437 .expect("a resolvable plan");
8438 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
8439 InstallRecord::of(&plan, &definition, Utc::now())
8440 .write(&host.paths)
8441 .expect("a writable record");
8442
8443 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
8444 .expect("the record is there")
8445 .permissions()
8446 .mode()
8447 & 0o777;
8448 assert_eq!(
8449 mode, 0o644,
8450 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
8451 service install` wrote, and `service status` fails on their own host. It holds no \
8452 credential and sits in a 0700 directory, so 0644 discloses nothing"
8453 );
8454 }
8455
8456 #[test]
8460 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
8461 let host = Host::new();
8462 let path = InstallRecord::path(&host.paths);
8463 std::fs::write(
8464 &path,
8465 format!(
8466 "schema_version = {RECORD_SCHEMA_VERSION}
8467service_name = \"runner-manager\"
8468 manager = \"systemd\"
8469start_mode = \"boot\"
8470account = \"root\"
8471 binary = \"/x\"
8472arguments = []
8473restart_delay_secs = 15
8474 restart_reset_secs = 600
8475log_file = \"/x\"
8476 installed_at = \"2026-01-01T00:00:00Z\"
8477installed_by_version = \"0.1.0\"
8478 [directories]
8479config = \"/a\"
8480state = \"/b\"
8481runtime = \"/c\"
8482logs = \"/d\"
8483"
8484 ),
8485 )
8486 .expect("a writable record");
8487 let read = InstallRecord::read(&host.paths)
8488 .expect("a record missing an optional field is still readable")
8489 .expect("a record is there");
8490 assert_eq!(
8491 read.source_binary, None,
8492 "the legacy layout has no source, and must not invent one"
8493 );
8494 }
8495
8496 #[test]
8498 fn a_registration_remembers_the_file_it_was_copied_from() {
8499 let host = Host::new();
8500 let source = host.binary.with_file_name("npm-installed-runner-manager");
8501 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
8502 let plan = InstallPlan::resolve(
8503 ServiceIdentity::product(),
8504 &host.request(StartMode::Boot).copied_from(&source),
8505 ServiceDirectories::of(&host.paths),
8506 )
8507 .expect("a resolvable plan");
8508 let definition = ServiceDefinition::from_text(
8509 DefinitionKind::SystemdUnit,
8510 "[Service]
8511",
8512 );
8513 let record = InstallRecord::of(&plan, &definition, Utc::now());
8514 record.write(&host.paths).expect("a writable record");
8515
8516 let read = InstallRecord::read(&host.paths)
8517 .expect("a readable record")
8518 .expect("a record is there");
8519 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
8520 assert_ne!(
8521 read.source_binary.as_deref(),
8522 Some(read.binary.as_path()),
8523 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
8524 );
8525 }
8526
8527 #[test]
8528 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
8529 let host = Host::new();
8530 let path = InstallRecord::path(&host.paths);
8531 std::fs::write(
8532 &path,
8533 format!(
8534 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
8535 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
8536 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
8537 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
8538 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
8539 RECORD_SCHEMA_VERSION + 1
8540 ),
8541 )
8542 .expect("a writable record");
8543 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
8544 assert!(
8545 matches!(error, ServiceError::RecordUnreadable { .. }),
8546 "{error}"
8547 );
8548 assert!(
8549 error.to_string().contains("service uninstall"),
8550 "the message must say how to recover: {error}"
8551 );
8552 }
8553
8554 #[test]
8555 fn no_record_is_not_an_error() {
8556 let host = Host::new();
8557 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
8558 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
8559 }
8560
8561 #[test]
8566 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
8567 let host = Host::new();
8568 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
8569 }
8570
8571 #[test]
8572 fn the_heartbeat_round_trips_to_the_second() {
8573 let host = Host::new();
8574 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
8575 .expect("a valid timestamp")
8576 .with_timezone(&Utc);
8577 record_github_contact(&host.paths, at).expect("a writable heartbeat");
8578 assert_eq!(
8579 last_github_contact(&host.paths).expect("readable"),
8580 Some(at)
8581 );
8582 }
8583
8584 #[test]
8585 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
8586 let host = Host::new();
8587 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
8588 let error = last_github_contact(&host.paths)
8589 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
8590 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8591 }
8592
8593 #[test]
8599 fn a_runner_root_refusal_round_trips_for_service_status() {
8600 let host = Host::new();
8601 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8602
8603 assert!(
8604 runner_root_refusals(&host.paths)
8605 .expect("readable")
8606 .is_empty(),
8607 "no record means every policy is placing runners"
8608 );
8609
8610 record_runner_root_refusal(
8611 &host.paths,
8612 "policy-a",
8613 at,
8614 "denied_by_privacy_policy",
8615 "/Volumes/NVME/runners",
8616 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
8617 )
8618 .expect("a writable record");
8619
8620 let refusals = runner_root_refusals(&host.paths).expect("readable");
8621 assert_eq!(refusals.len(), 1);
8622 assert_eq!(refusals[0].policy, "policy-a");
8623 assert_eq!(refusals[0].at, at);
8624 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
8625 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
8626 assert!(
8627 refusals[0].detail.contains("/Volumes/NVME/runners")
8628 && refusals[0].detail.contains("Full Disk Access"),
8629 "the path and the remediation are the whole point of this file: {refusals:?}"
8630 );
8631 }
8632
8633 #[test]
8641 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
8642 let host = Host::new();
8643 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8644 record_runner_root_refusal(
8645 &host.paths,
8646 "broken",
8647 at,
8648 "denied_by_privacy_policy",
8649 "/Volumes/NVME/runners",
8650 "detail",
8651 )
8652 .expect("a writable record");
8653 record_runner_root_refusal(
8654 &host.paths,
8655 "also-broken",
8656 at,
8657 "not_writable",
8658 "/srv/other",
8659 "detail",
8660 )
8661 .expect("a writable record");
8662
8663 clear_runner_root_refusal(&host.paths, "healthy")
8665 .expect("clearing an absent policy is fine");
8666 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
8667
8668 let refusals = runner_root_refusals(&host.paths).expect("readable");
8669 assert_eq!(
8670 refusals
8671 .iter()
8672 .map(|r| r.policy.as_str())
8673 .collect::<Vec<_>>(),
8674 vec!["broken"],
8675 "the policy that is still refused must keep its record"
8676 );
8677 }
8678
8679 #[test]
8682 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
8683 let host = Host::new();
8684 record_runner_root_refusal(
8685 &host.paths,
8686 "p",
8687 Utc::now(),
8688 "not_writable",
8689 "/srv/x",
8690 "detail",
8691 )
8692 .expect("a writable record");
8693
8694 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
8695 assert!(
8696 !root_refusal_path(&host.paths).exists(),
8697 "a host with nothing refused leaves nothing behind"
8698 );
8699 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
8700 }
8701
8702 #[test]
8705 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
8706 let host = Host::new();
8707 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
8708 let error = runner_root_refusals(&host.paths)
8709 .expect_err("an unparseable record is not the same as no record");
8710 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8711 }
8712
8713 #[test]
8723 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
8724 let host = Host::new();
8725 record_runner_root_refusal(
8726 &host.paths,
8727 "policy-a",
8728 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
8729 "denied_by_privacy_policy",
8730 "/Volumes/NVME/runners",
8731 "Grant Full Disk Access to the program that runs the service",
8732 )
8733 .expect("a writable record");
8734
8735 let status = host.operations().status().expect("a readable status");
8736 let notes = status.notes().join("\n");
8737
8738 assert!(
8739 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
8740 "the directory and the remediation the log had to scrub must appear here: {notes}"
8741 );
8742 assert!(
8743 notes.contains("policy-a"),
8744 "the operator has to know which target placed no runner: {notes}"
8745 );
8746 assert!(
8747 !status
8748 .problems()
8749 .iter()
8750 .any(|problem| problem.subject == "runner root"),
8751 "a record nothing an operator types can clear must not drive the exit code"
8752 );
8753 }
8754
8755 #[test]
8761 fn install_records_the_absolute_binary_path_and_the_four_directories() {
8762 let host = Host::new();
8763 let installed = host
8764 .operations()
8765 .install(&host.request(StartMode::Boot))
8766 .expect("an install against the recording controls");
8767
8768 assert_eq!(installed.record.binary, host.binary);
8769 assert!(installed.record.binary.is_absolute());
8770 assert_eq!(installed.record.start_mode, StartMode::Boot);
8771 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
8772 assert_eq!(
8773 installed.record.directories,
8774 ServiceDirectories::of(&host.paths)
8775 );
8776 assert_eq!(
8777 installed.record.log_file,
8778 host.paths.logs_dir().join(LOG_FILE_STEM)
8779 );
8780 assert_eq!(installed.record.restart_delay_secs, 15);
8781
8782 let registrations = host.controls.registrations();
8783 assert_eq!(registrations.len(), 1);
8784 assert_eq!(registrations[0].0, StartMode::Boot);
8785 assert_eq!(registrations[0].1, "runner-manager");
8786 }
8787
8788 #[test]
8789 fn install_is_refused_while_the_single_instance_lock_is_held() {
8790 let host = Host::new();
8791 {
8793 let operations = host.operations();
8794 operations
8795 .install(&host.request(StartMode::Boot))
8796 .expect("an install with the lock free");
8797 operations.uninstall().expect("a clean slate");
8798 }
8799
8800 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
8801 .expect("this process takes the lock first");
8802
8803 let error = host
8804 .operations()
8805 .install(&host.request(StartMode::Boot))
8806 .expect_err("a second agent must not be registered while one is running");
8807 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
8808 assert!(
8809 error.to_string().contains("already running"),
8810 "the message must be actionable: {error}"
8811 );
8812 assert!(
8813 host.controls.registrations().is_empty(),
8814 "a refused install must register nothing"
8815 );
8816 assert_eq!(
8817 InstallRecord::read(&host.paths).expect("readable"),
8818 None,
8819 "a refused install must write no record"
8820 );
8821 }
8822
8823 #[test]
8831 fn installing_over_the_same_start_mode_replaces_the_registration() {
8832 let host = Host::new();
8833 let operations = host.operations();
8834 operations
8835 .install(&host.request(StartMode::Boot))
8836 .expect("the first install");
8837
8838 let again = operations
8839 .install(&host.request(StartMode::Boot))
8840 .expect("an install over the same mode replaces rather than refusing");
8841
8842 assert!(
8843 again.replaced_existing,
8844 "the operator is told this replaced something rather than made it"
8845 );
8846 assert_eq!(
8847 host.controls.registrations().len(),
8848 1,
8849 "replacing must not leave two registrations behind"
8850 );
8851 assert_eq!(
8852 InstallRecord::read(&host.paths)
8853 .expect("readable")
8854 .expect("a record")
8855 .start_mode,
8856 StartMode::Boot
8857 );
8858 }
8859
8860 #[test]
8864 fn installing_over_the_other_start_mode_is_refused() {
8865 let host = Host::new();
8866 let operations = host.operations();
8867 operations
8868 .install(&host.request(StartMode::Boot))
8869 .expect("the first install");
8870
8871 let error = operations
8872 .install(&host.request(StartMode::Login))
8873 .expect_err("a mode change is not an install");
8874 assert!(
8875 matches!(
8876 error,
8877 ServiceError::AlreadyInstalled {
8878 existing: StartMode::Boot,
8879 requested: StartMode::Login,
8880 ..
8881 }
8882 ),
8883 "{error}"
8884 );
8885 assert!(
8886 !error
8887 .to_string()
8888 .contains("switch the start mode in place,"),
8889 "the old remedy named a capability no command offers; the terminal UI is where \
8890 the start mode moves: {error}"
8891 );
8892 assert_eq!(host.controls.registrations().len(), 1);
8893 }
8894
8895 #[test]
8896 fn install_rolls_back_the_registration_when_record_persistence_fails() {
8897 let host = Host::new();
8898 let record_path = InstallRecord::path(&host.paths);
8899 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
8900
8901 let error = host
8902 .operations()
8903 .install(&host.request(StartMode::Boot))
8904 .expect_err("record persistence must fail");
8905
8906 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8907 assert!(
8908 host.controls.registrations().is_empty(),
8909 "a failed install must not leave a live unrecorded registration"
8910 );
8911 assert!(
8912 host.controls
8913 .calls()
8914 .iter()
8915 .any(|call| call == "uninstall runner-manager (boot)"),
8916 "the registration must be explicitly rolled back: {:?}",
8917 host.controls.calls()
8918 );
8919 assert!(
8920 !host.runner_root.as_path().exists(),
8921 "the rollback must take the runner root this install created with it; a directory \
8922 prepared for a registration that does not exist is litter, and on Windows it is \
8923 litter with a security descriptor"
8924 );
8925 }
8926
8927 #[test]
8932 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
8933 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
8934
8935 assert_eq!(
8939 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
8940 ServiceAccount::LocalSystem
8941 );
8942 assert_eq!(
8943 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
8944 ServiceAccount::InvokingUser
8945 );
8946
8947 let boot = default_root_sddl(&RootAdmission::LocalSystem);
8948 assert!(!grants_broad_write(&boot), "{boot}");
8949 assert!(
8950 !boot.contains("S-1-5-21"),
8951 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
8952 );
8953
8954 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
8959 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
8960 assert!(!grants_broad_write(&login), "{login}");
8961 }
8962
8963 #[test]
8964 fn an_install_reports_the_runner_root_it_prepared() {
8965 let host = Host::new();
8966 let installed = host
8967 .operations()
8968 .install(&host.request(StartMode::Boot))
8969 .expect("an install");
8970 let rendered = installed.runner_root.to_string();
8971 assert!(
8972 !rendered.contains("S-1-5-21"),
8973 "the report must add no identity to the output: {rendered}"
8974 );
8975 if cfg!(windows) {
8976 assert_eq!(
8977 installed.runner_root.path(),
8978 Some(host.runner_root.as_path())
8979 );
8980 assert!(
8981 host.runner_root.as_path().is_dir(),
8982 "the directory jobs would run in has to exist once the service is registered"
8983 );
8984 } else {
8985 assert_eq!(
8986 installed.runner_root,
8987 crate::runner_root_access::RootAccessSummary::NotApplicable,
8988 "macOS and Linux keep the runtime directory they have always used"
8989 );
8990 }
8991 }
8992
8993 #[test]
8994 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
8995 let host = Host::new();
8996 let operations = host.operations();
8997 operations
8998 .install(&host.request(StartMode::Boot))
8999 .expect("an install at boot");
9000
9001 let change = operations
9002 .set_start_mode(StartMode::Login)
9003 .expect("a switch to login");
9004
9005 assert!(change.changed);
9006 if cfg!(windows) {
9007 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
9008 assert!(
9009 host.runner_root.as_path().is_dir(),
9010 "the switch must not remove the directory it reconciled"
9011 );
9012 }
9013 assert!(
9016 change.to_string().contains("runner root"),
9017 "{}",
9018 change.to_string()
9019 );
9020 }
9021
9022 #[test]
9023 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
9024 let host = Host::new();
9025 let operations = host.operations();
9026 operations
9027 .install(&host.request(StartMode::Boot))
9028 .expect("an install at boot");
9029
9030 let change = operations
9031 .set_start_mode(StartMode::Boot)
9032 .expect("a switch to the mode already in force");
9033
9034 assert!(!change.changed);
9035 assert_eq!(
9036 change.runner_root,
9037 crate::runner_root_access::RootAccessSummary::NotApplicable,
9038 "nothing moves, so nothing about the root's access control has to; reconciling here \
9039 would turn a no-op command into one that can fail on a permission it does not need"
9040 );
9041 }
9042
9043 #[cfg(windows)]
9044 #[test]
9045 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
9046 let host = Host::new();
9047 host.controls
9048 .fail_next_install(StartMode::Boot, "injected registration failure");
9049
9050 let error = host
9051 .operations()
9052 .install(&host.request(StartMode::Boot))
9053 .expect_err("the manager refuses the registration");
9054
9055 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9056 assert!(
9057 !host.runner_root.as_path().exists(),
9058 "the directory was created for a registration that does not exist"
9059 );
9060 }
9061
9062 #[cfg(windows)]
9063 #[test]
9064 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
9065 let host = Host::new();
9066 crate::runner_root_access::create_with_descriptor_for_tests(
9070 host.runner_root.as_path(),
9071 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
9072 )
9073 .expect("a deliberately open runner root");
9074 let before = crate::runner_root_access::report(host.runner_root.as_path());
9075
9076 let error = host
9077 .operations()
9078 .install(&host.request(StartMode::Boot))
9079 .expect_err("an open runner root is refused");
9080
9081 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
9082 assert!(
9083 error.to_string().contains("nothing was registered"),
9084 "{error}"
9085 );
9086 assert!(
9087 host.controls.registrations().is_empty(),
9088 "the refusal has to come before the platform is asked to register anything: {:?}",
9089 host.controls.calls()
9090 );
9091 assert_eq!(
9092 crate::runner_root_access::report(host.runner_root.as_path()),
9093 before,
9094 "an open directory is refused rather than tightened: its contents cannot be trusted, \
9095 so adopting it would be worse than declining it"
9096 );
9097 }
9098
9099 #[cfg(windows)]
9100 #[test]
9101 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
9102 let host = Host::new();
9103 let operations = host.operations();
9104 operations
9105 .install(&host.request(StartMode::Boot))
9106 .expect("an install");
9107 assert!(host.runner_root.as_path().is_dir());
9108
9109 operations.uninstall().expect("an uninstall");
9110
9111 assert!(
9112 host.runner_root.as_path().is_dir(),
9113 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
9114 runner root may hold an operator's retained workspaces."
9115 );
9116 }
9117
9118 #[test]
9119 fn install_reviews_what_it_registered() {
9120 let host = Host::new();
9121 let installed = host
9122 .operations()
9123 .install(&host.request(StartMode::Boot))
9124 .expect("an install");
9125 assert!(
9126 installed.review.is_least_privilege(),
9127 "{}",
9128 installed.review
9129 );
9130 assert!(
9131 !installed.review.controls().is_empty(),
9132 "a review that confirms nothing proves nothing: {}",
9133 installed.review
9134 );
9135 assert_eq!(
9136 installed.review.kind(),
9137 host_definition_kind(StartMode::Boot),
9138 "the review must be of the definition this host's manager was given"
9139 );
9140 assert!(
9141 !installed.review.account().justification().is_empty(),
9142 "a privileged account with no stated reason is an unreviewed one"
9143 );
9144 }
9145
9146 #[test]
9151 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
9152 let host = Host::new();
9153 let operations = host.operations();
9154 operations
9155 .install(&host.request(StartMode::Boot))
9156 .expect("an install");
9157
9158 let config = host.paths.config_dir();
9162 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
9163 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
9164 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
9165 std::fs::write(
9166 host.paths
9167 .state_dir()
9168 .join("packages/2.330.0/runner.tar.gz"),
9169 b"cached package",
9170 )
9171 .expect("writable");
9172 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
9173 std::fs::write(
9174 host.paths.state_dir().join("secrets/user-access-token"),
9175 b"a stand-in for the stored credential",
9176 )
9177 .expect("writable");
9178 std::fs::write(
9179 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
9180 b"diagnostics",
9181 )
9182 .expect("writable");
9183
9184 let roots: Vec<PathBuf> = host
9185 .paths
9186 .all()
9187 .iter()
9188 .map(|(_, path)| (*path).to_path_buf())
9189 .collect();
9190 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
9191 let before = snapshot(&roots);
9192
9193 assert!(
9196 before.len() >= 6,
9197 "the fixture must actually contain the files this test is about, got {before:#?}"
9198 );
9199 let record_path = InstallRecord::path(&host.paths);
9200 assert!(
9201 before.contains_key(&record_path),
9202 "the install record must be present before uninstall"
9203 );
9204
9205 let uninstalled = operations.uninstall().expect("an uninstall");
9206 assert!(uninstalled.removed_registration);
9207 assert!(uninstalled.removed_record);
9208
9209 let after = snapshot(&roots);
9210
9211 let mut expected = before.clone();
9213 expected.remove(&record_path);
9214 assert_eq!(
9215 after, expected,
9216 "uninstall must remove its own record and nothing else"
9217 );
9218 assert!(
9219 !record_path.exists(),
9220 "the record itself must go, or `uninstall` did nothing at all"
9221 );
9222 assert!(
9223 uninstalled
9224 .preserved
9225 .iter()
9226 .all(|path| roots.contains(&path.as_path())),
9227 "the preserved list must name the four directories: {uninstalled}"
9228 );
9229 }
9230
9231 #[test]
9232 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
9233 let host = Host::new();
9234 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
9235 assert!(!uninstalled.removed_registration);
9236 assert!(!uninstalled.removed_record);
9237 }
9238
9239 #[test]
9240 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
9241 let host = Host::new();
9242 let operations = host.operations();
9243 operations
9244 .install(&host.request(StartMode::Boot))
9245 .expect("an install");
9246 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
9247
9248 let uninstalled = operations.uninstall().expect("an uninstall");
9249 assert!(
9250 uninstalled.removed_registration,
9251 "a lost record must not strand a registration"
9252 );
9253 assert!(host.controls.registrations().is_empty());
9254 }
9255
9256 #[test]
9261 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
9262 let host = Host::new();
9263 let operations = host.operations();
9264 operations
9265 .install(&host.request(StartMode::Boot))
9266 .expect("an install at boot");
9267
9268 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
9272
9273 let change = operations
9274 .set_start_mode(StartMode::Login)
9275 .expect("a switch that does not reinstall the product");
9276 assert!(change.changed);
9277 assert_eq!(change.from, StartMode::Boot);
9278 assert_eq!(change.to, StartMode::Login);
9279 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
9280
9281 let record = InstallRecord::read(&host.paths)
9282 .expect("readable")
9283 .expect("a record");
9284 assert_eq!(record.start_mode, StartMode::Login);
9285 assert_eq!(
9286 record.binary, host.binary,
9287 "the recorded path must survive the switch untouched"
9288 );
9289
9290 let registrations = host.controls.registrations();
9291 assert_eq!(registrations.len(), 1, "{registrations:?}");
9292 assert_eq!(registrations[0].0, StartMode::Login);
9293 assert!(
9294 registrations[0]
9295 .2
9296 .command_line
9297 .contains(&host.binary.to_string_lossy().into_owned()),
9298 "{:?}",
9299 registrations[0].2
9300 );
9301 }
9302
9303 #[test]
9304 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
9305 let host = Host::new();
9306 let operations = host.operations();
9307 operations
9308 .install(&host.request(StartMode::Boot))
9309 .expect("an install at boot");
9310 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9311 host.controls
9312 .fail_next_install(StartMode::Login, "injected target failure");
9313
9314 let error = operations
9315 .set_start_mode(StartMode::Login)
9316 .expect_err("the target manager refuses the install");
9317
9318 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
9319 assert_eq!(
9320 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9321 record_before
9322 );
9323 let registrations = host.controls.registrations();
9324 assert_eq!(registrations.len(), 1, "{registrations:?}");
9325 assert_eq!(registrations[0].0, StartMode::Boot);
9326 }
9327
9328 #[test]
9329 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
9330 let host = Host::new();
9331 let operations = host.operations();
9332 operations
9333 .install(&host.request(StartMode::Boot))
9334 .expect("an install at boot");
9335 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
9336 let config = host.paths.config_dir().to_path_buf();
9337 let hidden = config.with_file_name("config-hidden-by-fault");
9338 host.controls.hide_directory_after_install(
9339 StartMode::Login,
9340 config.clone(),
9341 hidden.clone(),
9342 );
9343
9344 let error = operations
9345 .set_start_mode(StartMode::Login)
9346 .expect_err("the injected filesystem fault prevents persistence");
9347
9348 std::fs::remove_file(&config).expect("remove the injected blocker");
9349 std::fs::rename(&hidden, &config).expect("restore the record directory");
9350 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
9351 assert_eq!(
9352 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
9353 record_before
9354 );
9355 let registrations = host.controls.registrations();
9356 assert_eq!(registrations.len(), 1, "{registrations:?}");
9357 assert_eq!(registrations[0].0, StartMode::Boot);
9358 assert!(
9359 host.controls
9360 .calls()
9361 .iter()
9362 .any(|call| call == "uninstall runner-manager (login)"),
9363 "the target must be rolled back: {:?}",
9364 host.controls.calls()
9365 );
9366 }
9367
9368 #[test]
9369 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
9370 let host = Host::new();
9371 let operations = host.operations();
9372 operations
9373 .install(&host.request(StartMode::Boot))
9374 .expect("an install");
9375 let before = host.controls.calls().len();
9376
9377 let change = operations
9378 .set_start_mode(StartMode::Boot)
9379 .expect("a no-op switch");
9380 assert!(!change.changed);
9381 assert_eq!(
9382 host.controls.calls().len(),
9383 before,
9384 "a no-op switch must not touch the service manager"
9385 );
9386 }
9387
9388 #[test]
9389 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
9390 let host = Host::new();
9391 let error = host
9392 .operations()
9393 .set_start_mode(StartMode::Login)
9394 .expect_err("there is nothing to switch");
9395 assert!(
9396 matches!(error, ServiceError::NotInstalled { .. }),
9397 "{error}"
9398 );
9399 }
9400
9401 #[test]
9406 fn status_reports_the_four_facts_journey_five_asks_for() {
9407 let host = Host::new();
9408 let operations = host.operations();
9409 operations
9410 .install(&host.request(StartMode::Boot))
9411 .expect("an install");
9412 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
9413 .expect("a valid timestamp")
9414 .with_timezone(&Utc);
9415 record_github_contact(&host.paths, at).expect("a heartbeat");
9416
9417 let status = operations.status().expect("a status");
9418 assert_eq!(status.start_mode(), Some(StartMode::Boot));
9419 assert_eq!(
9420 status.binary().map(BinaryPath::recorded),
9421 Some(host.binary.as_path())
9422 );
9423 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
9424 assert_eq!(status.last_github_contact(), Some(at));
9425 assert!(status.is_installed());
9426 assert!(status.is_healthy(), "{status}");
9427
9428 let printed = status.to_string();
9429 for fragment in [
9430 "start mode",
9431 "diagnostic log",
9432 "last GitHub contact",
9433 "binary",
9434 ] {
9435 assert!(printed.contains(fragment), "{printed}");
9436 }
9437 }
9438
9439 #[cfg(unix)]
9451 #[test]
9452 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
9453 use std::os::unix::fs::PermissionsExt as _;
9454
9455 if unsafe { libc::geteuid() } == 0 {
9459 return;
9460 }
9461
9462 let host = Host::new();
9463 let operations = host.operations();
9464 operations
9465 .install(&host.request(StartMode::Boot))
9466 .expect("an install");
9467 assert!(
9468 operations.status().expect("a status").is_healthy(),
9469 "the discriminator: healthy before the record is made unreadable"
9470 );
9471
9472 let record = InstallRecord::path(&host.paths);
9473 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
9474 .expect("the mode is applied");
9475
9476 let status = operations
9477 .status()
9478 .expect("a record this account may not read is reported, not thrown");
9479 assert!(status.is_installed(), "{status}");
9480 assert!(!status.is_healthy(), "{status}");
9481
9482 let printed = status.to_string();
9483 assert!(
9484 printed.contains("this account may not read it"),
9485 "the operator is told which of the two states this is: {printed}"
9486 );
9487 assert!(
9488 !printed.contains("there is no install record"),
9489 "a record that is there and unreadable is not a record that is missing, and the \
9490 missing one's remedy starts with `service uninstall`: {printed}"
9491 );
9492
9493 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
9496 .expect("the mode is restored");
9497 }
9498
9499 #[test]
9500 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
9501 let host = Host::new();
9502 let operations = host.operations();
9503 operations
9504 .install(&host.request(StartMode::Boot))
9505 .expect("an install");
9506
9507 assert!(
9510 operations.status().expect("a status").is_healthy(),
9511 "the freshly installed host must be healthy"
9512 );
9513
9514 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
9515
9516 let status = operations.status().expect("a status");
9517 assert!(!status.is_healthy(), "{status}");
9518 assert!(
9519 status
9520 .problems()
9521 .iter()
9522 .any(|problem| problem.subject == "binary"),
9523 "{status}"
9524 );
9525 assert!(status.to_string().contains("STALE"), "{status}");
9526 }
9527
9528 #[test]
9529 fn status_reports_a_registration_that_would_not_start_at_boot() {
9530 let host = Host::new();
9531 let operations = host.operations();
9532 operations
9533 .install(&host.request(StartMode::Boot))
9534 .expect("an install");
9535 assert!(operations.status().expect("a status").is_healthy());
9536
9537 host.controls.edit("runner-manager", |registration| {
9538 registration.starts_automatically = false;
9539 });
9540
9541 let status = operations.status().expect("a status");
9542 assert!(!status.is_healthy(), "{status}");
9543 assert!(
9544 status
9545 .problems()
9546 .iter()
9547 .any(|problem| problem.detail.contains("after a reboot")),
9548 "{status}"
9549 );
9550 }
9551
9552 #[test]
9553 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
9554 let host = Host::new();
9555 let operations = host.operations();
9556 operations
9557 .install(&host.request(StartMode::Login))
9558 .expect("install and immediate start");
9559
9560 assert!(
9561 operations.status().expect("running status").is_running(),
9562 "install must not wait for the next login trigger"
9563 );
9564 operations.stop().expect("stop the registration");
9565 let status = operations.status().expect("stopped status");
9566 assert!(!status.is_healthy(), "{status}");
9567 assert!(
9568 status
9569 .problems()
9570 .iter()
9571 .any(|problem| problem.subject == "runtime"),
9572 "{status}"
9573 );
9574 let rendered = status.to_string();
9575 assert!(
9576 rendered.contains("runner-manager service install --start-at login"),
9577 "{rendered}"
9578 );
9579 assert!(
9580 !rendered.contains("runner-manager service start"),
9581 "{rendered}"
9582 );
9583 }
9584
9585 #[test]
9586 fn status_reports_a_restart_policy_something_else_edited() {
9587 let host = Host::new();
9588 let operations = host.operations();
9589 operations
9590 .install(&host.request(StartMode::Boot))
9591 .expect("an install");
9592 assert!(operations.status().expect("a status").is_healthy());
9593
9594 host.controls.edit("runner-manager", |registration| {
9595 registration.restart_delay = Some(Duration::from_secs(1));
9596 });
9597
9598 let status = operations.status().expect("a status");
9599 assert!(!status.is_healthy(), "{status}");
9600 assert!(
9601 status
9602 .problems()
9603 .iter()
9604 .any(|problem| problem.subject == "restart policy"),
9605 "{status}"
9606 );
9607 }
9608
9609 #[test]
9610 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
9611 let host = Host::new();
9612 let operations = host.operations();
9613 operations
9614 .install(&host.request(StartMode::Boot))
9615 .expect("an install");
9616 let other = host.binary.with_file_name("someone-elses.exe");
9617 std::fs::write(&other, b"x").expect("writable");
9618
9619 host.controls.edit("runner-manager", |registration| {
9620 registration.command_line = quote_argument(&other.to_string_lossy());
9621 });
9622
9623 let status = operations.status().expect("a status");
9624 assert!(!status.is_healthy(), "{status}");
9625 assert!(
9626 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
9627 "{status}"
9628 );
9629 }
9630
9631 #[test]
9632 fn status_reports_a_record_no_service_manager_knows_about() {
9633 let host = Host::new();
9634 let operations = host.operations();
9635 operations
9636 .install(&host.request(StartMode::Boot))
9637 .expect("an install");
9638 for mode in [StartMode::Boot, StartMode::Login] {
9640 host.controls
9641 .control(mode)
9642 .expect("a control")
9643 .uninstall(&ServiceIdentity::product())
9644 .expect("removed");
9645 }
9646
9647 let status = operations.status().expect("a status");
9648 assert!(!status.is_healthy(), "{status}");
9649 assert!(
9650 status
9651 .problems()
9652 .iter()
9653 .any(|problem| problem.subject == "registration"),
9654 "{status}"
9655 );
9656 }
9657
9658 #[test]
9659 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
9660 let host = Host::new();
9661 let status = host.operations().status().expect("a status");
9662 assert!(!status.is_installed());
9663 assert!(
9664 status.is_healthy(),
9665 "a host that never installed the service has no fault to report: {status}"
9666 );
9667 assert!(status.to_string().contains("installed"), "{status}");
9668 }
9669
9670 #[test]
9671 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
9672 let host = Host::new();
9673 let operations = host.operations();
9674 operations
9675 .install(&host.request(StartMode::Login))
9676 .expect("an install at login");
9677 let status = operations.status().expect("a status");
9678 assert!(
9679 status
9680 .notes()
9681 .iter()
9682 .any(|note| note.contains("does not run until the operator signs in")),
9683 "05-infrastructure.md requires `service status` to say so: {status}"
9684 );
9685 }
9686
9687 #[test]
9692 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
9693 let host = Host::new();
9694 let operations = host.operations();
9695 operations
9696 .install(&host.request(StartMode::Login))
9697 .expect("an install at login");
9698 operations.start().expect("a start");
9699 assert!(operations.status().expect("a status").is_running());
9700 assert!(operations.stop().expect("a stop"));
9701 assert!(!operations.status().expect("a status").is_running());
9702 }
9703
9704 #[test]
9705 fn starting_a_host_with_no_registration_is_refused() {
9706 let host = Host::new();
9707 let error = host.operations().start().expect_err("nothing to start");
9708 assert!(
9709 matches!(error, ServiceError::NotInstalled { .. }),
9710 "{error}"
9711 );
9712 }
9713
9714 #[cfg(windows)]
9730 #[test]
9731 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
9732 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
9733
9734 let root = tempfile::tempdir().expect("a temporary directory");
9735 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
9736 .expect("a rooted machine-scoped store");
9737 store
9738 .store(&secrecy::SecretString::from("a stand-in for the token"))
9739 .expect("the store accepts a value");
9740 let protection = store.protection().expect("the DACL can be read back");
9741
9742 assert!(
9743 protection.description().contains(";;;SY)"),
9744 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
9745 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
9746 protection.description()
9747 );
9748 assert_eq!(
9749 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9750 ServiceAccount::LocalSystem,
9751 "and that is the account this installer registers, which is why SY is what matters"
9752 );
9753 assert!(
9754 !protection.readable_by_other_local_users(),
9755 "the same DACL must still exclude ordinary local users: {}",
9756 protection.description()
9757 );
9758
9759 for rejected in [";;;LS)", ";;;NS)"] {
9763 assert!(
9764 !protection.description().contains(rejected),
9765 "if the store ever admitted {rejected}, the least-privilege analysis in \
9766 docs/service-account.md would need redoing: {}",
9767 protection.description()
9768 );
9769 }
9770 }
9771}