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 ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
157
158pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
169
170#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct ServiceIdentity {
184 name: String,
185 display_name: String,
186 description: String,
187}
188
189impl ServiceIdentity {
190 #[must_use]
192 pub fn product() -> Self {
193 Self {
194 name: SERVICE_NAME.to_string(),
195 display_name: DISPLAY_NAME.to_string(),
196 description: DESCRIPTION.to_string(),
197 }
198 }
199
200 #[must_use]
207 pub fn fixture(tag: &str) -> Self {
208 let tag: String = tag
209 .chars()
210 .map(|c| {
211 if c.is_ascii_alphanumeric() {
212 c.to_ascii_lowercase()
213 } else {
214 '-'
215 }
216 })
217 .collect();
218 let name = format!("{SERVICE_NAME}-selftest-{tag}");
219 Self {
220 display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
221 description: "Disposable fixture created by runner-manager's own installer tests. \
222 Safe to remove."
223 .to_string(),
224 name,
225 }
226 }
227
228 #[must_use]
233 pub fn is_fixture(&self) -> bool {
234 self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
235 }
236
237 #[must_use]
240 pub fn name(&self) -> &str {
241 &self.name
242 }
243
244 #[must_use]
246 pub fn display_name(&self) -> &str {
247 &self.display_name
248 }
249
250 #[must_use]
252 pub fn description(&self) -> &str {
253 &self.description
254 }
255
256 #[must_use]
262 pub fn launchd_label(&self) -> String {
263 format!(
264 "{}.{}.{}",
265 crate::paths::QUALIFIER,
266 crate::paths::ORGANIZATION,
267 self.name
268 )
269 }
270
271 #[must_use]
273 pub fn systemd_unit(&self) -> String {
274 format!("{}.service", self.name)
275 }
276}
277
278impl fmt::Display for ServiceIdentity {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 f.write_str(&self.name)
281 }
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub struct RestartPolicy {
299 delay: Duration,
300 reset_after: Duration,
301}
302
303impl RestartPolicy {
304 pub const MIN_DELAY: Duration = Duration::from_secs(1);
311
312 pub const MAX_DELAY: Duration = Duration::from_secs(300);
315
316 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
318
319 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
321
322 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
330 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
331 return Err(ServiceError::RestartDelay {
332 requested_secs: delay.as_secs(),
333 min_secs: Self::MIN_DELAY.as_secs(),
334 max_secs: Self::MAX_DELAY.as_secs(),
335 });
336 }
337 if reset_after <= delay {
338 return Err(ServiceError::RestartResetWindow {
339 reset_secs: reset_after.as_secs(),
340 delay_secs: delay.as_secs(),
341 });
342 }
343 Ok(Self { delay, reset_after })
344 }
345
346 #[must_use]
348 pub const fn delay(&self) -> Duration {
349 self.delay
350 }
351
352 #[must_use]
354 pub const fn reset_after(&self) -> Duration {
355 self.reset_after
356 }
357
358 #[must_use]
375 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
376 match kind {
377 DefinitionKind::WindowsScheduledTask => {
378 let seconds = self.delay.as_secs();
379 let minutes = seconds.div_ceil(60);
380 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
381 }
382 _ => self.delay,
383 }
384 }
385}
386
387impl Default for RestartPolicy {
388 fn default() -> Self {
389 Self {
390 delay: Self::DEFAULT_DELAY,
391 reset_after: Self::DEFAULT_RESET_AFTER,
392 }
393 }
394}
395
396impl fmt::Display for RestartPolicy {
397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398 write!(
399 f,
400 "restart after {}s, failure count resets after {}s",
401 self.delay.as_secs(),
402 self.reset_after.as_secs()
403 )
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "snake_case")]
419pub enum ServiceAccount {
420 LocalSystem,
423 Root,
426 InvokingUser,
428}
429
430impl ServiceAccount {
431 #[must_use]
440 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
441 match (kind, mode) {
442 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
443 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
444 (_, StartMode::Boot) => Self::Root,
445 }
446 }
447
448 #[must_use]
451 pub const fn for_start_mode(mode: StartMode) -> Self {
452 Self::for_definition(host_definition_kind(mode), mode)
453 }
454
455 #[must_use]
457 pub const fn as_str(&self) -> &'static str {
458 match self {
459 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
460 Self::Root => "root",
461 Self::InvokingUser => "the invoking user",
462 }
463 }
464
465 #[must_use]
472 pub const fn justification(&self) -> &'static str {
473 match self {
474 Self::LocalSystem => {
475 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
476 NetworkService cannot read it, and widening the DACL to reach them would grant \
477 every service on this host read access to the one credential this product holds"
478 }
479 Self::Root => {
480 "a boot-time registration runs outside every login session: on macOS the System \
481 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
482 machine-scoped store is a 0600 file under /var/lib that only root can open \
483 before a session exists"
484 }
485 Self::InvokingUser => {
486 "a login-mode registration reads the user-scoped store, which is deliberately \
487 readable by exactly one account and needs no elevation at all"
488 }
489 }
490 }
491
492 #[must_use]
494 pub const fn needs_elevation(&self) -> bool {
495 matches!(self, Self::LocalSystem | Self::Root)
496 }
497}
498
499impl fmt::Display for ServiceAccount {
500 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501 f.write_str(self.as_str())
502 }
503}
504
505#[derive(Debug, thiserror::Error)]
516pub enum ServiceError {
517 #[error("cannot install the service while an agent is already running on this host: {source}")]
523 LockHeld {
524 #[source]
526 source: Box<LockError>,
527 },
528
529 #[error(
532 "cannot tell whether an agent is already running on this host, so the service was not \
533 installed: {source}. Fix the reported problem with the state directory and try again."
534 )]
535 LockUnreadable {
536 #[source]
538 source: Box<LockError>,
539 },
540
541 #[error(
549 "this registration would run jobs under the default runner root, and that root could \
550 not be prepared, so nothing was registered: {source}"
551 )]
552 RunnerRoot {
553 #[source]
555 source: Box<RootAccessError>,
556 },
557
558 #[error(
560 "cannot resolve the absolute path of this executable, so there is nothing to register: \
561 {detail}. Run the installer from the installed binary rather than through a shell \
562 function or a wrapper that replaces argv[0]."
563 )]
564 BinaryPath {
565 detail: String,
567 },
568
569 #[error(
571 "{} is not a file, so registering it would create a service that cannot start. Install \
572 the product first, then run `service install` from the installed binary.",
573 path.display()
574 )]
575 BinaryMissing {
576 path: PathBuf,
578 },
579
580 #[error(
582 "a restart delay of {requested_secs}s is outside the supported range \
583 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
584 value, so `service status` would report a delay that is not the one in force."
585 )]
586 RestartDelay {
587 requested_secs: u64,
589 min_secs: u64,
591 max_secs: u64,
593 },
594
595 #[error(
597 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
598 restart delay, so the count would reset between every pair of restarts and no \
599 start limit could ever apply."
600 )]
601 RestartResetWindow {
602 reset_secs: u64,
604 delay_secs: u64,
606 },
607
608 #[error(
614 "{name} is already registered to start at {existing}, and this asks for {requested}. \
615 Changing the start mode moves the registration between two different service \
616 managers, so it is not something an install does by itself: run `service uninstall` \
617 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
618 switches it in place and keeps the registration running throughout."
619 )]
620 AlreadyInstalled {
621 name: String,
623 existing: StartMode,
625 requested: StartMode,
627 },
628
629 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
631 NotInstalled {
632 name: String,
634 operation: &'static str,
636 },
637
638 #[error("cannot {operation} the service record {}: {detail}", path.display())]
640 Record {
641 operation: &'static str,
643 path: PathBuf,
645 detail: String,
647 },
648
649 #[error(
651 "the service record {} was not written by this product, or was written by a version \
652 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
653 neither touches configuration, secrets, or the cache.",
654 path.display()
655 )]
656 RecordUnreadable {
657 path: PathBuf,
659 detail: String,
661 },
662
663 #[error(
677 "the service record {} is there and this account may not read it: {detail}. It was \
678 written by whichever account installed the service -- on a boot-mode host, `sudo \
679 service install`. Running `service install` again rewrites it readable; until then \
680 only what the service manager itself reports is available.",
681 path.display()
682 )]
683 RecordNotPermitted {
684 path: PathBuf,
686 detail: String,
688 },
689
690 #[error("cannot prepare this host's application-data directories: {source}")]
692 Paths {
693 #[source]
695 source: Box<crate::paths::PathsError>,
696 },
697
698 #[error("cannot {operation} {name} through {manager}: {detail}")]
700 Control {
701 operation: &'static str,
703 name: String,
705 manager: &'static str,
707 detail: String,
709 },
710
711 #[error(
713 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
714 Inspect `service status` before retrying."
715 )]
716 Rollback {
717 operation: &'static str,
719 name: String,
721 cause: String,
723 rollback: String,
725 },
726
727 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
729 NeedsElevation {
730 operation: &'static str,
732 name: String,
734 detail: String,
736 remedy: &'static str,
738 },
739}
740
741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
753pub struct ServiceDirectories {
754 pub config: PathBuf,
756 pub state: PathBuf,
758 pub runtime: PathBuf,
760 pub logs: PathBuf,
762}
763
764impl ServiceDirectories {
765 #[must_use]
767 pub fn of(paths: &AppPaths) -> Self {
768 Self {
769 config: paths.config_dir().to_path_buf(),
770 state: paths.state_dir().to_path_buf(),
771 runtime: paths.runtime_dir().to_path_buf(),
772 logs: paths.logs_dir().to_path_buf(),
773 }
774 }
775
776 #[must_use]
778 pub fn all(&self) -> [&Path; 4] {
779 [
780 self.config.as_path(),
781 self.state.as_path(),
782 self.runtime.as_path(),
783 self.logs.as_path(),
784 ]
785 }
786
787 #[must_use]
790 pub fn log_file(&self) -> PathBuf {
791 self.logs.join(LOG_FILE_STEM)
792 }
793}
794
795#[derive(Debug, Clone, PartialEq, Eq)]
807pub struct InstallRequest {
808 start_mode: StartMode,
809 binary: Option<PathBuf>,
810 source_binary: Option<PathBuf>,
811 arguments: Vec<OsString>,
812 restart: RestartPolicy,
813 on_demand: bool,
814}
815
816impl InstallRequest {
817 #[must_use]
819 pub fn new(start_mode: StartMode) -> Self {
820 Self {
821 start_mode,
822 binary: None,
823 source_binary: None,
824 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
825 restart: RestartPolicy::default(),
826 on_demand: false,
827 }
828 }
829
830 #[must_use]
840 pub const fn started_on_demand(mut self) -> Self {
841 self.on_demand = true;
842 self
843 }
844
845 #[must_use]
852 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
853 self.binary = Some(binary.into());
854 self
855 }
856
857 #[must_use]
874 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
875 self.source_binary = Some(source.into());
876 self
877 }
878
879 #[must_use]
881 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
882 where
883 I: IntoIterator<Item = S>,
884 S: Into<OsString>,
885 {
886 self.arguments = arguments.into_iter().map(Into::into).collect();
887 self
888 }
889
890 #[must_use]
893 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
894 self.restart = restart;
895 self
896 }
897
898 #[must_use]
900 pub const fn start_mode(&self) -> StartMode {
901 self.start_mode
902 }
903}
904
905#[derive(Debug, Clone, PartialEq, Eq)]
912pub struct InstallPlan {
913 identity: ServiceIdentity,
914 start_mode: StartMode,
915 binary: PathBuf,
916 source_binary: Option<PathBuf>,
917 arguments: Vec<OsString>,
918 account: ServiceAccount,
919 restart: RestartPolicy,
920 directories: ServiceDirectories,
921 secret_guard: Option<PathBuf>,
922 on_demand: bool,
923}
924
925impl InstallPlan {
926 pub fn resolve(
934 identity: ServiceIdentity,
935 request: &InstallRequest,
936 directories: ServiceDirectories,
937 ) -> Result<Self, ServiceError> {
938 let binary = match &request.binary {
939 Some(named) => absolute(named)?,
940 None => running_executable()?,
941 };
942 if !binary.is_file() {
943 return Err(ServiceError::BinaryMissing { path: binary });
944 }
945 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
950 .ok()
951 .map(|store| store.guard());
952 Ok(Self {
953 identity,
954 start_mode: request.start_mode,
955 binary,
956 source_binary: request.source_binary.clone(),
957 arguments: request.arguments.clone(),
958 account: ServiceAccount::for_start_mode(request.start_mode),
959 restart: request.restart,
960 directories,
961 secret_guard,
962 on_demand: request.on_demand,
963 })
964 }
965
966 #[must_use]
973 pub fn unchecked(
974 identity: ServiceIdentity,
975 start_mode: StartMode,
976 binary: impl Into<PathBuf>,
977 directories: ServiceDirectories,
978 ) -> Self {
979 Self {
980 identity,
981 start_mode,
982 binary: binary.into(),
983 source_binary: None,
984 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
985 account: ServiceAccount::for_start_mode(start_mode),
986 restart: RestartPolicy::default(),
987 directories,
988 secret_guard: None,
989 on_demand: false,
990 }
991 }
992
993 #[must_use]
996 pub const fn started_on_demand(mut self) -> Self {
997 self.on_demand = true;
998 self
999 }
1000
1001 #[must_use]
1003 pub const fn is_on_demand(&self) -> bool {
1004 self.on_demand
1005 }
1006
1007 #[must_use]
1014 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1015 self.secret_guard = Some(guard.into());
1016 self
1017 }
1018
1019 #[must_use]
1021 pub fn secret_guard(&self) -> Option<&Path> {
1022 self.secret_guard.as_deref()
1023 }
1024
1025 #[must_use]
1027 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1028 self.restart = restart;
1029 self
1030 }
1031
1032 #[must_use]
1034 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1035 where
1036 I: IntoIterator<Item = S>,
1037 S: Into<OsString>,
1038 {
1039 self.arguments = arguments.into_iter().map(Into::into).collect();
1040 self
1041 }
1042
1043 #[must_use]
1045 pub const fn identity(&self) -> &ServiceIdentity {
1046 &self.identity
1047 }
1048
1049 #[must_use]
1051 pub const fn start_mode(&self) -> StartMode {
1052 self.start_mode
1053 }
1054
1055 #[must_use]
1057 pub fn binary(&self) -> &Path {
1058 &self.binary
1059 }
1060
1061 #[must_use]
1067 pub fn source_binary(&self) -> Option<&Path> {
1068 self.source_binary.as_deref()
1069 }
1070
1071 #[must_use]
1073 pub fn arguments(&self) -> &[OsString] {
1074 &self.arguments
1075 }
1076
1077 #[must_use]
1079 pub const fn account(&self) -> &ServiceAccount {
1080 &self.account
1081 }
1082
1083 #[must_use]
1085 pub const fn restart(&self) -> RestartPolicy {
1086 self.restart
1087 }
1088
1089 #[must_use]
1091 pub const fn directories(&self) -> &ServiceDirectories {
1092 &self.directories
1093 }
1094
1095 #[must_use]
1097 pub fn command_line(&self) -> String {
1098 let mut out = quote_argument(&self.binary.to_string_lossy());
1099 for argument in &self.arguments {
1100 out.push(' ');
1101 out.push_str("e_argument(&argument.to_string_lossy()));
1102 }
1103 out
1104 }
1105}
1106
1107fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1115 let reversal = change.revert();
1116 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1117}
1118
1119fn rolled_back<T>(
1132 retained: Option<String>,
1133 rollback: Result<T, ServiceError>,
1134 operation: &'static str,
1135 identity: &ServiceIdentity,
1136 cause: ServiceError,
1137) -> ServiceError {
1138 let left_behind = match (rollback.err(), retained) {
1139 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1140 (Some(rollback), None) => Some(rollback.to_string()),
1141 (None, retained) => retained,
1142 };
1143 match left_behind {
1144 Some(rollback) => ServiceError::Rollback {
1145 operation,
1146 name: identity.name().to_string(),
1147 cause: cause.to_string(),
1148 rollback,
1149 },
1150 None => cause,
1151 }
1152}
1153
1154fn undo_runner_root(
1157 change: &RootAccessChange,
1158 operation: &'static str,
1159 identity: &ServiceIdentity,
1160 cause: ServiceError,
1161) -> ServiceError {
1162 rolled_back(
1163 retained_runner_root(change),
1164 Ok(()),
1165 operation,
1166 identity,
1167 cause,
1168 )
1169}
1170
1171fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1185 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1186 detail: format!("{} could not be made absolute: {error}", path.display()),
1187 })
1188}
1189
1190fn running_executable() -> Result<PathBuf, ServiceError> {
1192 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1193 detail: error.to_string(),
1194 })?;
1195 absolute(&raw)
1196}
1197
1198pub(crate) fn quote_argument(argument: &str) -> String {
1207 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1208 return argument.to_string();
1209 }
1210 let mut out = String::with_capacity(argument.len() + 2);
1211 out.push('"');
1212 let mut backslashes = 0usize;
1213 for c in argument.chars() {
1214 match c {
1215 '\\' => {
1216 backslashes += 1;
1217 out.push('\\');
1218 }
1219 '"' => {
1220 for _ in 0..=backslashes {
1223 out.push('\\');
1224 }
1225 out.push('"');
1226 backslashes = 0;
1227 }
1228 other => {
1229 backslashes = 0;
1230 out.push(other);
1231 }
1232 }
1233 }
1234 for _ in 0..backslashes {
1236 out.push('\\');
1237 }
1238 out.push('"');
1239 out
1240}
1241
1242#[must_use]
1253pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1254 let trimmed = command_line.trim_start();
1255 if trimmed.is_empty() {
1256 return None;
1257 }
1258 let mut out = String::new();
1259 let mut chars = trimmed.chars().peekable();
1260 let quoted = chars.peek() == Some(&'"');
1261 if quoted {
1262 chars.next();
1263 let mut backslashes = 0usize;
1264 for c in chars {
1265 match c {
1266 '\\' => {
1267 backslashes += 1;
1268 }
1269 '"' => {
1270 out.extend(std::iter::repeat_n('\\', backslashes / 2));
1273 if backslashes.is_multiple_of(2) {
1274 break;
1275 }
1276 backslashes = 0;
1277 out.push('"');
1278 }
1279 other => {
1280 out.extend(std::iter::repeat_n('\\', backslashes));
1281 backslashes = 0;
1282 out.push(other);
1283 }
1284 }
1285 }
1286 } else {
1287 for c in chars {
1288 if c == ' ' || c == '\t' {
1289 break;
1290 }
1291 out.push(c);
1292 }
1293 }
1294 if out.is_empty() {
1295 None
1296 } else {
1297 Some(PathBuf::from(out))
1298 }
1299}
1300
1301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1307pub enum DefinitionKind {
1308 WindowsService,
1312 WindowsScheduledTask,
1314 LaunchdPlist,
1316 SystemdUnit,
1318}
1319
1320impl DefinitionKind {
1321 #[must_use]
1323 pub const fn manager(self) -> &'static str {
1324 match self {
1325 Self::WindowsService => "the Windows Service Control Manager",
1326 Self::WindowsScheduledTask => "Windows Task Scheduler",
1327 Self::LaunchdPlist => "launchd",
1328 Self::SystemdUnit => "systemd",
1329 }
1330 }
1331}
1332
1333impl fmt::Display for DefinitionKind {
1334 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1335 f.write_str(self.manager())
1336 }
1337}
1338
1339#[derive(Debug, Clone, PartialEq, Eq)]
1346pub struct ServiceDefinition {
1347 kind: DefinitionKind,
1348 text: String,
1349 install_path: Option<PathBuf>,
1350}
1351
1352impl ServiceDefinition {
1353 #[must_use]
1355 pub const fn kind(&self) -> DefinitionKind {
1356 self.kind
1357 }
1358
1359 #[must_use]
1361 pub fn text(&self) -> &str {
1362 &self.text
1363 }
1364
1365 #[must_use]
1372 pub fn install_path(&self) -> Option<&Path> {
1373 self.install_path.as_deref()
1374 }
1375
1376 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1389 Ok(match host_definition_kind(plan.start_mode()) {
1390 DefinitionKind::WindowsService => Self::windows_service(plan),
1391 DefinitionKind::WindowsScheduledTask => {
1392 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1393 }
1394 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1395 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1396 })
1397 }
1398
1399 #[must_use]
1401 pub fn windows_service(plan: &InstallPlan) -> Self {
1402 Self {
1403 kind: DefinitionKind::WindowsService,
1404 text: windows_service_descriptor(plan),
1405 install_path: None,
1406 }
1407 }
1408
1409 #[must_use]
1411 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1412 Self {
1413 kind: DefinitionKind::WindowsScheduledTask,
1414 text: windows_scheduled_task_xml(plan, principal),
1415 install_path: None,
1416 }
1417 }
1418
1419 #[must_use]
1424 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1425 let file = format!("{}.plist", plan.identity().launchd_label());
1426 let install_path = match plan.start_mode() {
1427 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1428 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1429 };
1430 Self {
1431 kind: DefinitionKind::LaunchdPlist,
1432 text: launchd_plist(plan),
1433 install_path,
1434 }
1435 }
1436
1437 #[must_use]
1445 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1446 Self {
1447 kind,
1448 text: text.into(),
1449 install_path: None,
1450 }
1451 }
1452
1453 #[must_use]
1458 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1459 let file = plan.identity().systemd_unit();
1460 let install_path = match plan.start_mode() {
1461 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1462 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1463 };
1464 Self {
1465 kind: DefinitionKind::SystemdUnit,
1466 text: systemd_unit(plan),
1467 install_path,
1468 }
1469 }
1470}
1471
1472pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1474pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1476pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1478pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1480
1481const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1484
1485pub const START_LIMIT_BURST: u32 = 5;
1493
1494#[must_use]
1515pub fn systemd_unit(plan: &InstallPlan) -> String {
1516 let identity = plan.identity();
1517 let restart = plan.restart();
1518 let directories = plan.directories();
1519
1520 let mut out = String::new();
1521 out.push_str("[Unit]\n");
1522 out.push_str(&format!("Description={}\n", identity.display_name()));
1523 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1524 out.push_str("After=network-online.target\n");
1525 out.push_str("Wants=network-online.target\n");
1526 out.push_str(&format!(
1529 "StartLimitIntervalSec={}\n",
1530 restart.reset_after().as_secs()
1531 ));
1532 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1533
1534 out.push_str("\n[Service]\n");
1535 out.push_str("Type=simple\n");
1536 out.push_str("KillMode=process\n");
1540 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1541 out.push_str(&format!(
1542 "WorkingDirectory={}\n",
1543 directories.state.display()
1544 ));
1545 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1546 out.push_str("Restart=on-failure\n");
1547 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1548
1549 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1550 for directive in SYSTEMD_HARDENING {
1551 out.push_str(directive);
1552 out.push('\n');
1553 }
1554 let mut writable = directories.all().to_vec();
1555 if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1556 writable.push(secret_directory);
1557 }
1558 out.push_str(&format!(
1559 "ReadWritePaths={}\n",
1560 writable
1561 .iter()
1562 .map(|path| quote_argument(&path.to_string_lossy()))
1563 .collect::<Vec<_>>()
1564 .join(" ")
1565 ));
1566
1567 out.push_str("\n[Install]\n");
1568 out.push_str(match plan.start_mode() {
1569 StartMode::Boot => "WantedBy=multi-user.target\n",
1570 StartMode::Login => "WantedBy=default.target\n",
1571 });
1572 out
1573}
1574
1575pub const SYSTEMD_HARDENING: [&str; 13] = [
1582 "NoNewPrivileges=yes",
1583 "CapabilityBoundingSet=",
1584 "AmbientCapabilities=",
1585 "PrivateTmp=yes",
1586 "PrivateDevices=yes",
1587 "ProtectSystem=strict",
1588 "ProtectKernelTunables=yes",
1589 "ProtectKernelModules=yes",
1590 "ProtectControlGroups=yes",
1591 "RestrictNamespaces=yes",
1592 "RestrictRealtime=yes",
1593 "RestrictSUIDSGID=yes",
1594 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1595];
1596
1597#[must_use]
1606pub fn launchd_plist(plan: &InstallPlan) -> String {
1607 let identity = plan.identity();
1608 let directories = plan.directories();
1609 let mut out = String::new();
1610 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1611 out.push_str(
1612 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1613 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1614 );
1615 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1616 out.push_str(&plist_string("Label", &identity.launchd_label()));
1617
1618 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1619 out.push_str(&format!(
1620 " <string>{}</string>\n",
1621 xml_escape(&plan.binary().to_string_lossy())
1622 ));
1623 for argument in plan.arguments() {
1624 out.push_str(&format!(
1625 " <string>{}</string>\n",
1626 xml_escape(&argument.to_string_lossy())
1627 ));
1628 }
1629 out.push_str(" </array>\n");
1630
1631 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1632 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1633 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1634 out.push_str(" </dict>\n");
1635 out.push_str(&format!(
1636 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1637 plan.restart().delay().as_secs()
1638 ));
1639 out.push_str(&plist_string("ProcessType", "Background"));
1642 out.push_str(&plist_string(
1643 "WorkingDirectory",
1644 &directories.state.to_string_lossy(),
1645 ));
1646 out.push_str(&plist_string(
1647 "StandardOutPath",
1648 &directories
1649 .logs
1650 .join("runner-manager.launchd.out.log")
1651 .to_string_lossy(),
1652 ));
1653 out.push_str(&plist_string(
1654 "StandardErrorPath",
1655 &directories
1656 .logs
1657 .join("runner-manager.launchd.err.log")
1658 .to_string_lossy(),
1659 ));
1660
1661 match plan.start_mode() {
1662 StartMode::Boot => {
1663 out.push_str(&plist_string(
1666 "UserName",
1667 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1668 .as_str(),
1669 ));
1670 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1672 }
1673 StartMode::Login => {
1674 }
1678 }
1679
1680 out.push_str("</dict>\n</plist>\n");
1681 out
1682}
1683
1684fn plist_string(key: &str, value: &str) -> String {
1686 format!(
1687 " <key>{}</key>\n <string>{}</string>\n",
1688 xml_escape(key),
1689 xml_escape(value)
1690 )
1691}
1692
1693#[derive(Debug, Clone, PartialEq, Eq)]
1701pub struct TaskPrincipal {
1702 user_id: String,
1703}
1704
1705impl TaskPrincipal {
1706 pub fn current() -> Result<Self, ServiceError> {
1714 let user = std::env::var("USERNAME")
1715 .ok()
1716 .filter(|value| !value.trim().is_empty());
1717 let Some(user) = user else {
1718 return Err(ServiceError::Control {
1719 operation: "identify the account for",
1720 name: SERVICE_NAME.to_string(),
1721 manager: "Windows Task Scheduler",
1722 detail: "this session reports no %USERNAME%, so there is no principal to \
1723 register a logon-triggered task for"
1724 .to_string(),
1725 });
1726 };
1727 let domain = std::env::var("USERDOMAIN")
1728 .ok()
1729 .filter(|value| !value.trim().is_empty());
1730 Ok(Self {
1731 user_id: match domain {
1732 Some(domain) => format!("{domain}\\{user}"),
1733 None => user,
1734 },
1735 })
1736 }
1737
1738 #[must_use]
1740 pub fn named(user_id: impl Into<String>) -> Self {
1741 Self {
1742 user_id: user_id.into(),
1743 }
1744 }
1745
1746 #[must_use]
1748 pub fn user_id(&self) -> &str {
1749 &self.user_id
1750 }
1751}
1752
1753#[must_use]
1760pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1761 let identity = plan.identity();
1762 let user = xml_escape(principal.user_id());
1763 let arguments = plan
1764 .arguments()
1765 .iter()
1766 .map(|argument| quote_argument(&argument.to_string_lossy()))
1767 .collect::<Vec<_>>()
1768 .join(" ");
1769 let mut out = String::new();
1770 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1771 out.push_str(
1772 "<Task version=\"1.4\" \
1773 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1774 );
1775 out.push_str(" <RegistrationInfo>\n");
1776 out.push_str(&format!(
1777 " <Description>{}</Description>\n",
1778 xml_escape(identity.description())
1779 ));
1780 out.push_str(&format!(
1781 " <URI>\\{}</URI>\n",
1782 xml_escape(identity.name())
1783 ));
1784 out.push_str(" </RegistrationInfo>\n");
1785
1786 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1787 out.push_str(" <Enabled>true</Enabled>\n");
1788 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1789 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1790
1791 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1792 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1793 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1794 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1795 out.push_str(" </Principal>\n </Principals>\n");
1796
1797 out.push_str(" <Settings>\n");
1798 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1802 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1803 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1804 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1805 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1806 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1807 out.push_str(" <IdleSettings>\n");
1808 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1809 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1810 out.push_str(" </IdleSettings>\n");
1811 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1812 out.push_str(" <Enabled>true</Enabled>\n");
1813 out.push_str(" <Hidden>false</Hidden>\n");
1814 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1815 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1816 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1818 out.push_str(" <Priority>7</Priority>\n");
1819 out.push_str(" <RestartOnFailure>\n");
1820 out.push_str(&format!(
1821 " <Interval>{}</Interval>\n",
1822 iso8601_minutes(
1823 plan.restart()
1824 .effective_delay(DefinitionKind::WindowsScheduledTask)
1825 )
1826 ));
1827 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1828 out.push_str(" </RestartOnFailure>\n");
1829 out.push_str(" </Settings>\n");
1830
1831 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1832 out.push_str(&format!(
1833 " <Command>{}</Command>\n",
1834 xml_escape(&plan.binary().to_string_lossy())
1835 ));
1836 if !arguments.is_empty() {
1837 out.push_str(&format!(
1838 " <Arguments>{}</Arguments>\n",
1839 xml_escape(&arguments)
1840 ));
1841 }
1842 out.push_str(&format!(
1843 " <WorkingDirectory>{}</WorkingDirectory>\n",
1844 xml_escape(&plan.directories().state.to_string_lossy())
1845 ));
1846 out.push_str(" </Exec>\n </Actions>\n");
1847 out.push_str("</Task>\n");
1848 out
1849}
1850
1851fn iso8601_minutes(duration: Duration) -> String {
1854 format!("PT{}M", duration.as_secs() / 60)
1855}
1856
1857pub(crate) fn xml_escape(value: &str) -> String {
1865 let mut out = String::with_capacity(value.len());
1866 for c in value.chars() {
1867 match c {
1868 '&' => out.push_str("&"),
1869 '<' => out.push_str("<"),
1870 '>' => out.push_str(">"),
1871 '"' => out.push_str("""),
1872 '\'' => out.push_str("'"),
1873 other => out.push(other),
1874 }
1875 }
1876 out
1877}
1878
1879fn xml_unescape(value: &str) -> String {
1885 value
1886 .replace("<", "<")
1887 .replace(">", ">")
1888 .replace(""", "\"")
1889 .replace("'", "'")
1890 .replace("&", "&")
1891}
1892
1893#[derive(Debug, Clone, PartialEq, Eq)]
1902pub struct WindowsServiceSpec {
1903 pub name: String,
1905 pub display_name: String,
1907 pub description: String,
1909 pub automatic_start: bool,
1911 pub account: Option<String>,
1914 pub command_line: String,
1916 pub restart: RestartPolicy,
1918}
1919
1920#[must_use]
1922pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1923 WindowsServiceSpec {
1924 name: plan.identity().name().to_string(),
1925 display_name: plan.identity().display_name().to_string(),
1926 description: plan.identity().description().to_string(),
1927 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1932 account: match ServiceAccount::for_definition(
1933 DefinitionKind::WindowsService,
1934 plan.start_mode(),
1935 ) {
1936 ServiceAccount::LocalSystem => None,
1939 other => Some(other.as_str().to_string()),
1940 },
1941 command_line: plan.command_line(),
1942 restart: plan.restart(),
1943 }
1944}
1945
1946#[must_use]
1949fn windows_service_descriptor(plan: &InstallPlan) -> String {
1950 let spec = windows_service_spec(plan);
1951 let mut out = String::new();
1952 out.push_str("[windows-service]\n");
1953 out.push_str(&format!("Name={}\n", spec.name));
1954 out.push_str(&format!("DisplayName={}\n", spec.display_name));
1955 out.push_str(&format!("Description={}\n", spec.description));
1956 out.push_str("ServiceType=OWN_PROCESS\n");
1960 out.push_str(&format!(
1961 "StartType={}\n",
1962 if spec.automatic_start {
1963 "AutoStart"
1964 } else {
1965 "OnDemand"
1966 }
1967 ));
1968 out.push_str("ErrorControl=Normal\n");
1969 out.push_str(&format!(
1970 "Account={}\n",
1971 spec.account
1972 .as_deref()
1973 .unwrap_or(ServiceAccount::LocalSystem.as_str())
1974 ));
1975 out.push_str(&format!("CommandLine={}\n", spec.command_line));
1976 out.push_str(&format!(
1977 "FailureActionRestartDelaySecs={}\n",
1978 spec.restart.delay().as_secs()
1979 ));
1980 out.push_str(&format!(
1981 "FailureActionsResetPeriodSecs={}\n",
1982 spec.restart.reset_after().as_secs()
1983 ));
1984 out.push_str("FailureActionsOnNonCrashFailures=true\n");
1988 out.push_str(&format!(
1989 "ReadWritePaths={}\n",
1990 plan.directories()
1991 .all()
1992 .iter()
1993 .map(|path| quote_argument(&path.to_string_lossy()))
1994 .collect::<Vec<_>>()
1995 .join(" ")
1996 ));
1997 out
1998}
1999
2000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2006pub enum FindingKind {
2007 Excess,
2010 Shortfall,
2013}
2014
2015impl fmt::Display for FindingKind {
2016 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2017 f.write_str(match self {
2018 Self::Excess => "excess",
2019 Self::Shortfall => "shortfall",
2020 })
2021 }
2022}
2023
2024#[derive(Debug, Clone, PartialEq, Eq)]
2026pub struct PrivilegeFinding {
2027 pub kind: FindingKind,
2029 pub subject: String,
2031 pub detail: String,
2033}
2034
2035impl fmt::Display for PrivilegeFinding {
2036 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2037 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2038 }
2039}
2040
2041#[derive(Debug, Clone, PartialEq, Eq)]
2055pub struct PrivilegeReview {
2056 kind: DefinitionKind,
2057 account: ServiceAccount,
2058 controls: Vec<String>,
2059 findings: Vec<PrivilegeFinding>,
2060}
2061
2062impl PrivilegeReview {
2063 #[must_use]
2065 pub fn is_least_privilege(&self) -> bool {
2066 !self
2067 .findings
2068 .iter()
2069 .any(|finding| finding.kind == FindingKind::Excess)
2070 }
2071
2072 #[must_use]
2074 pub fn findings(&self) -> &[PrivilegeFinding] {
2075 &self.findings
2076 }
2077
2078 #[must_use]
2081 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2082 self.findings
2083 .iter()
2084 .filter(|finding| finding.kind == FindingKind::Excess)
2085 .collect()
2086 }
2087
2088 #[must_use]
2094 pub fn controls(&self) -> &[String] {
2095 &self.controls
2096 }
2097
2098 #[must_use]
2100 pub const fn account(&self) -> &ServiceAccount {
2101 &self.account
2102 }
2103
2104 #[must_use]
2106 pub const fn kind(&self) -> DefinitionKind {
2107 self.kind
2108 }
2109}
2110
2111impl fmt::Display for PrivilegeReview {
2112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2113 writeln!(
2114 f,
2115 "{} runs as {} ({})",
2116 self.kind,
2117 self.account,
2118 self.account.justification()
2119 )?;
2120 for control in &self.controls {
2121 writeln!(f, " confirmed {control}")?;
2122 }
2123 for finding in &self.findings {
2124 writeln!(f, " {finding}")?;
2125 }
2126 if self.is_least_privilege() {
2127 write!(f, " verdict least privilege")
2128 } else {
2129 write!(
2130 f,
2131 " verdict NOT least privilege: {} excess(es)",
2132 self.excesses().len()
2133 )
2134 }
2135 }
2136}
2137
2138#[must_use]
2144pub fn review_least_privilege(
2145 definition: &ServiceDefinition,
2146 plan: &InstallPlan,
2147) -> PrivilegeReview {
2148 let mut controls = Vec::new();
2149 let mut findings = Vec::new();
2150 match definition.kind() {
2151 DefinitionKind::SystemdUnit => {
2152 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2153 }
2154 DefinitionKind::LaunchdPlist => {
2155 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2156 }
2157 DefinitionKind::WindowsScheduledTask => {
2158 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2159 }
2160 DefinitionKind::WindowsService => {
2161 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2162 }
2163 }
2164 PrivilegeReview {
2165 kind: definition.kind(),
2166 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2171 controls,
2172 findings,
2173 }
2174}
2175
2176fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2178 let mut permitted = plan
2179 .directories()
2180 .all()
2181 .iter()
2182 .map(|path| path.to_string_lossy().into_owned())
2183 .collect::<Vec<_>>();
2184 if kind == DefinitionKind::SystemdUnit
2185 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2186 {
2187 permitted.push(secret_directory.to_string_lossy().into_owned());
2188 }
2189 permitted
2190}
2191
2192fn same_path_text(left: &str, right: &str) -> bool {
2199 if cfg!(windows) {
2200 left.eq_ignore_ascii_case(right)
2201 } else {
2202 left == right
2203 }
2204}
2205
2206fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2209 match kind {
2210 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2211 left.eq_ignore_ascii_case(right)
2212 }
2213 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2214 }
2215}
2216
2217fn review_writable_paths(
2219 kind: DefinitionKind,
2220 subject: &str,
2221 listed: &[String],
2222 plan: &InstallPlan,
2223 controls: &mut Vec<String>,
2224 findings: &mut Vec<PrivilegeFinding>,
2225) {
2226 let permitted = permitted_paths(kind, plan);
2227 for entry in listed {
2228 if !permitted
2229 .iter()
2230 .any(|allowed| same_path_for(kind, allowed, entry))
2231 {
2232 findings.push(PrivilegeFinding {
2233 kind: FindingKind::Excess,
2234 subject: subject.to_string(),
2235 detail: format!(
2236 "{entry} is writable but is not one of this registration's required paths"
2237 ),
2238 });
2239 }
2240 }
2241 for allowed in &permitted {
2242 if !listed
2243 .iter()
2244 .any(|entry| same_path_for(kind, allowed, entry))
2245 {
2246 findings.push(PrivilegeFinding {
2247 kind: FindingKind::Shortfall,
2248 subject: subject.to_string(),
2249 detail: format!(
2250 "{allowed} is one of this registration's directories but is not writable, \
2251 so the daemon cannot use it"
2252 ),
2253 });
2254 }
2255 }
2256 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2257 controls.push(format!(
2258 "{subject} names exactly the required application-data and credential paths"
2259 ));
2260 }
2261}
2262
2263fn review_inbound_surface(
2271 text: &str,
2272 markers: &[(&str, &str)],
2273 controls: &mut Vec<String>,
2274 findings: &mut Vec<PrivilegeFinding>,
2275) {
2276 let mut clean = true;
2277 for (marker, detail) in markers {
2278 if text.contains(marker) {
2279 clean = false;
2280 findings.push(PrivilegeFinding {
2281 kind: FindingKind::Excess,
2282 subject: (*marker).to_string(),
2283 detail: (*detail).to_string(),
2284 });
2285 }
2286 }
2287 if clean {
2288 controls.push(
2289 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2290 );
2291 }
2292}
2293
2294fn review_systemd(
2295 text: &str,
2296 plan: &InstallPlan,
2297 controls: &mut Vec<String>,
2298 findings: &mut Vec<PrivilegeFinding>,
2299) {
2300 let directives = ini_directives(text, "Service");
2301 for expected in SYSTEMD_HARDENING {
2302 let (key, value) = expected
2303 .split_once('=')
2304 .expect("every hardening directive is written as key=value");
2305 match directives.get(key) {
2306 Some(actual) if actual == value => controls.push((*expected).to_string()),
2307 Some(actual) => findings.push(PrivilegeFinding {
2308 kind: FindingKind::Excess,
2309 subject: key.to_string(),
2310 detail: format!(
2311 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2312 requirement does not ask for"
2313 ),
2314 }),
2315 None => findings.push(PrivilegeFinding {
2316 kind: FindingKind::Excess,
2317 subject: key.to_string(),
2318 detail: format!(
2319 "is absent, so the unit inherits systemd's default rather than `{value}`"
2320 ),
2321 }),
2322 }
2323 }
2324
2325 match directives.get("ReadWritePaths") {
2326 Some(value) => {
2327 let listed = split_quoted(value);
2328 review_writable_paths(
2329 DefinitionKind::SystemdUnit,
2330 "ReadWritePaths",
2331 &listed,
2332 plan,
2333 controls,
2334 findings,
2335 );
2336 }
2337 None => findings.push(PrivilegeFinding {
2338 kind: FindingKind::Shortfall,
2339 subject: "ReadWritePaths".to_string(),
2340 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2341 .to_string(),
2342 }),
2343 }
2344
2345 if directives.contains_key("PrivateUsers")
2348 && directives.get("PrivateUsers") == Some(&"no".to_string())
2349 {
2350 findings.push(PrivilegeFinding {
2351 kind: FindingKind::Excess,
2352 subject: "PrivateUsers".to_string(),
2353 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2354 .to_string(),
2355 });
2356 }
2357
2358 review_inbound_surface(
2359 text,
2360 &[
2361 (
2362 "ListenStream=",
2363 "asks systemd to open a listening socket for this service, which \
2364 07-security.md rule 2 forbids the product to have",
2365 ),
2366 (
2367 "ListenDatagram=",
2368 "asks systemd to open a listening socket for this service, which \
2369 07-security.md rule 2 forbids the product to have",
2370 ),
2371 ],
2372 controls,
2373 findings,
2374 );
2375}
2376
2377fn review_launchd(
2378 text: &str,
2379 plan: &InstallPlan,
2380 controls: &mut Vec<String>,
2381 findings: &mut Vec<PrivilegeFinding>,
2382) {
2383 match plist_string_value(text, "ProcessType").as_deref() {
2384 Some("Background") => controls.push("ProcessType=Background".to_string()),
2385 Some(other) => findings.push(PrivilegeFinding {
2386 kind: FindingKind::Excess,
2387 subject: "ProcessType".to_string(),
2388 detail: format!(
2389 "is `{other}`, which asks the scheduler for more CPU and I/O than a background \
2390 daemon needs"
2391 ),
2392 }),
2393 None => findings.push(PrivilegeFinding {
2394 kind: FindingKind::Excess,
2395 subject: "ProcessType".to_string(),
2396 detail: "is absent, so launchd applies its `Standard` default rather than \
2397 `Background`"
2398 .to_string(),
2399 }),
2400 }
2401
2402 match plan.start_mode() {
2403 StartMode::Boot => {
2404 if plist_bool_value(text, "SessionCreate") == Some(true) {
2405 findings.push(PrivilegeFinding {
2406 kind: FindingKind::Excess,
2407 subject: "SessionCreate".to_string(),
2408 detail: "asks launchd to create a security session for a job that runs \
2409 outside every login session and has no use for one"
2410 .to_string(),
2411 });
2412 } else {
2413 controls.push("SessionCreate is not requested".to_string());
2414 }
2415 match plist_string_value(text, "UserName").as_deref() {
2416 Some("root") => {
2417 controls.push("UserName=root, stated rather than inherited".to_string())
2418 }
2419 Some(other) => findings.push(PrivilegeFinding {
2420 kind: FindingKind::Shortfall,
2421 subject: "UserName".to_string(),
2422 detail: format!(
2423 "is `{other}`, which cannot unlock the System Keychain: \
2424 /var/db/SystemKey is root-only, so the daemon would start and then \
2425 find no credential"
2426 ),
2427 }),
2428 None => findings.push(PrivilegeFinding {
2429 kind: FindingKind::Shortfall,
2430 subject: "UserName".to_string(),
2431 detail: "is absent, so the account is launchd's implicit default and this \
2432 review cannot confirm it"
2433 .to_string(),
2434 }),
2435 }
2436 }
2437 StartMode::Login => {
2438 if let Some(named) = plist_string_value(text, "UserName") {
2439 findings.push(PrivilegeFinding {
2440 kind: FindingKind::Excess,
2441 subject: "UserName".to_string(),
2442 detail: format!(
2443 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2444 naming an account here asks launchd for a switch a login-mode \
2445 registration has no reason to want"
2446 ),
2447 });
2448 } else {
2449 controls
2450 .push("no UserName: the agent runs as the operator and no other".to_string());
2451 }
2452 }
2453 }
2454
2455 review_inbound_surface(
2456 text,
2457 &[
2458 (
2459 "<key>Sockets</key>",
2460 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2461 forbids the product to have",
2462 ),
2463 (
2464 "<key>MachServices</key>",
2465 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2466 forbids the product to have",
2467 ),
2468 ],
2469 controls,
2470 findings,
2471 );
2472}
2473
2474fn review_scheduled_task(
2475 text: &str,
2476 controls: &mut Vec<String>,
2477 findings: &mut Vec<PrivilegeFinding>,
2478) {
2479 match xml_value(text, "RunLevel").as_deref() {
2480 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2481 Some(other) => findings.push(PrivilegeFinding {
2482 kind: FindingKind::Excess,
2483 subject: "RunLevel".to_string(),
2484 detail: format!(
2485 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2486 an administrator"
2487 ),
2488 }),
2489 None => findings.push(PrivilegeFinding {
2490 kind: FindingKind::Excess,
2491 subject: "RunLevel".to_string(),
2492 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2493 .to_string(),
2494 }),
2495 }
2496
2497 match xml_value(text, "LogonType").as_deref() {
2498 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2499 Some(other) => findings.push(PrivilegeFinding {
2500 kind: FindingKind::Excess,
2501 subject: "LogonType".to_string(),
2502 detail: format!(
2503 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2504 task; an interactive token needs neither"
2505 ),
2506 }),
2507 None => findings.push(PrivilegeFinding {
2508 kind: FindingKind::Shortfall,
2509 subject: "LogonType".to_string(),
2510 detail: "is absent, so this review cannot confirm that no credential is stored"
2511 .to_string(),
2512 }),
2513 }
2514}
2515
2516fn review_windows_service(
2517 text: &str,
2518 plan: &InstallPlan,
2519 controls: &mut Vec<String>,
2520 findings: &mut Vec<PrivilegeFinding>,
2521) {
2522 let directives = ini_directives(text, "windows-service");
2523
2524 match directives.get("ServiceType").map(String::as_str) {
2525 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2526 Some(other) => findings.push(PrivilegeFinding {
2527 kind: FindingKind::Excess,
2528 subject: "ServiceType".to_string(),
2529 detail: format!(
2530 "is `{other}`; an interactive or shared-process service reaches further than a \
2531 daemon that only talks to GitHub over HTTPS"
2532 ),
2533 }),
2534 None => findings.push(PrivilegeFinding {
2535 kind: FindingKind::Shortfall,
2536 subject: "ServiceType".to_string(),
2537 detail: "is absent, so this review cannot confirm the service is not interactive"
2538 .to_string(),
2539 }),
2540 }
2541
2542 match directives.get("Account").map(String::as_str) {
2546 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2547 controls.push(format!(
2548 "Account={account}: the only stock account the machine-scoped store's DACL \
2549 (SY, BA, OW) admits"
2550 ));
2551 }
2552 Some(other) => findings.push(PrivilegeFinding {
2553 kind: FindingKind::Shortfall,
2554 subject: "Account".to_string(),
2555 detail: format!(
2556 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2557 daemon would start and then find no credential. Widening that DACL is not this \
2558 registration's to do: an ACE reaching `{other}` would also reach every other \
2559 service running under it"
2560 ),
2561 }),
2562 None => findings.push(PrivilegeFinding {
2563 kind: FindingKind::Shortfall,
2564 subject: "Account".to_string(),
2565 detail: "is absent, so this review cannot confirm which account was registered"
2566 .to_string(),
2567 }),
2568 }
2569
2570 match directives.get("ReadWritePaths") {
2571 Some(value) => {
2572 let listed = split_quoted(value);
2573 review_writable_paths(
2574 DefinitionKind::WindowsService,
2575 "ReadWritePaths",
2576 &listed,
2577 plan,
2578 controls,
2579 findings,
2580 );
2581 }
2582 None => findings.push(PrivilegeFinding {
2583 kind: FindingKind::Shortfall,
2584 subject: "ReadWritePaths".to_string(),
2585 detail: "is absent, so the directories the service was installed against are not \
2586 recorded"
2587 .to_string(),
2588 }),
2589 }
2590}
2591
2592fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2600 let mut out = BTreeMap::new();
2601 let mut inside = false;
2602 for line in text.lines() {
2603 let line = line.trim();
2604 if line.starts_with('[') && line.ends_with(']') {
2605 inside = &line[1..line.len() - 1] == section;
2606 continue;
2607 }
2608 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2609 continue;
2610 }
2611 if let Some((key, value)) = line.split_once('=') {
2612 out.insert(key.trim().to_string(), value.trim().to_string());
2613 }
2614 }
2615 out
2616}
2617
2618fn split_quoted(value: &str) -> Vec<String> {
2621 let mut out = Vec::new();
2622 let mut rest = value.trim();
2623 while !rest.is_empty() {
2624 if rest.starts_with('"') {
2625 if let Some(parsed) = executable_from_command_line(rest) {
2628 out.push(parsed.to_string_lossy().into_owned());
2629 }
2630 let mut depth = 0usize;
2632 let mut end = rest.len();
2633 for (index, c) in rest.char_indices() {
2634 match c {
2635 '\\' => depth += 1,
2636 '"' => {
2637 if depth.is_multiple_of(2) && index > 0 {
2638 end = index + 1;
2639 break;
2640 }
2641 depth = 0;
2642 }
2643 _ => depth = 0,
2644 }
2645 }
2646 rest = rest[end..].trim_start();
2647 } else {
2648 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2649 out.push(rest[..end].to_string());
2650 rest = rest[end..].trim_start();
2651 }
2652 }
2653 out
2654}
2655
2656pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2668 let open = format!("<{tag}>");
2669 let close = format!("</{tag}>");
2670 let start = text.find(&open)? + open.len();
2671 let end = text[start..].find(&close)? + start;
2672 Some(xml_unescape(text[start..end].trim()))
2673}
2674
2675#[cfg(any(windows, test))]
2681fn windows_login_task_starts_automatically(document: &str) -> bool {
2682 document.contains("<LogonTrigger>")
2683 && xml_value(document, "Enabled").as_deref() != Some("false")
2684}
2685
2686fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2688 let marker = format!("<key>{key}</key>");
2689 let start = text.find(&marker)? + marker.len();
2690 Some(text[start..].trim_start())
2691}
2692
2693fn plist_string_value(text: &str, key: &str) -> Option<String> {
2694 let rest = plist_value_after_key(text, key)?;
2695 if !rest.starts_with("<string>") {
2696 return None;
2697 }
2698 xml_value(rest, "string")
2699}
2700
2701fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2702 let rest = plist_value_after_key(text, key)?;
2703 if rest.starts_with("<true/>") {
2704 Some(true)
2705 } else if rest.starts_with("<false/>") {
2706 Some(false)
2707 } else {
2708 None
2709 }
2710}
2711
2712pub const RECORD_SCHEMA_VERSION: u32 = 1;
2723
2724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2736pub struct InstallRecord {
2737 pub schema_version: u32,
2739 pub service_name: String,
2741 pub manager: String,
2743 pub start_mode: StartMode,
2745 pub account: ServiceAccount,
2747 pub binary: PathBuf,
2749 #[serde(default)]
2757 pub source_binary: Option<PathBuf>,
2758 pub arguments: Vec<String>,
2760 pub restart_delay_secs: u64,
2762 pub restart_reset_secs: u64,
2764 pub log_file: PathBuf,
2766 #[serde(default)]
2774 pub starts_on_demand: bool,
2775 pub definition_path: Option<PathBuf>,
2777 pub installed_at: DateTime<Utc>,
2779 pub installed_by_version: String,
2781 pub directories: ServiceDirectories,
2786}
2787
2788impl InstallRecord {
2789 #[must_use]
2791 pub fn path(paths: &AppPaths) -> PathBuf {
2792 paths.config_dir().join(RECORD_FILE)
2793 }
2794
2795 #[must_use]
2797 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2798 Self {
2799 schema_version: RECORD_SCHEMA_VERSION,
2800 service_name: plan.identity().name().to_string(),
2801 manager: definition.kind().manager().to_string(),
2802 start_mode: plan.start_mode(),
2803 account: plan.account().clone(),
2804 binary: plan.binary().to_path_buf(),
2805 arguments: plan
2806 .arguments()
2807 .iter()
2808 .map(|argument| argument.to_string_lossy().into_owned())
2809 .collect(),
2810 restart_delay_secs: plan.restart().delay().as_secs(),
2811 restart_reset_secs: plan.restart().reset_after().as_secs(),
2812 starts_on_demand: plan.is_on_demand(),
2813 source_binary: plan.source_binary().map(Path::to_path_buf),
2814 log_file: plan.directories().log_file(),
2815 definition_path: definition.install_path().map(Path::to_path_buf),
2816 installed_at: at,
2817 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2818 directories: plan.directories().clone(),
2819 }
2820 }
2821
2822 #[must_use]
2826 pub fn restart(&self) -> RestartPolicy {
2827 RestartPolicy::new(
2828 Duration::from_secs(self.restart_delay_secs),
2829 Duration::from_secs(self.restart_reset_secs),
2830 )
2831 .unwrap_or_default()
2832 }
2833
2834 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2845 let path = Self::path(paths);
2846 let text = match std::fs::read_to_string(&path) {
2847 Ok(text) => text,
2848 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2849 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2852 return Err(ServiceError::RecordNotPermitted {
2853 path,
2854 detail: error.to_string(),
2855 });
2856 }
2857 Err(error) => {
2858 return Err(ServiceError::Record {
2859 operation: "read",
2860 path,
2861 detail: error.to_string(),
2862 });
2863 }
2864 };
2865 let record: Self =
2866 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2867 path: path.clone(),
2868 detail: error.to_string(),
2869 })?;
2870 if record.schema_version != RECORD_SCHEMA_VERSION {
2871 return Err(ServiceError::RecordUnreadable {
2872 path,
2873 detail: format!(
2874 "it declares schema version {} and this build reads version {}",
2875 record.schema_version, RECORD_SCHEMA_VERSION
2876 ),
2877 });
2878 }
2879 Ok(Some(record))
2880 }
2881
2882 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2888 use std::io::Write as _;
2889
2890 let path = Self::path(paths);
2891 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2892 operation: "encode",
2893 path: path.clone(),
2894 detail: error.to_string(),
2895 })?;
2896 if let Some(parent) = path.parent() {
2897 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2898 operation: "write",
2899 path: path.clone(),
2900 detail: error.to_string(),
2901 })?;
2902 }
2903 let parent = path.parent().ok_or_else(|| ServiceError::Record {
2904 operation: "write",
2905 path: path.clone(),
2906 detail: "the record path has no parent directory".to_string(),
2907 })?;
2908 let mut temporary =
2909 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2910 operation: "write",
2911 path: path.clone(),
2912 detail: error.to_string(),
2913 })?;
2914 temporary
2915 .write_all(text.as_bytes())
2916 .and_then(|()| temporary.as_file().sync_all())
2917 .map_err(|error| ServiceError::Record {
2918 operation: "write",
2919 path: path.clone(),
2920 detail: error.to_string(),
2921 })?;
2922 #[cfg(unix)]
2938 {
2939 use std::os::unix::fs::PermissionsExt as _;
2940
2941 temporary
2942 .as_file()
2943 .set_permissions(std::fs::Permissions::from_mode(0o644))
2944 .map_err(|error| ServiceError::Record {
2945 operation: "write",
2946 path: path.clone(),
2947 detail: error.to_string(),
2948 })?;
2949 }
2950 temporary
2951 .persist(&path)
2952 .map(|_| ())
2953 .map_err(|error| ServiceError::Record {
2954 operation: "write",
2955 path,
2956 detail: error.error.to_string(),
2957 })
2958 }
2959
2960 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
2966 let path = Self::path(paths);
2967 match std::fs::remove_file(&path) {
2968 Ok(()) => Ok(true),
2969 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
2970 Err(error) => Err(ServiceError::Record {
2971 operation: "remove",
2972 path,
2973 detail: error.to_string(),
2974 }),
2975 }
2976 }
2977}
2978
2979const CONTACT_SCHEMA_VERSION: u32 = 1;
2985
2986#[derive(Debug, Clone, Serialize, Deserialize)]
2987struct ContactRecord {
2988 schema_version: u32,
2989 last_success: DateTime<Utc>,
2990}
2991
2992pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3013 let path = contact_path(paths);
3014 let record = ContactRecord {
3015 schema_version: CONTACT_SCHEMA_VERSION,
3016 last_success: at,
3017 };
3018 let failed = |detail: String| ServiceError::Record {
3019 operation: "write",
3020 path: path.clone(),
3021 detail,
3022 };
3023 let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3024 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3025 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3026 let temporary = path.with_extension("toml.new");
3027 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3028 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3029}
3030
3031pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3043 let path = contact_path(paths);
3044 let text = match std::fs::read_to_string(&path) {
3045 Ok(text) => text,
3046 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3047 Err(error) => {
3048 return Err(ServiceError::Record {
3049 operation: "read",
3050 path,
3051 detail: error.to_string(),
3052 });
3053 }
3054 };
3055 let record: ContactRecord = toml::from_str(&text).map_err(|error| ServiceError::Record {
3056 operation: "read",
3057 path,
3058 detail: error.to_string(),
3059 })?;
3060 Ok(Some(record.last_success))
3061}
3062
3063#[must_use]
3065pub fn contact_path(paths: &AppPaths) -> PathBuf {
3066 paths.state_dir().join(CONTACT_FILE)
3067}
3068
3069const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3074
3075#[derive(Debug, Clone, Serialize, Deserialize)]
3076struct RootRefusalFile {
3077 schema_version: u32,
3078 #[serde(default)]
3080 refusals: BTreeMap<String, RootRefusalEntry>,
3081}
3082
3083#[derive(Debug, Clone, Serialize, Deserialize)]
3084struct RootRefusalEntry {
3085 at: DateTime<Utc>,
3086 kind: String,
3087 root: String,
3088 detail: String,
3089}
3090
3091#[derive(Debug, Clone, PartialEq, Eq)]
3093pub struct RunnerRootRefusal {
3094 pub policy: String,
3096 pub at: DateTime<Utc>,
3098 pub kind: String,
3100 pub root: String,
3102 pub detail: String,
3105}
3106
3107pub fn record_runner_root_refusal(
3141 paths: &AppPaths,
3142 policy: &str,
3143 at: DateTime<Utc>,
3144 kind: &str,
3145 root: &str,
3146 detail: &str,
3147) -> Result<(), ServiceError> {
3148 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3149 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3150 refusals: BTreeMap::new(),
3151 });
3152 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3153 file.refusals.insert(
3154 policy.to_owned(),
3155 RootRefusalEntry {
3156 at,
3157 kind: kind.to_owned(),
3158 root: root.to_owned(),
3159 detail: detail.to_owned(),
3160 },
3161 );
3162 write_refusal_file(paths, &file)
3163}
3164
3165pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3176 let Some(mut file) = read_refusal_file(paths)? else {
3177 return Ok(());
3178 };
3179 if file.refusals.remove(policy).is_none() {
3180 return Ok(());
3181 }
3182 if file.refusals.is_empty() {
3183 let path = root_refusal_path(paths);
3184 return match std::fs::remove_file(&path) {
3185 Ok(()) => Ok(()),
3186 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3187 Err(error) => Err(ServiceError::Record {
3188 operation: "remove",
3189 path,
3190 detail: error.to_string(),
3191 }),
3192 };
3193 }
3194 write_refusal_file(paths, &file)
3195}
3196
3197pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3209 Ok(read_refusal_file(paths)?
3210 .map(|file| {
3211 file.refusals
3212 .into_iter()
3213 .map(|(policy, entry)| RunnerRootRefusal {
3214 policy,
3215 at: entry.at,
3216 kind: entry.kind,
3217 root: entry.root,
3218 detail: entry.detail,
3219 })
3220 .collect()
3221 })
3222 .unwrap_or_default())
3223}
3224
3225fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3226 let path = root_refusal_path(paths);
3227 let text = match std::fs::read_to_string(&path) {
3228 Ok(text) => text,
3229 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3230 Err(error) => {
3231 return Err(ServiceError::Record {
3232 operation: "read",
3233 path,
3234 detail: error.to_string(),
3235 });
3236 }
3237 };
3238 toml::from_str(&text)
3239 .map(Some)
3240 .map_err(|error| ServiceError::Record {
3241 operation: "read",
3242 path,
3243 detail: error.to_string(),
3244 })
3245}
3246
3247fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3248 let path = root_refusal_path(paths);
3249 let failed = |detail: String| ServiceError::Record {
3250 operation: "write",
3251 path: path.clone(),
3252 detail,
3253 };
3254 let text = toml::to_string_pretty(file).map_err(|error| failed(error.to_string()))?;
3255 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3256 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3257 let temporary = path.with_extension("toml.new");
3258 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3259 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3260}
3261
3262#[must_use]
3264pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3265 paths.state_dir().join(ROOT_REFUSAL_FILE)
3266}
3267
3268#[derive(Debug, Clone, PartialEq, Eq)]
3279pub enum BinaryPath {
3280 Current {
3282 path: PathBuf,
3284 },
3285 Missing {
3292 recorded: PathBuf,
3294 },
3295 NotExecutable {
3298 recorded: PathBuf,
3300 detail: String,
3302 },
3303 Diverged {
3305 recorded: PathBuf,
3307 registered: PathBuf,
3309 },
3310}
3311
3312impl BinaryPath {
3313 #[must_use]
3315 pub const fn is_error(&self) -> bool {
3316 !matches!(self, Self::Current { .. })
3317 }
3318
3319 #[must_use]
3321 pub fn recorded(&self) -> &Path {
3322 match self {
3323 Self::Current { path } => path,
3324 Self::Missing { recorded }
3325 | Self::NotExecutable { recorded, .. }
3326 | Self::Diverged { recorded, .. } => recorded,
3327 }
3328 }
3329}
3330
3331impl fmt::Display for BinaryPath {
3332 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3333 match self {
3334 Self::Current { path } => write!(f, "{}", path.display()),
3335 Self::Missing { recorded } => write!(
3336 f,
3337 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3338 package manager that moved the binary is the usual cause; an `npm i -g` \
3339 installation moves with the active Node version. Run `service install` again \
3340 from the binary that is now installed.",
3341 recorded.display()
3342 ),
3343 Self::NotExecutable { recorded, detail } => write!(
3344 f,
3345 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3346 from the installed binary.",
3347 recorded.display()
3348 ),
3349 Self::Diverged {
3350 recorded,
3351 registered,
3352 } => write!(
3353 f,
3354 "{} -- STALE: the service manager is registered to start {} instead. Something \
3355 has edited the registration since it was installed. Run `service uninstall` and \
3356 `service install`; neither touches configuration, secrets, or the cache.",
3357 recorded.display(),
3358 registered.display()
3359 ),
3360 }
3361 }
3362}
3363
3364#[must_use]
3372pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3373 match std::fs::metadata(recorded) {
3374 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3375 return BinaryPath::Missing {
3376 recorded: recorded.to_path_buf(),
3377 };
3378 }
3379 Err(error) => {
3380 return BinaryPath::NotExecutable {
3381 recorded: recorded.to_path_buf(),
3382 detail: format!("it cannot be inspected ({error})"),
3383 };
3384 }
3385 Ok(metadata) if !metadata.is_file() => {
3386 return BinaryPath::NotExecutable {
3387 recorded: recorded.to_path_buf(),
3388 detail: "what is there is not a file".to_string(),
3389 };
3390 }
3391 Ok(_) => {}
3392 }
3393 if let Some(registered) = registered
3394 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3395 {
3396 return BinaryPath::Diverged {
3397 recorded: recorded.to_path_buf(),
3398 registered: registered.to_path_buf(),
3399 };
3400 }
3401 BinaryPath::Current {
3402 path: recorded.to_path_buf(),
3403 }
3404}
3405
3406#[derive(Debug, Clone, PartialEq, Eq)]
3412pub struct Registration {
3413 pub manager: DefinitionKind,
3418 pub start_mode: StartMode,
3420 pub command_line: String,
3422 pub account: Option<String>,
3424 pub running: bool,
3426 pub starts_automatically: bool,
3433 pub restart_delay: Option<Duration>,
3442}
3443
3444impl Registration {
3445 #[must_use]
3447 pub fn binary(&self) -> Option<PathBuf> {
3448 executable_from_command_line(&self.command_line)
3449 }
3450}
3451
3452pub trait ServiceControl: fmt::Debug {
3458 fn manager(&self) -> DefinitionKind;
3460
3461 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3467
3468 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3479
3480 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3486
3487 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3494
3495 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3501}
3502
3503pub trait ControlFactory: fmt::Debug + Send + Sync {
3509 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3515}
3516
3517#[derive(Debug, Clone, Copy, Default)]
3519pub struct HostControls;
3520
3521#[derive(Debug, Clone)]
3527pub struct Installed {
3528 pub plan: InstallPlan,
3530 pub definition: ServiceDefinition,
3532 pub record: InstallRecord,
3534 pub review: PrivilegeReview,
3536 pub runner_root: RootAccessSummary,
3541 pub replaced_existing: bool,
3549}
3550
3551#[derive(Debug, Clone, PartialEq, Eq)]
3553pub struct Uninstalled {
3554 pub removed_registration: bool,
3556 pub removed_record: bool,
3558 pub removed_definition: Option<PathBuf>,
3560 pub preserved: Vec<PathBuf>,
3567}
3568
3569impl fmt::Display for Uninstalled {
3570 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3571 if self.removed_registration {
3572 writeln!(f, "The service registration was removed.")?;
3573 } else {
3574 writeln!(f, "There was no service registration to remove.")?;
3575 }
3576 writeln!(f, "Nothing else was deleted. These are untouched:")?;
3577 for path in &self.preserved {
3578 writeln!(f, " {}", path.display())?;
3579 }
3580 write!(
3581 f,
3582 "The stored GitHub token is untouched too; `auth logout` is what purges it."
3583 )
3584 }
3585}
3586
3587#[derive(Debug, Clone, PartialEq, Eq)]
3589pub struct StartModeChange {
3590 pub from: StartMode,
3592 pub to: StartMode,
3594 pub changed: bool,
3596 pub store_scope: crate::secrets::SecretScope,
3599 pub runner_root: RootAccessSummary,
3605}
3606
3607impl fmt::Display for StartModeChange {
3608 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3609 if !self.changed {
3610 return write!(f, "The service already starts at {}.", self.to);
3611 }
3612 write!(
3613 f,
3614 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3615 if the token was stored under the other scope, run `auth login` again. {}",
3616 self.to, self.from, self.store_scope, self.runner_root
3617 )
3618 }
3619}
3620
3621#[derive(Debug, Clone)]
3629pub struct ServiceOperations {
3630 paths: AppPaths,
3631 identity: ServiceIdentity,
3632 controls: std::sync::Arc<dyn ControlFactory>,
3633 runner_root: Option<LocalAbsolutePath>,
3634}
3635
3636impl ServiceOperations {
3637 #[must_use]
3639 pub fn on_this_host(paths: AppPaths) -> Self {
3640 Self::with_controls(
3641 paths,
3642 ServiceIdentity::product(),
3643 std::sync::Arc::new(HostControls),
3644 )
3645 }
3646
3647 #[must_use]
3655 pub fn with_controls(
3656 paths: AppPaths,
3657 identity: ServiceIdentity,
3658 controls: std::sync::Arc<dyn ControlFactory>,
3659 ) -> Self {
3660 Self {
3661 paths,
3662 identity,
3663 controls,
3664 runner_root: None,
3665 }
3666 }
3667
3668 #[must_use]
3687 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
3688 self.runner_root = Some(root);
3689 self
3690 }
3691
3692 #[must_use]
3694 pub const fn paths(&self) -> &AppPaths {
3695 &self.paths
3696 }
3697
3698 #[must_use]
3700 pub const fn identity(&self) -> &ServiceIdentity {
3701 &self.identity
3702 }
3703
3704 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
3724 self.paths
3725 .create_all()
3726 .map_err(|source| ServiceError::Paths {
3727 source: Box::new(source),
3728 })?;
3729
3730 let _guard = self.refuse_while_an_agent_runs()?;
3733
3734 let (replacing, previous_was_running) = match self.find_registration()? {
3763 Some((existing, registration)) if existing == request.start_mode() => {
3764 (true, registration.running)
3765 }
3766 Some((existing, _)) => {
3767 return Err(ServiceError::AlreadyInstalled {
3768 name: self.identity.name().to_string(),
3769 existing,
3770 requested: request.start_mode(),
3771 });
3772 }
3773 None => (false, false),
3774 };
3775
3776 let plan = InstallPlan::resolve(
3777 self.identity.clone(),
3778 request,
3779 ServiceDirectories::of(&self.paths),
3780 )?;
3781
3782 let control = self.controls.control(plan.start_mode())?;
3787
3788 let root = self.prepare_runner_root(plan.start_mode())?;
3794
3795 let previous = if replacing {
3800 InstallRecord::read(&self.paths).ok().flatten()
3801 } else {
3802 None
3803 };
3804 if replacing && let Err(cause) = control.uninstall(&self.identity) {
3805 return Err(undo_runner_root(&root, "install", &self.identity, cause));
3806 }
3807
3808 let definition = match control.install(&plan) {
3809 Ok(definition) => definition,
3810 Err(cause) => {
3811 let restored =
3814 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
3815 return Err(rolled_back(
3816 retained_runner_root(&root),
3817 restored,
3818 "install",
3819 &self.identity,
3820 cause,
3821 ));
3822 }
3823 };
3824 let review = review_least_privilege(&definition, &plan);
3825 let record = InstallRecord::of(&plan, &definition, Utc::now());
3826 if let Err(cause) = record.write(&self.paths) {
3827 let rollback = control
3828 .uninstall(&self.identity)
3829 .map(|_| ())
3830 .and_then(|()| {
3831 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
3832 });
3833 return Err(rolled_back(
3834 retained_runner_root(&root),
3835 rollback,
3836 "install",
3837 &self.identity,
3838 cause,
3839 ));
3840 }
3841
3842 if let Err(cause) = control.start(&self.identity) {
3849 let remove_new = control.uninstall(&self.identity).map(|_| ());
3850 let restore_registration = remove_new.and_then(|()| {
3851 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
3852 });
3853 let restore_record = restore_registration.and_then(|()| match &previous {
3854 Some(previous) => previous.write(&self.paths),
3855 None => InstallRecord::remove(&self.paths).map(|_| ()),
3856 });
3857 return Err(rolled_back(
3858 retained_runner_root(&root),
3859 restore_record,
3860 "install and start",
3861 &self.identity,
3862 cause,
3863 ));
3864 }
3865 Ok(Installed {
3866 plan,
3867 definition,
3868 record,
3869 review,
3870 runner_root: root.summary().clone(),
3871 replaced_existing: replacing,
3872 })
3873 }
3874
3875 fn reinstate(
3886 &self,
3887 control: &dyn ServiceControl,
3888 previous: Option<&InstallRecord>,
3889 was_running: bool,
3890 ) -> Result<(), ServiceError> {
3891 let Some(record) = previous else {
3892 return Ok(());
3893 };
3894 let plan = InstallPlan::unchecked(
3895 self.identity.clone(),
3896 record.start_mode,
3897 record.binary.clone(),
3898 record.directories.clone(),
3899 )
3900 .with_arguments(record.arguments.clone())
3901 .with_restart(record.restart());
3902 let plan = if record.starts_on_demand {
3903 plan.started_on_demand()
3904 } else {
3905 plan
3906 };
3907 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode) {
3908 Ok(store) => plan.with_secret_guard(store.guard()),
3909 Err(_) => plan,
3910 };
3911 control.install(&plan)?;
3912 if was_running {
3913 control.start(&self.identity)?;
3914 }
3915 Ok(())
3916 }
3917
3918 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
3927 let record = InstallRecord::read(&self.paths).ok().flatten();
3928 let removed_definition = record
3929 .as_ref()
3930 .and_then(|record| record.definition_path.clone());
3931
3932 let mut removed_registration = false;
3933 for mode in [StartMode::Boot, StartMode::Login] {
3937 let control = self.controls.control(mode)?;
3938 if control.uninstall(&self.identity)? {
3939 removed_registration = true;
3940 }
3941 }
3942 let removed_record = InstallRecord::remove(&self.paths)?;
3943 Ok(Uninstalled {
3944 removed_registration,
3945 removed_record,
3946 removed_definition: removed_definition.filter(|_| removed_registration),
3947 preserved: self
3948 .paths
3949 .all()
3950 .iter()
3951 .map(|(_, path)| (*path).to_path_buf())
3952 .collect(),
3953 })
3954 }
3955
3956 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
3978 let Some(record) = InstallRecord::read(&self.paths)? else {
3979 return Err(ServiceError::NotInstalled {
3980 name: self.identity.name().to_string(),
3981 operation: "switch the start mode of",
3982 });
3983 };
3984 let from = record.start_mode;
3985 if from == to {
3986 return Ok(StartModeChange {
3990 from,
3991 to,
3992 changed: false,
3993 store_scope: crate::secrets::SecretScope::for_start_mode(to),
3994 runner_root: RootAccessSummary::NotApplicable,
3995 });
3996 }
3997
3998 #[cfg(windows)]
3999 let arguments = {
4000 let mut arguments = record.arguments.clone();
4001 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4002 if to == StartMode::Boot {
4003 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4004 }
4005 arguments
4006 };
4007 #[cfg(not(windows))]
4008 let arguments = record.arguments.clone();
4009 let plan = InstallPlan::unchecked(
4010 self.identity.clone(),
4011 to,
4012 record.binary.clone(),
4013 record.directories.clone(),
4014 )
4015 .with_arguments(arguments)
4016 .with_restart(record.restart());
4017 let plan = if record.starts_on_demand {
4018 plan.started_on_demand()
4019 } else {
4020 plan
4021 };
4022 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4023 Ok(store) => plan.with_secret_guard(store.guard()),
4024 Err(_) => plan,
4025 };
4026
4027 let target = self.controls.control(to)?;
4034 let previous = self.controls.control(from)?;
4039
4040 let root = self.prepare_runner_root(to)?;
4048
4049 let definition = match target.install(&plan) {
4050 Ok(definition) => definition,
4051 Err(cause) => {
4052 return Err(undo_runner_root(
4053 &root,
4054 "switch start mode",
4055 &self.identity,
4056 cause,
4057 ));
4058 }
4059 };
4060 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4061 if let Err(cause) = next_record.write(&self.paths) {
4062 return Err(rolled_back(
4063 retained_runner_root(&root),
4064 target.uninstall(&self.identity),
4065 "switch start mode",
4066 &self.identity,
4067 cause,
4068 ));
4069 }
4070
4071 if let Err(cause) = previous.uninstall(&self.identity) {
4075 let target_rollback = target.uninstall(&self.identity);
4076 let record_rollback = record.write(&self.paths);
4077 return Err(rolled_back(
4078 retained_runner_root(&root),
4079 target_rollback.and(record_rollback),
4080 "switch start mode",
4081 &self.identity,
4082 cause,
4083 ));
4084 }
4085 Ok(StartModeChange {
4086 from,
4087 to,
4088 changed: true,
4089 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4090 runner_root: root.summary().clone(),
4091 })
4092 }
4093
4094 pub fn start(&self) -> Result<(), ServiceError> {
4100 let Some((mode, _)) = self.find_registration()? else {
4101 return Err(ServiceError::NotInstalled {
4102 name: self.identity.name().to_string(),
4103 operation: "start",
4104 });
4105 };
4106 self.controls.control(mode)?.start(&self.identity)
4107 }
4108
4109 pub fn stop(&self) -> Result<bool, ServiceError> {
4115 let Some((mode, _)) = self.find_registration()? else {
4116 return Err(ServiceError::NotInstalled {
4117 name: self.identity.name().to_string(),
4118 operation: "stop",
4119 });
4120 };
4121 self.controls.control(mode)?.stop(&self.identity)
4122 }
4123
4124 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4143 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4144 Ok(record) => (record, None),
4145 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4146 Err(error) => return Err(error),
4147 };
4148 let found = self.find_registration()?;
4149 let last_github_contact = last_github_contact(&self.paths)?;
4150 Ok(ServiceStatus::compose(
4151 self.identity.clone(),
4152 record,
4153 record_refused.as_ref(),
4154 found.map(|(_, registration)| registration),
4155 last_github_contact,
4156 &self.paths,
4157 ))
4158 }
4159
4160 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4170 #[cfg(not(windows))]
4171 {
4172 let _ = (mode, &self.runner_root);
4175 Ok(RootAccessChange::not_applicable())
4176 }
4177 #[cfg(windows)]
4178 {
4179 let wrap = |source| ServiceError::RunnerRoot {
4180 source: Box::new(source),
4181 };
4182 if let Some(root) = self
4200 .runner_root
4201 .as_ref()
4202 .filter(|_| self.identity.is_fixture() || cfg!(test))
4203 {
4204 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4205 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4206 .map_err(wrap);
4207 }
4208
4209 let admission = match ServiceAccount::for_start_mode(mode) {
4210 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4213 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4217 RootAdmission::of_this_account().map_err(wrap)?
4218 }
4219 };
4220 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4221 }
4222 }
4223
4224 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4226 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4227 |source| match source {
4228 held @ LockError::Held { .. } => ServiceError::LockHeld {
4229 source: Box::new(held),
4230 },
4231 other => ServiceError::LockUnreadable {
4232 source: Box::new(other),
4233 },
4234 },
4235 )
4236 }
4237
4238 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4240 for mode in [StartMode::Boot, StartMode::Login] {
4241 let control = self.controls.control(mode)?;
4242 if let Some(registration) = control.query(&self.identity)? {
4243 return Ok(Some((registration.start_mode, registration)));
4244 }
4245 }
4246 Ok(None)
4247 }
4248}
4249
4250#[derive(Debug, Clone, PartialEq, Eq)]
4256pub struct StatusProblem {
4257 pub subject: &'static str,
4259 pub detail: String,
4261}
4262
4263impl fmt::Display for StatusProblem {
4264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4265 write!(f, "{}: {}", self.subject, self.detail)
4266 }
4267}
4268
4269#[derive(Debug, Clone)]
4278pub struct ServiceStatus {
4279 identity: ServiceIdentity,
4280 record: Option<InstallRecord>,
4281 registration: Option<Registration>,
4282 binary: Option<BinaryPath>,
4283 log_file: PathBuf,
4284 store: Option<crate::secrets::ActiveStore>,
4285 last_github_contact: Option<DateTime<Utc>>,
4286 runner_root: Option<(PathBuf, RootAccessReport)>,
4287 problems: Vec<StatusProblem>,
4288 notes: Vec<String>,
4289}
4290
4291impl ServiceStatus {
4292 fn compose(
4297 identity: ServiceIdentity,
4298 record: Option<InstallRecord>,
4299 record_refused: Option<&ServiceError>,
4300 registration: Option<Registration>,
4301 last_github_contact: Option<DateTime<Utc>>,
4302 paths: &AppPaths,
4303 ) -> Self {
4304 let mut problems = Vec::new();
4305 let mut notes = Vec::new();
4306
4307 if let Some(refusal) = record_refused {
4308 problems.push(StatusProblem {
4313 subject: "install record",
4314 detail: refusal.to_string(),
4315 });
4316 }
4317
4318 let log_file = record.as_ref().map_or_else(
4319 || ServiceDirectories::of(paths).log_file(),
4320 |record| record.log_file.clone(),
4321 );
4322
4323 let binary = record.as_ref().map(|record| {
4324 inspect_binary(
4325 &record.binary,
4326 registration
4327 .as_ref()
4328 .and_then(Registration::binary)
4329 .as_deref(),
4330 )
4331 });
4332 if let Some(state) = &binary
4333 && state.is_error()
4334 {
4335 problems.push(StatusProblem {
4336 subject: "binary",
4337 detail: state.to_string(),
4338 });
4339 }
4340
4341 match (&record, ®istration) {
4342 (Some(_), None) => problems.push(StatusProblem {
4343 subject: "registration",
4344 detail: "this host has a service record but no service manager knows the \
4345 registration. Run `service install` again; it deletes nothing."
4346 .to_string(),
4347 }),
4348 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4353 subject: "record",
4354 detail: format!(
4355 "{} holds a registration for this service but there is no install record, so \
4356 the path it was installed from and the directories it was installed against \
4357 are unknown. Run `service uninstall` and `service install`.",
4358 found.manager
4359 ),
4360 }),
4361 (None, Some(_)) => {}
4362 (Some(record), Some(found)) => {
4363 if record.start_mode != found.start_mode {
4364 problems.push(StatusProblem {
4365 subject: "start mode",
4366 detail: format!(
4367 "the record says {} and {} holds a {} registration. Switch the start \
4368 mode again to make them agree.",
4369 record.start_mode, found.manager, found.start_mode
4370 ),
4371 });
4372 }
4373 if record.start_mode == StartMode::Boot && !found.starts_automatically {
4374 problems.push(StatusProblem {
4375 subject: "start mode",
4376 detail: format!(
4377 "{} holds the registration but will not start it by itself, so this \
4378 host does not resume work after a reboot.",
4379 found.manager
4380 ),
4381 });
4382 }
4383 if let Some(actual) = found.restart_delay {
4384 let expected = record.restart().effective_delay(found.manager);
4385 if actual != expected {
4386 problems.push(StatusProblem {
4387 subject: "restart policy",
4388 detail: format!(
4389 "the record says the service restarts after {}s and {} reports \
4390 {}s. Something has edited the registration since it was \
4391 installed.",
4392 expected.as_secs(),
4393 found.manager,
4394 actual.as_secs()
4395 ),
4396 });
4397 } else if expected != record.restart().delay() {
4398 notes.push(format!(
4403 "{} expresses the restart delay in whole minutes, so the {}s asked \
4404 for is enforced as {}s. The service therefore never restarts faster \
4405 than the configured bound.",
4406 found.manager,
4407 record.restart().delay().as_secs(),
4408 expected.as_secs()
4409 ));
4410 }
4411 }
4412 if found.starts_automatically && !found.running {
4413 problems.push(StatusProblem {
4414 subject: "runtime",
4415 detail: format!(
4416 "{} holds an automatic registration, but the daemon is stopped. Run \
4417 `runner-manager service install --start-at {}`; if the service \
4418 manager denies replacement, retry from an elevated terminal. If it \
4419 stops again, inspect {}.",
4420 found.manager,
4421 found.start_mode,
4422 log_file.display()
4423 ),
4424 });
4425 }
4426 }
4427 (None, None) => {}
4428 }
4429
4430 let store = record.as_ref().and_then(|record| {
4435 let registered_mode = registration
4436 .as_ref()
4437 .map_or(record.start_mode, |found| found.start_mode);
4438 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4439 .ok()
4440 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4441 });
4442 if let Some(store) = &store
4443 && !store.agrees_with_start_mode()
4444 {
4445 problems.push(StatusProblem {
4446 subject: "secret store",
4447 detail: format!(
4448 "{store}. Run `auth login` again so the token is stored where the registered \
4449 start mode can read it."
4450 ),
4451 });
4452 }
4453
4454 if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4455 notes.push(
4456 "This registration starts at login, so the agent does not run until the operator \
4457 signs in; this host does not resume work after an unattended reboot."
4458 .to_string(),
4459 );
4460 }
4461 if last_github_contact.is_none() {
4462 notes.push(
4463 "GitHub has not been reached successfully since this host's state directory was \
4464 created."
4465 .to_string(),
4466 );
4467 }
4468
4469 let runner_root = crate::runner_root::default_runner_root(paths)
4476 .ok()
4477 .map(|root| {
4478 let path = root.as_path().to_path_buf();
4479 let report = crate::runner_root_access::report(&path);
4480 (path, report)
4481 });
4482 if let Some((
4492 path,
4493 RootAccessReport::Present {
4494 broad_write: true, ..
4495 },
4496 )) = &runner_root
4497 {
4498 notes.push(format!(
4499 "the platform default runner root {} can be written by ordinary local users, so \
4500 it is not a safe place to run jobs. `service install` refuses it rather than \
4501 tightening it, because the contents of a directory anybody could write cannot \
4502 be trusted: remove or empty it, or choose another root with `runner-manager \
4503 host set-runtime-root --path <PATH>`.",
4504 path.display()
4505 ));
4506 }
4507
4508 match runner_root_refusals(paths) {
4531 Ok(refusals) => {
4532 for refusal in refusals {
4533 notes.push(format!(
4534 "policy {} started no runner: its runner root {} refused the launch \
4535 ({}), last at {}. {} This clears when that policy next places a \
4536 runner.",
4537 refusal.policy,
4538 refusal.root,
4539 refusal.kind,
4540 refusal.at.to_rfc3339(),
4541 refusal.detail,
4542 ));
4543 }
4544 }
4545 Err(error) => notes.push(format!(
4546 "whether the agent could use its runner roots could not be read: {error}"
4547 )),
4548 }
4549
4550 Self {
4551 identity,
4552 record,
4553 registration,
4554 binary,
4555 log_file,
4556 store,
4557 last_github_contact,
4558 runner_root,
4559 problems,
4560 notes,
4561 }
4562 }
4563
4564 #[must_use]
4572 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
4573 self.runner_root
4574 .as_ref()
4575 .map(|(path, report)| (path.as_path(), report))
4576 }
4577
4578 #[must_use]
4580 pub const fn is_installed(&self) -> bool {
4581 self.registration.is_some() || self.record.is_some()
4582 }
4583
4584 #[must_use]
4586 pub fn is_running(&self) -> bool {
4587 self.registration
4588 .as_ref()
4589 .is_some_and(|registration| registration.running)
4590 }
4591
4592 #[must_use]
4594 pub fn is_healthy(&self) -> bool {
4595 self.problems.is_empty()
4596 }
4597
4598 #[must_use]
4600 pub fn problems(&self) -> &[StatusProblem] {
4601 &self.problems
4602 }
4603
4604 #[must_use]
4607 pub fn notes(&self) -> &[String] {
4608 &self.notes
4609 }
4610
4611 #[must_use]
4613 pub fn start_mode(&self) -> Option<StartMode> {
4614 self.record.as_ref().map(|record| record.start_mode)
4615 }
4616
4617 #[must_use]
4620 pub const fn binary(&self) -> Option<&BinaryPath> {
4621 self.binary.as_ref()
4622 }
4623
4624 #[must_use]
4626 pub fn log_file(&self) -> &Path {
4627 &self.log_file
4628 }
4629
4630 #[must_use]
4632 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
4633 self.last_github_contact
4634 }
4635
4636 #[must_use]
4639 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
4640 self.store.as_ref()
4641 }
4642
4643 #[must_use]
4645 pub const fn record(&self) -> Option<&InstallRecord> {
4646 self.record.as_ref()
4647 }
4648
4649 #[must_use]
4651 pub const fn registration(&self) -> Option<&Registration> {
4652 self.registration.as_ref()
4653 }
4654}
4655
4656impl fmt::Display for ServiceStatus {
4657 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4658 writeln!(f, "Service: {}", self.identity)?;
4659 match (&self.record, &self.registration) {
4660 (None, None) => {
4661 writeln!(
4662 f,
4663 " installed no. `service install` registers `{} {}`.",
4664 SERVICE_NAME,
4665 DAEMON_ARGUMENTS.join(" ")
4666 )?;
4667 }
4668 _ => {
4669 let manager = self
4670 .registration
4671 .as_ref()
4672 .map(|registration| registration.manager.manager());
4673 writeln!(
4674 f,
4675 " installed {}",
4676 manager.unwrap_or("yes, but no service manager knows it")
4677 )?;
4678 writeln!(
4679 f,
4680 " state {}",
4681 if self.is_running() {
4682 "running"
4683 } else {
4684 "not running"
4685 }
4686 )?;
4687 }
4688 }
4689 if let Some(record) = &self.record {
4690 writeln!(f, " start mode {}", record.start_mode)?;
4691 writeln!(f, " account {}", record.account)?;
4692 writeln!(f, " restart on failure {}", record.restart())?;
4693 writeln!(
4694 f,
4695 " arguments {}",
4696 record.arguments.join(" ")
4697 )?;
4698 }
4699 if let Some(binary) = &self.binary {
4700 writeln!(f, " binary {binary}")?;
4701 }
4702 writeln!(f, " diagnostic log {}", self.log_file.display())?;
4703 if let Some((path, report)) = &self.runner_root {
4704 writeln!(f, " default runner root {}", path.display())?;
4711 if *report != RootAccessReport::NotApplicable {
4712 writeln!(f, " default root access {report}")?;
4713 }
4714 }
4715 if let Some(store) = &self.store {
4716 writeln!(f, " secret store {store}")?;
4717 }
4718 writeln!(
4719 f,
4720 " last GitHub contact {}",
4721 match self.last_github_contact {
4722 Some(at) => at.to_rfc3339(),
4723 None => "never".to_string(),
4724 }
4725 )?;
4726 for note in &self.notes {
4727 writeln!(f, " note {note}")?;
4728 }
4729 for problem in &self.problems {
4730 writeln!(f, " ERROR {problem}")?;
4731 }
4732 write!(
4733 f,
4734 " verdict {}",
4735 if self.is_healthy() {
4736 "healthy"
4737 } else {
4738 "NOT healthy"
4739 }
4740 )
4741 }
4742}
4743
4744#[derive(Debug, Clone, Default)]
4761pub struct RecordingControls {
4762 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
4763}
4764
4765#[derive(Debug, Default)]
4766struct RecordingState {
4767 registrations: BTreeMap<(StartMode, String), Registration>,
4768 definitions: BTreeMap<String, ServiceDefinition>,
4769 calls: Vec<String>,
4770 #[cfg(test)]
4771 install_failures: BTreeMap<StartMode, String>,
4772 #[cfg(test)]
4773 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
4774}
4775
4776#[cfg(test)]
4777#[derive(Debug, Clone)]
4778enum TestInstallSideEffect {
4779 HideDirectory { directory: PathBuf, hidden: PathBuf },
4780}
4781
4782impl RecordingControls {
4783 #[must_use]
4785 pub fn new() -> Self {
4786 Self::default()
4787 }
4788
4789 #[must_use]
4792 pub fn calls(&self) -> Vec<String> {
4793 self.state.lock().expect("not poisoned").calls.clone()
4794 }
4795
4796 #[must_use]
4798 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
4799 self.state
4800 .lock()
4801 .expect("not poisoned")
4802 .registrations
4803 .iter()
4804 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
4805 .collect()
4806 }
4807
4808 #[must_use]
4810 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
4811 self.state
4812 .lock()
4813 .expect("not poisoned")
4814 .definitions
4815 .get(name)
4816 .cloned()
4817 }
4818
4819 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
4829 let mut state = self.state.lock().expect("not poisoned");
4830 if let Some((_, registration)) = state
4831 .registrations
4832 .iter_mut()
4833 .find(|((_, held), _)| held == name)
4834 {
4835 edit(registration);
4836 }
4837 }
4838
4839 #[cfg(test)]
4840 fn fail_next_install(&self, mode: StartMode, detail: &str) {
4841 self.state
4842 .lock()
4843 .expect("not poisoned")
4844 .install_failures
4845 .insert(mode, detail.to_string());
4846 }
4847
4848 #[cfg(test)]
4849 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
4850 self.state
4851 .lock()
4852 .expect("not poisoned")
4853 .after_install
4854 .insert(
4855 mode,
4856 TestInstallSideEffect::HideDirectory { directory, hidden },
4857 );
4858 }
4859}
4860
4861impl ControlFactory for RecordingControls {
4862 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
4863 Ok(Box::new(RecordingControl {
4864 mode,
4865 state: std::sync::Arc::clone(&self.state),
4866 }))
4867 }
4868}
4869
4870#[derive(Debug)]
4871struct RecordingControl {
4872 mode: StartMode,
4873 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
4874}
4875
4876impl RecordingControl {
4877 fn note(&self, operation: &str, name: &str) {
4878 self.state
4879 .lock()
4880 .expect("not poisoned")
4881 .calls
4882 .push(format!("{operation} {name} ({})", self.mode));
4883 }
4884}
4885
4886impl ServiceControl for RecordingControl {
4887 fn manager(&self) -> DefinitionKind {
4888 host_definition_kind(self.mode)
4892 }
4893
4894 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
4895 self.note("install", plan.identity().name());
4896 #[cfg(test)]
4897 if let Some(detail) = self
4898 .state
4899 .lock()
4900 .expect("not poisoned")
4901 .install_failures
4902 .remove(&self.mode)
4903 {
4904 return Err(ServiceError::Control {
4905 operation: "install",
4906 name: plan.identity().name().to_string(),
4907 manager: "recording control",
4908 detail,
4909 });
4910 }
4911 let definition = ServiceDefinition::for_host(plan)?;
4916 let mut state = self.state.lock().expect("not poisoned");
4917 state.registrations.insert(
4918 (self.mode, plan.identity().name().to_string()),
4919 Registration {
4920 manager: host_definition_kind(self.mode),
4921 start_mode: self.mode,
4922 command_line: plan.command_line(),
4923 account: Some(plan.account().as_str().to_string()),
4924 running: false,
4925 starts_automatically: true,
4926 restart_delay: Some(plan.restart().delay()),
4927 },
4928 );
4929 state
4930 .definitions
4931 .insert(plan.identity().name().to_string(), definition.clone());
4932 #[cfg(test)]
4933 let side_effect = state.after_install.remove(&self.mode);
4934 drop(state);
4935 #[cfg(test)]
4936 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
4937 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
4938 std::fs::write(&directory, b"blocks recreation")
4939 .expect("test fault can block record directory recreation");
4940 }
4941 Ok(definition)
4942 }
4943
4944 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
4945 self.note("uninstall", identity.name());
4946 let mut state = self.state.lock().expect("not poisoned");
4947 state.definitions.remove(identity.name());
4948 Ok(state
4949 .registrations
4950 .remove(&(self.mode, identity.name().to_string()))
4951 .is_some())
4952 }
4953
4954 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
4955 self.note("query", identity.name());
4956 Ok(self
4957 .state
4958 .lock()
4959 .expect("not poisoned")
4960 .registrations
4961 .get(&(self.mode, identity.name().to_string()))
4962 .cloned())
4963 }
4964
4965 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
4966 self.note("start", identity.name());
4967 let mut state = self.state.lock().expect("not poisoned");
4968 match state
4969 .registrations
4970 .get_mut(&(self.mode, identity.name().to_string()))
4971 {
4972 Some(registration) => {
4973 registration.running = true;
4974 Ok(())
4975 }
4976 None => Err(ServiceError::NotInstalled {
4977 name: identity.name().to_string(),
4978 operation: "start",
4979 }),
4980 }
4981 }
4982
4983 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
4984 self.note("stop", identity.name());
4985 let mut state = self.state.lock().expect("not poisoned");
4986 match state
4987 .registrations
4988 .get_mut(&(self.mode, identity.name().to_string()))
4989 {
4990 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
4991 None => Err(ServiceError::NotInstalled {
4992 name: identity.name().to_string(),
4993 operation: "stop",
4994 }),
4995 }
4996 }
4997}
4998
4999impl ControlFactory for HostControls {
5000 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5001 sys::control(mode)
5002 }
5003}
5004
5005fn host_home() -> Option<PathBuf> {
5012 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5013}
5014
5015#[cfg(unix)]
5017fn home_directory() -> Option<PathBuf> {
5018 host_home()
5019}
5020
5021fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5030 let output = std::process::Command::new(program)
5031 .args(arguments)
5032 .output()?;
5033 Ok((
5034 output.status.success(),
5035 String::from_utf8_lossy(&output.stdout).into_owned(),
5036 String::from_utf8_lossy(&output.stderr).into_owned(),
5037 ))
5038}
5039
5040#[must_use]
5043pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5044 if cfg!(windows) {
5045 match mode {
5046 StartMode::Boot => DefinitionKind::WindowsService,
5047 StartMode::Login => DefinitionKind::WindowsScheduledTask,
5048 }
5049 } else if cfg!(target_os = "macos") {
5050 DefinitionKind::LaunchdPlist
5051 } else {
5052 DefinitionKind::SystemdUnit
5053 }
5054}
5055
5056#[cfg(any(target_os = "macos", test))]
5063fn enable_launchd_registration(
5064 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5065 domain: &str,
5066 service_target: &str,
5067 plist: &Path,
5068 name: &str,
5069 elevation_remedy: &'static str,
5070) -> Result<(), ServiceError> {
5071 let (enabled, cause) = launchctl(&[
5072 std::ffi::OsStr::new("enable"),
5073 std::ffi::OsStr::new(service_target),
5074 ]);
5075 if enabled {
5076 return Ok(());
5077 }
5078
5079 let (booted_out, bootout_detail) = launchctl(&[
5080 std::ffi::OsStr::new("bootout"),
5081 std::ffi::OsStr::new(service_target),
5082 ]);
5083 let removed = std::fs::remove_file(plist);
5084 if !booted_out || removed.is_err() {
5085 return Err(ServiceError::Rollback {
5086 operation: "enable launchd registration",
5087 name: name.to_string(),
5088 cause,
5089 rollback: format!(
5090 "launchctl bootout {domain}: {}; remove {}: {}",
5091 if booted_out {
5092 "succeeded".to_string()
5093 } else {
5094 bootout_detail
5095 },
5096 plist.display(),
5097 removed
5098 .err()
5099 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5100 ),
5101 });
5102 }
5103
5104 if cause.to_ascii_lowercase().contains("permission denied") {
5105 Err(ServiceError::NeedsElevation {
5106 operation: "enable",
5107 name: name.to_string(),
5108 detail: cause,
5109 remedy: elevation_remedy,
5110 })
5111 } else {
5112 Err(ServiceError::Control {
5113 operation: "enable",
5114 name: name.to_string(),
5115 manager: "launchd",
5116 detail: cause,
5117 })
5118 }
5119}
5120
5121#[derive(Debug, Clone)]
5130pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5131
5132impl ServiceShutdown {
5133 pub async fn wait(mut self) {
5135 if !*self.0.borrow() {
5136 let _ = self.0.changed().await;
5137 }
5138 }
5139}
5140
5141#[cfg(windows)]
5148pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5149where
5150 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5151{
5152 windows_host::run(Box::new(run))
5153}
5154
5155#[cfg(windows)]
5156mod windows_host {
5157 use std::ffi::OsString;
5158 use std::sync::{Arc, Mutex, OnceLock, mpsc};
5159 use std::time::Duration;
5160
5161 use windows_service::service::{
5162 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5163 ServiceType,
5164 };
5165 use windows_service::service_control_handler::{
5166 self, ServiceControlHandlerResult, ServiceStatusHandle,
5167 };
5168
5169 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5170
5171 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5172
5173 struct Invocation {
5174 run: Runner,
5175 result: mpsc::SyncSender<Result<u8, String>>,
5176 }
5177
5178 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5179
5180 windows_service::define_windows_service!(ffi_service_main, service_main);
5181
5182 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5183 let (result_tx, result_rx) = mpsc::sync_channel(1);
5184 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5185 let mut invocation = slot
5186 .lock()
5187 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5188 if invocation.is_some() {
5189 return Err(host_error(
5190 "prepare",
5191 "the service-host slot was already used",
5192 ));
5193 }
5194 *invocation = Some(Invocation {
5195 run,
5196 result: result_tx,
5197 });
5198 drop(invocation);
5199
5200 windows_service::service_dispatcher::start("", ffi_service_main)
5201 .map_err(|error| host_error("connect", &error.to_string()))?;
5202 result_rx
5203 .recv()
5204 .map_err(|error| host_error("finish", &error.to_string()))?
5205 .map_err(|detail| host_error("run", &detail))
5206 }
5207
5208 fn service_main(_arguments: Vec<OsString>) {
5209 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5210 return;
5211 };
5212 let result = run_service(invocation.run);
5213 let _ = invocation.result.send(result);
5214 }
5215
5216 fn run_service(run: Runner) -> Result<u8, String> {
5217 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5218 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5219 let handler_status = Arc::clone(&status);
5220 let handler = move |control| match control {
5221 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5222 ServiceControl::Stop | ServiceControl::Shutdown => {
5223 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5224 let _ = handle.set_service_status(service_status(
5225 ServiceState::StopPending,
5226 ServiceControlAccept::empty(),
5227 1,
5228 Duration::from_secs(300),
5229 0,
5230 ));
5231 }
5232 let _ = stop_tx.send(true);
5233 ServiceControlHandlerResult::NoError
5234 }
5235 _ => ServiceControlHandlerResult::NotImplemented,
5236 };
5237 let handle = service_control_handler::register("", handler)
5238 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5239 *status
5240 .lock()
5241 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5242 handle
5243 .set_service_status(service_status(
5244 ServiceState::Running,
5245 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5246 0,
5247 Duration::default(),
5248 0,
5249 ))
5250 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5251
5252 let exit = run(ServiceShutdown(stop_rx));
5253 handle
5254 .set_service_status(service_status(
5255 ServiceState::Stopped,
5256 ServiceControlAccept::empty(),
5257 0,
5258 Duration::default(),
5259 u32::from(exit),
5260 ))
5261 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5262 Ok(exit)
5263 }
5264
5265 fn service_status(
5266 state: ServiceState,
5267 accepted: ServiceControlAccept,
5268 checkpoint: u32,
5269 wait_hint: Duration,
5270 exit: u32,
5271 ) -> ServiceStatus {
5272 ServiceStatus {
5273 service_type: ServiceType::OWN_PROCESS,
5274 current_state: state,
5275 controls_accepted: accepted,
5276 exit_code: if exit == 0 {
5277 ServiceExitCode::Win32(0)
5278 } else {
5279 ServiceExitCode::ServiceSpecific(exit)
5280 },
5281 checkpoint,
5282 wait_hint,
5283 process_id: None,
5284 }
5285 }
5286
5287 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5288 ServiceError::Control {
5289 operation,
5290 name: SERVICE_NAME.to_string(),
5291 manager: "the Windows Service Control Manager",
5292 detail: detail.to_string(),
5293 }
5294 }
5295}
5296
5297#[cfg(windows)]
5298mod sys {
5299 use std::ffi::{OsStr, OsString};
5309 use std::time::{Duration, Instant};
5310
5311 use runner_manager_domain::model::StartMode;
5312 use windows_service::service::{
5313 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5314 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5315 ServiceState, ServiceType,
5316 };
5317 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5318
5319 use super::{
5320 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5321 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5322 };
5323
5324 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5327 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5331 const ACCESS_DENIED: i32 = 5;
5333 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5334 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5335
5336 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5337 choose \"Run as administrator\".";
5338
5339 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5340 Ok(match mode {
5341 StartMode::Boot => Box::new(ScmControl),
5342 StartMode::Login => Box::new(TaskControl),
5343 })
5344 }
5345
5346 #[derive(Debug)]
5349 struct ScmControl;
5350
5351 fn scm_error(
5355 operation: &'static str,
5356 name: &str,
5357 error: &windows_service::Error,
5358 ) -> ServiceError {
5359 let raw = match error {
5360 windows_service::Error::Winapi(io) => io.raw_os_error(),
5361 _ => None,
5362 };
5363 let detail = match error {
5364 windows_service::Error::Winapi(io) => io.to_string(),
5365 other => other.to_string(),
5366 };
5367 if raw == Some(ACCESS_DENIED) {
5368 return ServiceError::NeedsElevation {
5369 operation,
5370 name: name.to_string(),
5371 detail,
5372 remedy: ELEVATION_REMEDY,
5373 };
5374 }
5375 ServiceError::Control {
5376 operation,
5377 name: name.to_string(),
5378 manager: "the Windows Service Control Manager",
5379 detail,
5380 }
5381 }
5382
5383 fn open_manager(
5384 access: ServiceManagerAccess,
5385 operation: &'static str,
5386 name: &str,
5387 ) -> Result<ServiceManager, ServiceError> {
5388 ServiceManager::local_computer(None::<&OsStr>, access)
5389 .map_err(|error| scm_error(operation, name, &error))
5390 }
5391
5392 impl ServiceControl for ScmControl {
5393 fn manager(&self) -> DefinitionKind {
5394 DefinitionKind::WindowsService
5395 }
5396
5397 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5398 let spec = windows_service_spec(plan);
5399 let manager = open_manager(
5400 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5401 "install",
5402 &spec.name,
5403 )?;
5404 let info = ServiceInfo {
5405 name: OsString::from(&spec.name),
5406 display_name: OsString::from(&spec.display_name),
5407 service_type: ServiceType::OWN_PROCESS,
5409 start_type: if spec.automatic_start {
5410 ServiceStartType::AutoStart
5411 } else {
5412 ServiceStartType::OnDemand
5413 },
5414 error_control: ServiceErrorControl::Normal,
5415 executable_path: plan.binary().to_path_buf(),
5416 launch_arguments: plan.arguments().to_vec(),
5417 dependencies: Vec::new(),
5418 account_name: spec.account.as_ref().map(OsString::from),
5420 account_password: None,
5423 };
5424 let service = manager
5425 .create_service(
5426 &info,
5427 ServiceAccess::CHANGE_CONFIG
5428 | ServiceAccess::QUERY_CONFIG
5429 | ServiceAccess::QUERY_STATUS
5430 | ServiceAccess::START
5431 | ServiceAccess::STOP
5432 | ServiceAccess::DELETE,
5433 )
5434 .map_err(|error| scm_error("install", &spec.name, &error))?;
5435 service
5436 .set_description(&spec.description)
5437 .map_err(|error| scm_error("describe", &spec.name, &error))?;
5438 service
5439 .update_failure_actions(ServiceFailureActions {
5440 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5441 reboot_msg: None,
5442 command: None,
5443 actions: Some(vec![
5450 ServiceAction {
5451 action_type: ServiceActionType::Restart,
5452 delay: spec.restart.delay(),
5453 };
5454 3
5455 ]),
5456 })
5457 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5458 service
5461 .set_failure_actions_on_non_crash_failures(true)
5462 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5463 Ok(ServiceDefinition::windows_service(plan))
5464 }
5465
5466 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5467 let manager =
5468 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
5469 let service = match manager.open_service(
5470 identity.name(),
5471 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
5472 ) {
5473 Ok(service) => Some(service),
5474 Err(error) if is_missing(&error) => return Ok(false),
5475 Err(error) if is_marked_for_delete(&error) => None,
5480 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
5481 };
5482 if let Some(service) = service {
5483 if let Ok(status) = service.query_status()
5487 && status.current_state != ServiceState::Stopped
5488 {
5489 let _ = service.stop();
5490 }
5491 match service.delete() {
5492 Ok(()) => {}
5493 Err(error) if is_marked_for_delete(&error) => {}
5497 Err(error) => {
5498 return Err(scm_error("uninstall", identity.name(), &error));
5499 }
5500 }
5501 drop(service);
5502 }
5503
5504 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
5511 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
5512 Ok(service) => {
5513 drop(service);
5514 Ok(false)
5515 }
5516 Err(error) if is_missing(&error) => Ok(true),
5517 Err(error) if is_marked_for_delete(&error) => Ok(false),
5518 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
5519 }
5520 })?;
5521 if !absent {
5522 return Err(ServiceError::Control {
5523 operation: "verify uninstall of",
5524 name: identity.name().to_string(),
5525 manager: "the Windows Service Control Manager",
5526 detail: format!(
5527 "the registration was still visible {} seconds after DeleteService; \
5528 retry `service uninstall` from an elevated prompt",
5529 DELETE_TIMEOUT.as_secs()
5530 ),
5531 });
5532 }
5533 Ok(true)
5534 }
5535
5536 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5537 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
5538 let service = match manager.open_service(
5539 identity.name(),
5540 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
5541 ) {
5542 Ok(service) => service,
5543 Err(error) if is_missing(&error) => return Ok(None),
5544 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
5545 };
5546 let config = service
5547 .query_config()
5548 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5549 let status = service
5550 .query_status()
5551 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5552 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
5553 actions
5554 .actions
5555 .and_then(|actions| actions.into_iter().next())
5556 .filter(|action| action.action_type == ServiceActionType::Restart)
5557 .map(|action| action.delay)
5558 });
5559 Ok(Some(Registration {
5560 manager: DefinitionKind::WindowsService,
5561 start_mode: StartMode::Boot,
5562 command_line: config.executable_path.to_string_lossy().into_owned(),
5566 account: config
5567 .account_name
5568 .map(|account| account.to_string_lossy().into_owned()),
5569 running: status.current_state == ServiceState::Running,
5570 starts_automatically: config.start_type == ServiceStartType::AutoStart,
5571 restart_delay,
5572 }))
5573 }
5574
5575 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5576 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
5577 let service = manager
5578 .open_service(identity.name(), ServiceAccess::START)
5579 .map_err(|error| scm_error("start", identity.name(), &error))?;
5580 service
5581 .start::<&OsStr>(&[])
5582 .map_err(|error| scm_error("start", identity.name(), &error))
5583 }
5584
5585 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5586 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
5587 let service = manager
5588 .open_service(
5589 identity.name(),
5590 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
5591 )
5592 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5593 let status = service
5594 .query_status()
5595 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5596 if status.current_state == ServiceState::Stopped {
5597 return Ok(false);
5598 }
5599 service
5600 .stop()
5601 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5602 Ok(true)
5603 }
5604 }
5605
5606 fn is_missing(error: &windows_service::Error) -> bool {
5607 matches!(error, windows_service::Error::Winapi(io)
5608 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
5609 }
5610
5611 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
5612 matches!(error, windows_service::Error::Winapi(io)
5613 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
5614 }
5615
5616 pub(super) fn wait_until_scm_absent(
5617 timeout: Duration,
5618 poll_interval: Duration,
5619 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
5620 ) -> Result<bool, ServiceError> {
5621 let deadline = Instant::now() + timeout;
5622 loop {
5623 if probe_absent()? {
5624 return Ok(true);
5625 }
5626 if Instant::now() >= deadline {
5627 return Ok(false);
5628 }
5629 std::thread::sleep(poll_interval);
5630 }
5631 }
5632
5633 #[derive(Debug)]
5636 struct TaskControl;
5637
5638 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
5639 if detail.to_ascii_lowercase().contains("access is denied") {
5640 return ServiceError::NeedsElevation {
5641 operation,
5642 name: name.to_string(),
5643 detail,
5644 remedy: ELEVATION_REMEDY,
5645 };
5646 }
5647 ServiceError::Control {
5648 operation,
5649 name: name.to_string(),
5650 manager: "Windows Task Scheduler",
5651 detail,
5652 }
5653 }
5654
5655 fn schtasks(
5656 operation: &'static str,
5657 name: &str,
5658 arguments: &[&OsStr],
5659 ) -> Result<(bool, String), ServiceError> {
5660 match run("schtasks.exe", arguments) {
5661 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
5662 Err(error) => Err(task_error(operation, name, error.to_string())),
5663 }
5664 }
5665
5666 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
5669 let mut bytes = vec![0xFF, 0xFE];
5670 for unit in text.encode_utf16() {
5671 bytes.extend_from_slice(&unit.to_le_bytes());
5672 }
5673 std::fs::write(path, bytes)
5674 }
5675
5676 impl ServiceControl for TaskControl {
5677 fn manager(&self) -> DefinitionKind {
5678 DefinitionKind::WindowsScheduledTask
5679 }
5680
5681 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5682 let name = plan.identity().name().to_string();
5683 let principal = TaskPrincipal::current()?;
5684 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
5685 let directory = tempfile::tempdir()
5686 .map_err(|error| task_error("install", &name, error.to_string()))?;
5687 let document = directory.path().join("task.xml");
5688 write_utf16(&document, definition.text())
5689 .map_err(|error| task_error("install", &name, error.to_string()))?;
5690 let (ok, message) = schtasks(
5691 "install",
5692 &name,
5693 &[
5694 OsStr::new("/Create"),
5695 OsStr::new("/TN"),
5696 OsStr::new(&name),
5697 OsStr::new("/XML"),
5698 document.as_os_str(),
5699 ],
5700 )?;
5701 if !ok {
5702 return Err(task_error("install", &name, message));
5703 }
5704 Ok(definition)
5705 }
5706
5707 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5708 if self.query(identity)?.is_none() {
5709 return Ok(false);
5710 }
5711 let name = identity.name().to_string();
5712 let (ok, message) = schtasks(
5713 "uninstall",
5714 &name,
5715 &[
5716 OsStr::new("/Delete"),
5717 OsStr::new("/TN"),
5718 OsStr::new(&name),
5719 OsStr::new("/F"),
5720 ],
5721 )?;
5722 if !ok {
5723 return Err(task_error("uninstall", &name, message));
5724 }
5725 Ok(true)
5726 }
5727
5728 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5729 let name = identity.name().to_string();
5730 let (ok, document) = schtasks(
5731 "inspect",
5732 &name,
5733 &[
5734 OsStr::new("/Query"),
5735 OsStr::new("/TN"),
5736 OsStr::new(&name),
5737 OsStr::new("/XML"),
5738 OsStr::new("ONE"),
5739 ],
5740 )?;
5741 if !ok {
5742 return Ok(None);
5748 }
5749 let command = xml_value(&document, "Command").unwrap_or_default();
5750 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
5751 let command_line = if arguments.is_empty() {
5752 super::quote_argument(&command)
5753 } else {
5754 format!("{} {arguments}", super::quote_argument(&command))
5755 };
5756 Ok(Some(Registration {
5757 manager: DefinitionKind::WindowsScheduledTask,
5758 start_mode: StartMode::Login,
5759 command_line,
5760 account: xml_value(&document, "UserId"),
5761 running: task_is_running(&name),
5762 starts_automatically: super::windows_login_task_starts_automatically(&document),
5763 restart_delay: xml_value(&document, "Interval")
5764 .as_deref()
5765 .and_then(parse_iso8601),
5766 }))
5767 }
5768
5769 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5770 let name = identity.name().to_string();
5771 let (ok, message) = schtasks(
5772 "start",
5773 &name,
5774 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
5775 )?;
5776 if ok {
5777 Ok(())
5778 } else {
5779 Err(task_error("start", &name, message))
5780 }
5781 }
5782
5783 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5784 let running = self
5785 .query(identity)?
5786 .is_some_and(|registration| registration.running);
5787 if !running {
5788 return Ok(false);
5789 }
5790 let name = identity.name().to_string();
5791 let (ok, message) = schtasks(
5792 "stop",
5793 &name,
5794 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
5795 )?;
5796 if ok {
5797 Ok(true)
5798 } else {
5799 Err(task_error("stop", &name, message))
5800 }
5801 }
5802 }
5803
5804 fn task_is_running(name: &str) -> bool {
5818 let Ok((true, stdout, _)) = run(
5819 "schtasks.exe",
5820 &[
5821 OsStr::new("/Query"),
5822 OsStr::new("/TN"),
5823 OsStr::new(name),
5824 OsStr::new("/FO"),
5825 OsStr::new("CSV"),
5826 OsStr::new("/NH"),
5827 ],
5828 ) else {
5829 return false;
5830 };
5831 stdout
5832 .lines()
5833 .filter_map(|line| line.rsplit(',').next())
5834 .any(|status| {
5835 status
5836 .trim()
5837 .trim_matches('"')
5838 .eq_ignore_ascii_case("running")
5839 })
5840 }
5841
5842 fn parse_iso8601(value: &str) -> Option<Duration> {
5846 let rest = value.strip_prefix("PT")?;
5847 if let Some(minutes) = rest.strip_suffix('M') {
5848 return minutes
5849 .parse::<u64>()
5850 .ok()
5851 .map(|minutes| Duration::from_secs(minutes * 60));
5852 }
5853 rest.strip_suffix('S')?
5854 .parse::<u64>()
5855 .ok()
5856 .map(Duration::from_secs)
5857 }
5858}
5859
5860#[cfg(unix)]
5872fn write_definition(
5873 operation: &'static str,
5874 name: &str,
5875 path: &Path,
5876 text: &str,
5877 remedy: &'static str,
5878) -> Result<(), ServiceError> {
5879 if let Some(parent) = path.parent()
5880 && let Err(error) = std::fs::create_dir_all(parent)
5881 && error.kind() != std::io::ErrorKind::AlreadyExists
5882 {
5883 return Err(definition_error(operation, name, error, remedy, parent));
5884 }
5885 std::fs::write(path, text)
5886 .map_err(|error| definition_error(operation, name, error, remedy, path))
5887}
5888
5889#[cfg(unix)]
5890fn definition_error(
5891 operation: &'static str,
5892 name: &str,
5893 error: std::io::Error,
5894 remedy: &'static str,
5895 path: &Path,
5896) -> ServiceError {
5897 let detail = format!("{}: {error}", path.display());
5898 if error.kind() == std::io::ErrorKind::PermissionDenied {
5899 ServiceError::NeedsElevation {
5900 operation,
5901 name: name.to_string(),
5902 detail,
5903 remedy,
5904 }
5905 } else {
5906 ServiceError::Control {
5907 operation,
5908 name: name.to_string(),
5909 manager: "the local service manager",
5910 detail,
5911 }
5912 }
5913}
5914
5915#[cfg(unix)]
5917const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
5918 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
5919 agent not running until you sign in.";
5920
5921#[cfg(target_os = "macos")]
5926mod sys {
5927 use std::ffi::OsStr;
5940 use std::path::PathBuf;
5941 use std::time::Duration;
5942
5943 use runner_manager_domain::model::StartMode;
5944
5945 use super::{
5946 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
5947 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
5948 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
5949 write_definition, xml_value,
5950 };
5951
5952 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5953 Ok(Box::new(LaunchdControl { mode }))
5954 }
5955
5956 #[derive(Debug)]
5957 struct LaunchdControl {
5958 mode: StartMode,
5959 }
5960
5961 impl LaunchdControl {
5962 fn domain(&self) -> String {
5964 match self.mode {
5965 StartMode::Boot => "system".to_string(),
5966 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
5969 }
5970 }
5971
5972 fn service_target(&self, identity: &ServiceIdentity) -> String {
5973 format!("{}/{}", self.domain(), identity.launchd_label())
5974 }
5975
5976 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
5979 let file = format!("{}.plist", identity.launchd_label());
5980 match self.mode {
5981 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
5982 StartMode::Login => {
5983 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
5984 }
5985 }
5986 }
5987
5988 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
5989 ServiceError::Control {
5990 operation,
5991 name: name.to_string(),
5992 manager: "launchd",
5993 detail,
5994 }
5995 }
5996
5997 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
5998 match run("launchctl", arguments) {
5999 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6000 Err(error) => (false, error.to_string()),
6001 }
6002 }
6003 }
6004
6005 impl ServiceControl for LaunchdControl {
6006 fn manager(&self) -> DefinitionKind {
6007 DefinitionKind::LaunchdPlist
6008 }
6009
6010 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6011 let name = plan.identity().name().to_string();
6012 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6013 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6014 return Err(self.failed(
6015 "install",
6016 &name,
6017 "this account has no home directory, so there is nowhere to put a \
6018 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6019 /Library/LaunchDaemons."
6020 .to_string(),
6021 ));
6022 };
6023 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6024 let target = self.domain();
6025 let (ok, message) = self.launchctl(&[
6026 OsStr::new("bootstrap"),
6027 OsStr::new(&target),
6028 path.as_os_str(),
6029 ]);
6030 if !ok {
6031 let _ = std::fs::remove_file(&path);
6035 if message.to_ascii_lowercase().contains("permission denied") {
6036 return Err(ServiceError::NeedsElevation {
6037 operation: "install",
6038 name,
6039 detail: message,
6040 remedy: SUDO_REMEDY,
6041 });
6042 }
6043 return Err(self.failed("install", &name, message));
6044 }
6045 let service_target = self.service_target(plan.identity());
6048 enable_launchd_registration(
6049 |arguments| self.launchctl(arguments),
6050 &target,
6051 &service_target,
6052 &path,
6053 &name,
6054 SUDO_REMEDY,
6055 )?;
6056 Ok(definition)
6057 }
6058
6059 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6060 let Some(path) = self.plist_path(identity) else {
6061 return Ok(false);
6062 };
6063 if !path.exists() {
6064 return Ok(false);
6065 }
6066 let target = self.service_target(identity);
6067 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6068 if !ok
6069 && !message.to_ascii_lowercase().contains("no such process")
6070 && !message.contains("113")
6071 {
6072 if message.to_ascii_lowercase().contains("permission denied") {
6073 return Err(ServiceError::NeedsElevation {
6074 operation: "uninstall",
6075 name: identity.name().to_string(),
6076 detail: message,
6077 remedy: SUDO_REMEDY,
6078 });
6079 }
6080 return Err(self.failed("uninstall", identity.name(), message));
6081 }
6082 std::fs::remove_file(&path).map_err(|error| {
6085 super::definition_error(
6086 "uninstall",
6087 identity.name(),
6088 error,
6089 SUDO_REMEDY,
6090 path.as_path(),
6091 )
6092 })?;
6093 Ok(true)
6094 }
6095
6096 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6097 let Some(path) = self.plist_path(identity) else {
6098 return Ok(None);
6099 };
6100 let Ok(document) = std::fs::read_to_string(&path) else {
6101 return Ok(None);
6102 };
6103 let target = self.service_target(identity);
6104 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6105 Ok(Some(Registration {
6106 manager: DefinitionKind::LaunchdPlist,
6107 start_mode: self.mode,
6108 command_line: program_arguments(&document),
6109 account: plist_string_value(&document, "UserName")
6110 .or_else(|| Some("the invoking user".to_string())),
6111 running: loaded && printed.contains("state = running"),
6112 starts_automatically: document.contains("<key>RunAtLoad</key>")
6113 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6114 restart_delay: xml_value(
6115 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6116 "integer",
6117 )
6118 .and_then(|value| value.parse::<u64>().ok())
6119 .map(Duration::from_secs),
6120 }))
6121 }
6122
6123 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6124 let target = self.service_target(identity);
6125 let (ok, message) = self.launchctl(&[
6126 OsStr::new("kickstart"),
6127 OsStr::new("-k"),
6128 OsStr::new(&target),
6129 ]);
6130 if ok {
6131 Ok(())
6132 } else {
6133 Err(self.failed("start", identity.name(), message))
6134 }
6135 }
6136
6137 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6138 let running = self
6139 .query(identity)?
6140 .is_some_and(|registration| registration.running);
6141 if !running {
6142 return Ok(false);
6143 }
6144 let target = self.service_target(identity);
6145 let (ok, message) = self.launchctl(&[
6146 OsStr::new("kill"),
6147 OsStr::new("SIGTERM"),
6148 OsStr::new(&target),
6149 ]);
6150 if ok {
6151 Ok(true)
6152 } else {
6153 Err(self.failed("stop", identity.name(), message))
6154 }
6155 }
6156 }
6157
6158 fn program_arguments(document: &str) -> String {
6160 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6161 return String::new();
6162 };
6163 let Some(end) = rest.find("</array>") else {
6164 return String::new();
6165 };
6166 let mut out = Vec::new();
6167 let mut cursor = &rest[..end];
6168 while let Some(open) = cursor.find("<string>") {
6169 let after = &cursor[open + "<string>".len()..];
6170 let Some(close) = after.find("</string>") else {
6171 break;
6172 };
6173 out.push(quote_argument(&super::xml_unescape(&after[..close])));
6174 cursor = &after[close..];
6175 }
6176 out.join(" ")
6177 }
6178}
6179
6180#[cfg(all(unix, not(target_os = "macos")))]
6185mod sys {
6186 use std::ffi::OsStr;
6196 use std::path::PathBuf;
6197 use std::time::Duration;
6198
6199 use runner_manager_domain::model::StartMode;
6200
6201 use super::{
6202 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6203 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6204 home_directory, ini_directives, run, write_definition,
6205 };
6206
6207 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6208 Ok(Box::new(SystemdControl { mode }))
6209 }
6210
6211 #[derive(Debug)]
6212 struct SystemdControl {
6213 mode: StartMode,
6214 }
6215
6216 impl SystemdControl {
6217 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6218 let file = identity.systemd_unit();
6219 match self.mode {
6220 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6221 StartMode::Login => {
6222 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6223 }
6224 }
6225 }
6226
6227 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6229 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6230 if self.mode == StartMode::Login {
6231 all.push(OsStr::new("--user"));
6232 }
6233 all.extend(arguments.iter().map(OsStr::new));
6234 match run("systemctl", &all) {
6235 Ok((ok, stdout, stderr)) => (
6236 ok,
6237 if stdout.trim().is_empty() {
6238 stderr
6239 } else {
6240 stdout
6241 },
6242 ),
6243 Err(error) => (false, error.to_string()),
6244 }
6245 }
6246
6247 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6248 ServiceError::Control {
6249 operation,
6250 name: name.to_string(),
6251 manager: "systemd",
6252 detail,
6253 }
6254 }
6255 }
6256
6257 impl ServiceControl for SystemdControl {
6258 fn manager(&self) -> DefinitionKind {
6259 DefinitionKind::SystemdUnit
6260 }
6261
6262 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6263 let name = plan.identity().name().to_string();
6264 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6265 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6266 return Err(self.failed(
6267 "install",
6268 &name,
6269 "this account has no home directory, so there is nowhere to put a systemd \
6270 user unit. Use --start-at boot, which installs a system unit under \
6271 /etc/systemd/system."
6272 .to_string(),
6273 ));
6274 };
6275 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6276 let unit = plan.identity().systemd_unit();
6277 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6278 if !reloaded {
6279 let _ = std::fs::remove_file(&path);
6280 return Err(self.failed("install", &name, message));
6281 }
6282 let (enabled, message) = self.systemctl(&["enable", &unit]);
6283 if !enabled {
6284 let _ = std::fs::remove_file(&path);
6285 let _ = self.systemctl(&["daemon-reload"]);
6286 return Err(self.failed("install", &name, message));
6287 }
6288 Ok(definition)
6289 }
6290
6291 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6292 let Some(path) = self.unit_path(identity) else {
6293 return Ok(false);
6294 };
6295 if !path.exists() {
6296 return Ok(false);
6297 }
6298 let unit = identity.systemd_unit();
6299 let _ = self.systemctl(&["disable", "--now", &unit]);
6304 std::fs::remove_file(&path).map_err(|error| {
6305 super::definition_error(
6306 "uninstall",
6307 identity.name(),
6308 error,
6309 SUDO_REMEDY,
6310 path.as_path(),
6311 )
6312 })?;
6313 let _ = self.systemctl(&["daemon-reload"]);
6314 Ok(true)
6315 }
6316
6317 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6318 let Some(path) = self.unit_path(identity) else {
6319 return Ok(None);
6320 };
6321 let Ok(document) = std::fs::read_to_string(&path) else {
6322 return Ok(None);
6323 };
6324 let unit = identity.systemd_unit();
6325 let directives = ini_directives(&document, "Service");
6326 let (_, active) = self.systemctl(&["is-active", &unit]);
6327 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6328 Ok(Some(Registration {
6329 manager: DefinitionKind::SystemdUnit,
6330 start_mode: self.mode,
6331 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6332 account: directives.get("User").cloned().or_else(|| {
6333 Some(match self.mode {
6334 StartMode::Boot => "root".to_string(),
6335 StartMode::Login => "the invoking user".to_string(),
6336 })
6337 }),
6338 running: active.trim() == "active",
6339 starts_automatically: enabled.trim() == "enabled",
6340 restart_delay: directives
6341 .get("RestartSec")
6342 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6343 .map(Duration::from_secs),
6344 }))
6345 }
6346
6347 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6348 let unit = identity.systemd_unit();
6349 let (ok, message) = self.systemctl(&["start", &unit]);
6350 if ok {
6351 Ok(())
6352 } else {
6353 Err(self.failed("start", identity.name(), message))
6354 }
6355 }
6356
6357 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6358 let running = self
6359 .query(identity)?
6360 .is_some_and(|registration| registration.running);
6361 if !running {
6362 return Ok(false);
6363 }
6364 let unit = identity.systemd_unit();
6365 let (ok, message) = self.systemctl(&["stop", &unit]);
6366 if ok {
6367 Ok(true)
6368 } else {
6369 Err(self.failed("stop", identity.name(), message))
6370 }
6371 }
6372 }
6373}
6374
6375#[cfg(test)]
6376mod tests {
6377 use super::*;
6378
6379 use std::collections::BTreeMap;
6380
6381 #[test]
6382 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
6383 assert!(windows_login_task_starts_automatically(
6384 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
6385 ));
6386 assert!(windows_login_task_starts_automatically(
6387 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
6388 ));
6389 assert!(!windows_login_task_starts_automatically(
6390 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
6391 ));
6392 assert!(!windows_login_task_starts_automatically(
6393 "<Task><BootTrigger></BootTrigger></Task>"
6394 ));
6395 }
6396
6397 fn linux_plan(mode: StartMode) -> InstallPlan {
6404 InstallPlan::unchecked(
6405 ServiceIdentity::product(),
6406 mode,
6407 "/opt/runner-manager/bin/runner-manager",
6408 ServiceDirectories {
6409 config: PathBuf::from("/var/lib/runner-manager/config"),
6410 state: PathBuf::from("/var/lib/runner-manager/state"),
6411 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
6412 logs: PathBuf::from("/var/log/runner-manager"),
6413 },
6414 )
6415 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
6416 }
6417
6418 fn windows_plan(mode: StartMode) -> InstallPlan {
6419 InstallPlan::unchecked(
6420 ServiceIdentity::product(),
6421 mode,
6422 "C:\\Program Files\\runner-manager\\runner-manager.exe",
6423 ServiceDirectories {
6424 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
6425 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
6426 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
6427 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
6428 },
6429 )
6430 }
6431
6432 fn edited(text: &str, from: &str, to: &str) -> String {
6440 assert!(
6441 text.contains(from),
6442 "the rendered definition does not contain `{from}`, so this test would assert \
6443 nothing about a widened one"
6444 );
6445 text.replace(from, to)
6446 }
6447
6448 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
6450 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
6451 let Ok(entries) = std::fs::read_dir(directory) else {
6452 return;
6453 };
6454 for entry in entries.flatten() {
6455 let path = entry.path();
6456 if path.is_dir() {
6457 walk(&path, out);
6458 } else if let Ok(bytes) = std::fs::read(&path) {
6459 out.insert(path, bytes);
6460 }
6461 }
6462 }
6463 let mut out = BTreeMap::new();
6464 for root in roots {
6465 walk(root, &mut out);
6466 }
6467 out
6468 }
6469
6470 struct Host {
6471 _root: tempfile::TempDir,
6472 paths: AppPaths,
6473 binary: PathBuf,
6474 runner_root: LocalAbsolutePath,
6483 controls: RecordingControls,
6484 }
6485
6486 impl Host {
6487 fn new() -> Self {
6488 let root = tempfile::tempdir().expect("a temporary directory");
6489 let paths = AppPaths::rooted_at(root.path());
6490 paths.create_all().expect("the four directories");
6491 let binary = root.path().join(if cfg!(windows) {
6492 "runner-manager.exe"
6493 } else {
6494 "runner-manager"
6495 });
6496 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
6497 let runner_root = LocalAbsolutePath::new(
6498 root.path()
6499 .join("runner-root")
6500 .to_str()
6501 .expect("a unicode temporary path"),
6502 )
6503 .expect("a local absolute path");
6504 Self {
6505 _root: root,
6506 paths,
6507 binary,
6508 runner_root,
6509 controls: RecordingControls::new(),
6510 }
6511 }
6512
6513 fn operations(&self) -> ServiceOperations {
6514 ServiceOperations::with_controls(
6515 self.paths.clone(),
6516 ServiceIdentity::product(),
6517 std::sync::Arc::new(self.controls.clone()),
6518 )
6519 .with_runner_root(self.runner_root.clone())
6520 }
6521
6522 fn request(&self, mode: StartMode) -> InstallRequest {
6523 InstallRequest::new(mode).for_binary(&self.binary)
6524 }
6525 }
6526
6527 #[cfg(windows)]
6528 #[test]
6529 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
6530 let probes = std::cell::Cell::new(0);
6531 let absent =
6532 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
6533 let next = probes.get() + 1;
6534 probes.set(next);
6535 Ok(next == 3)
6536 })
6537 .expect("the simulated SCM probe succeeds");
6538
6539 assert!(absent);
6540 assert_eq!(
6541 probes.get(),
6542 3,
6543 "uninstall must recheck after transient presence instead of treating it as a leak"
6544 );
6545 }
6546
6547 #[cfg(windows)]
6548 #[test]
6549 fn windows_recognises_an_already_pending_service_deletion() {
6550 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
6551
6552 assert!(super::sys::is_marked_for_delete(&error));
6553 }
6554
6555 #[test]
6556 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
6557 let root = tempfile::tempdir().expect("a temporary directory");
6558 let plist = root.path().join("fixture.plist");
6559 std::fs::write(&plist, b"fixture").expect("a plist fixture");
6560 let calls = std::cell::RefCell::new(Vec::new());
6561
6562 let error = enable_launchd_registration(
6563 |arguments| {
6564 let call = arguments
6565 .iter()
6566 .map(|argument| argument.to_string_lossy().into_owned())
6567 .collect::<Vec<_>>();
6568 let operation = call[0].clone();
6569 calls.borrow_mut().push(call);
6570 if operation == "enable" {
6571 (false, "label remains disabled".to_string())
6572 } else {
6573 (true, String::new())
6574 }
6575 },
6576 "system",
6577 "system/com.openai.runner-manager-selftest",
6578 &plist,
6579 "runner-manager-selftest",
6580 "rerun with administrative rights",
6581 )
6582 .expect_err("enable failure must fail the install");
6583
6584 assert!(
6585 matches!(
6586 error,
6587 ServiceError::Control {
6588 operation: "enable",
6589 ..
6590 }
6591 ),
6592 "{error}"
6593 );
6594 assert_eq!(calls.borrow().len(), 2);
6595 assert_eq!(calls.borrow()[0][0], "enable");
6596 assert_eq!(calls.borrow()[1][0], "bootout");
6597 assert!(!plist.exists(), "rollback must remove the plist");
6598 }
6599
6600 #[test]
6605 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
6606 let plan = windows_plan(StartMode::Boot);
6607 let command_line = plan.command_line();
6608 assert!(
6609 command_line.starts_with('"'),
6610 "a path with a space must be quoted, got {command_line}"
6611 );
6612 assert_eq!(
6613 executable_from_command_line(&command_line).as_deref(),
6614 Some(plan.binary())
6615 );
6616 }
6617
6618 #[test]
6619 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
6620 let plan = linux_plan(StartMode::Boot);
6621 let command_line = plan.command_line();
6622 assert!(!command_line.starts_with('"'), "got {command_line}");
6623 assert_eq!(
6624 executable_from_command_line(&command_line).as_deref(),
6625 Some(plan.binary())
6626 );
6627 }
6628
6629 #[test]
6630 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
6631 let awkward = r#"C:\odd "name"\rm.exe"#;
6634 let quoted = quote_argument(awkward);
6635 assert_eq!(
6636 executable_from_command_line(&format!("{quoted} daemon run"))
6637 .as_deref()
6638 .map(Path::to_string_lossy)
6639 .as_deref(),
6640 Some(awkward)
6641 );
6642 }
6643
6644 #[test]
6645 fn an_empty_command_line_has_no_executable() {
6646 assert_eq!(executable_from_command_line(" "), None);
6647 assert_eq!(executable_from_command_line(""), None);
6648 }
6649
6650 #[test]
6651 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
6652 let awkward = r#"DOMAIN\R&D <team> "ops""#;
6653 assert_eq!(
6654 xml_escape(awkward),
6655 "DOMAIN\\R&D <team> "ops""
6656 );
6657 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
6658 }
6659
6660 #[test]
6665 fn a_restart_delay_under_the_floor_is_refused() {
6666 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
6667 .expect_err("half a second is under the one-second floor");
6668 assert!(
6669 matches!(error, ServiceError::RestartDelay { .. }),
6670 "{error}"
6671 );
6672 }
6673
6674 #[test]
6675 fn a_restart_delay_over_the_ceiling_is_refused() {
6676 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
6677 .expect_err("an hour is over the five-minute ceiling");
6678 assert!(
6679 matches!(error, ServiceError::RestartDelay { .. }),
6680 "{error}"
6681 );
6682 }
6683
6684 #[test]
6685 fn a_reset_window_no_longer_than_the_delay_is_refused() {
6686 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
6687 .expect_err("a window equal to the delay can never elapse between restarts");
6688 assert!(
6689 matches!(error, ServiceError::RestartResetWindow { .. }),
6690 "{error}"
6691 );
6692 }
6693
6694 #[test]
6695 fn a_delay_inside_the_bound_is_accepted() {
6696 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
6697 .expect("twenty seconds is inside the bound");
6698 assert_eq!(policy.delay(), Duration::from_secs(20));
6699 assert_eq!(policy.reset_after(), Duration::from_secs(300));
6700 }
6701
6702 #[test]
6707 fn a_fixture_identity_can_never_be_the_product_identity() {
6708 let fixture = ServiceIdentity::fixture("abc123");
6709 assert!(fixture.is_fixture());
6710 assert!(!ServiceIdentity::product().is_fixture());
6711 assert_ne!(fixture.name(), ServiceIdentity::product().name());
6712 assert_ne!(
6713 fixture.launchd_label(),
6714 ServiceIdentity::product().launchd_label()
6715 );
6716 assert_ne!(
6717 fixture.systemd_unit(),
6718 ServiceIdentity::product().systemd_unit()
6719 );
6720 }
6721
6722 #[test]
6723 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
6724 let fixture = ServiceIdentity::fixture("A b/c:\\d");
6725 assert!(
6726 fixture
6727 .name()
6728 .chars()
6729 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
6730 "got {}",
6731 fixture.name()
6732 );
6733 }
6734
6735 #[test]
6740 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
6741 let unit = systemd_unit(&linux_plan(StartMode::Boot));
6742 assert!(unit.contains("KillMode=process\n"), "{unit}");
6743 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
6744 assert!(unit.contains("RestartSec=15\n"), "{unit}");
6745 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
6746 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
6747 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
6748 }
6749
6750 #[test]
6751 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
6752 let unit = systemd_unit(&linux_plan(StartMode::Boot));
6753 assert!(
6754 !unit.contains("LoadCredential="),
6755 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
6756 );
6757 }
6758
6759 #[test]
6760 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
6761 let unit = systemd_unit(&linux_plan(StartMode::Login));
6762 assert!(
6763 !unit.contains("LoadCredential="),
6764 "a user unit must not name a root-owned credential file, got:\n{unit}"
6765 );
6766 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
6767 }
6768
6769 #[test]
6770 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
6771 let plan = linux_plan(StartMode::Boot);
6772 let unit = systemd_unit(&plan);
6773 let directives = ini_directives(&unit, "Service");
6774 let listed = split_quoted(
6775 directives
6776 .get("ReadWritePaths")
6777 .expect("the unit names its writable paths"),
6778 );
6779 assert_eq!(listed.len(), 5, "{listed:?}");
6780 for path in plan.directories().all() {
6781 assert!(
6782 listed.iter().any(|entry| entry == &path.to_string_lossy()),
6783 "{} is missing from {listed:?}",
6784 path.display()
6785 );
6786 }
6787 assert!(
6788 listed
6789 .iter()
6790 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
6791 "atomic credential replacement needs its parent directory in {listed:?}"
6792 );
6793 }
6794
6795 #[test]
6796 fn the_unit_records_the_absolute_binary_path() {
6797 let plan = linux_plan(StartMode::Boot);
6798 let unit = systemd_unit(&plan);
6799 assert!(
6800 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
6801 "{unit}"
6802 );
6803 }
6804
6805 #[test]
6810 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
6811 let plist = launchd_plist(&linux_plan(StartMode::Boot));
6812 assert!(
6815 plist.contains(
6816 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
6817 ),
6818 "{plist}"
6819 );
6820 assert!(
6821 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
6822 "{plist}"
6823 );
6824 }
6825
6826 #[test]
6827 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
6828 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
6829 assert_eq!(
6830 plist_string_value(&daemon, "UserName").as_deref(),
6831 Some("root")
6832 );
6833 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
6834
6835 let agent = launchd_plist(&linux_plan(StartMode::Login));
6836 assert_eq!(
6837 plist_string_value(&agent, "UserName"),
6838 None,
6839 "a LaunchAgent already runs as the operator:\n{agent}"
6840 );
6841 }
6842
6843 #[test]
6844 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
6845 let plist = launchd_plist(&linux_plan(StartMode::Boot));
6846 assert!(
6847 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
6848 "{plist}"
6849 );
6850 assert!(plist.contains("<string>daemon</string>"), "{plist}");
6851 assert!(plist.contains("<string>run</string>"), "{plist}");
6852 }
6853
6854 #[test]
6855 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
6856 assert_eq!(
6857 ServiceIdentity::product().launchd_label(),
6858 "io.github.IvanMurzak.runner-manager"
6859 );
6860 }
6861
6862 #[test]
6867 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
6868 let xml = windows_scheduled_task_xml(
6869 &windows_plan(StartMode::Login),
6870 &TaskPrincipal::named("HOST\\operator"),
6871 );
6872 assert!(xml.contains("<LogonTrigger>"), "{xml}");
6873 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
6874 assert!(
6875 xml.contains("<LogonType>InteractiveToken</LogonType>"),
6876 "{xml}"
6877 );
6878 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
6879 assert!(
6880 xml.contains("<Interval>PT1M</Interval>"),
6881 "Task Scheduler takes whole minutes only, and rejects the registration outright \
6882 for anything finer:\n{xml}"
6883 );
6884 }
6885
6886 #[test]
6887 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
6888 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
6893 let policy =
6894 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
6895 .expect("inside the supported range");
6896 assert_eq!(
6897 policy
6898 .effective_delay(DefinitionKind::WindowsScheduledTask)
6899 .as_secs(),
6900 enforced,
6901 "a {asked}s delay must be enforced as {enforced}s"
6902 );
6903 assert!(
6904 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
6905 "rounding must never shorten the bound"
6906 );
6907 }
6908 }
6909
6910 #[test]
6911 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
6912 let policy = RestartPolicy::default();
6913 for kind in [
6914 DefinitionKind::WindowsService,
6915 DefinitionKind::LaunchdPlist,
6916 DefinitionKind::SystemdUnit,
6917 ] {
6918 assert_eq!(
6919 policy.effective_delay(kind),
6920 policy.delay(),
6921 "{kind:?} takes seconds and enforces exactly what it is given"
6922 );
6923 }
6924 }
6925
6926 #[test]
6927 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
6928 let host = Host::new();
6929 let operations = host.operations();
6930 operations
6931 .install(&host.request(StartMode::Login))
6932 .expect("an install at login");
6933 host.controls.edit("runner-manager", |registration| {
6935 registration.manager = DefinitionKind::WindowsScheduledTask;
6936 registration.restart_delay = Some(Duration::from_secs(60));
6937 });
6938
6939 let status = operations.status().expect("a status");
6940 assert!(
6941 status.is_healthy(),
6942 "minute granularity is the manager's, not a mis-registration: {status}"
6943 );
6944 assert!(
6945 status
6946 .notes()
6947 .iter()
6948 .any(|note| note.contains("whole minutes")),
6949 "but the operator must be told why 15 became 60: {status}"
6950 );
6951
6952 host.controls.edit("runner-manager", |registration| {
6955 registration.restart_delay = Some(Duration::from_secs(1));
6956 });
6957 assert!(
6958 !operations.status().expect("a status").is_healthy(),
6959 "a one-second delay is not what any manager was asked for"
6960 );
6961 }
6962
6963 #[test]
6964 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
6965 let plan = windows_plan(StartMode::Login);
6966 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
6967 assert_eq!(
6968 xml_value(&xml, "Command").as_deref(),
6969 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
6970 "{xml}"
6971 );
6972 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
6973 }
6974
6975 #[test]
6976 fn an_account_name_holding_xml_punctuation_is_escaped() {
6977 let xml = windows_scheduled_task_xml(
6978 &windows_plan(StartMode::Login),
6979 &TaskPrincipal::named("R&D\\ops"),
6980 );
6981 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
6982 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
6983 }
6984
6985 #[test]
6990 fn the_service_starts_automatically_under_the_account_the_store_admits() {
6991 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
6992 let directives = ini_directives(&text, "windows-service");
6993 assert_eq!(
6994 directives.get("StartType").map(String::as_str),
6995 Some("AutoStart")
6996 );
6997 assert_eq!(
6998 directives.get("Account").map(String::as_str),
6999 Some("NT AUTHORITY\\SYSTEM")
7000 );
7001 assert_eq!(
7002 directives.get("ServiceType").map(String::as_str),
7003 Some("OWN_PROCESS")
7004 );
7005 assert_eq!(
7006 directives
7007 .get("FailureActionRestartDelaySecs")
7008 .map(String::as_str),
7009 Some("15")
7010 );
7011 assert_eq!(
7012 directives
7013 .get("FailureActionsOnNonCrashFailures")
7014 .map(String::as_str),
7015 Some("true"),
7016 "without this flag a non-zero exit is not a failure the manager restarts"
7017 );
7018 }
7019
7020 #[test]
7021 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
7022 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
7023 assert_eq!(spec.account, None);
7024 assert!(spec.automatic_start);
7025 assert!(
7026 spec.command_line.contains("daemon run"),
7027 "{}",
7028 spec.command_line
7029 );
7030 }
7031
7032 #[test]
7037 fn each_definition_goes_where_its_platform_expects_it() {
7038 let home = PathBuf::from("/home/op");
7039 assert_eq!(
7040 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
7041 Some(Path::new(
7042 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
7043 ))
7044 );
7045 assert_eq!(
7046 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
7047 Some(Path::new(
7048 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
7049 ))
7050 );
7051 assert_eq!(
7052 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
7053 Some(Path::new("/etc/systemd/system/runner-manager.service"))
7054 );
7055 assert_eq!(
7056 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
7057 Some(Path::new(
7058 "/home/op/.config/systemd/user/runner-manager.service"
7059 ))
7060 );
7061 assert_eq!(
7062 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
7063 None,
7064 "the Service Control Manager has no file"
7065 );
7066 }
7067
7068 #[test]
7069 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
7070 assert_eq!(
7071 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
7072 None
7073 );
7074 assert_eq!(
7075 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
7076 None
7077 );
7078 }
7079
7080 #[test]
7085 fn the_rendered_definitions_are_all_least_privilege() {
7086 let linux = linux_plan(StartMode::Boot);
7087 let windows = windows_plan(StartMode::Boot);
7088 for (definition, plan) in [
7089 (ServiceDefinition::systemd(&linux, None), &linux),
7090 (ServiceDefinition::launchd(&linux, None), &linux),
7091 (ServiceDefinition::windows_service(&windows), &windows),
7092 ] {
7093 let review = review_least_privilege(&definition, plan);
7094 assert!(
7095 review.is_least_privilege(),
7096 "{:?} should be least privilege, got:\n{review}",
7097 definition.kind()
7098 );
7099 assert!(
7100 !review.controls().is_empty(),
7101 "a review that confirms nothing proves nothing: {:?}",
7102 definition.kind()
7103 );
7104 }
7105 }
7106
7107 #[test]
7108 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
7109 let plan = windows_plan(StartMode::Login);
7110 let definition =
7111 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
7112 let review = review_least_privilege(&definition, &plan);
7113 assert!(review.is_least_privilege(), "{review}");
7114 assert!(
7115 review
7116 .controls()
7117 .iter()
7118 .any(|control| control.contains("LeastPrivilege")),
7119 "{review}"
7120 );
7121 }
7122
7123 #[test]
7124 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
7125 let plan = linux_plan(StartMode::Boot);
7126 let rendered = systemd_unit(&plan);
7127 let widened = edited(
7128 &rendered,
7129 "ReadWritePaths=/var/lib/runner-manager/config",
7130 "ReadWritePaths=/etc /var/lib/runner-manager/config",
7131 );
7132 let review = review_least_privilege(
7133 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7134 &plan,
7135 );
7136 assert!(!review.is_least_privilege(), "{review}");
7137 assert!(
7138 review
7139 .excesses()
7140 .iter()
7141 .any(|finding| finding.detail.contains("/etc")),
7142 "the review must name the directory it objects to: {review}"
7143 );
7144 }
7145
7146 #[test]
7147 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
7148 let plan = linux_plan(StartMode::Boot);
7149 let rendered = systemd_unit(&plan);
7150 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
7151 let review = review_least_privilege(
7152 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
7153 &plan,
7154 );
7155 assert!(!review.is_least_privilege(), "{review}");
7156 assert!(
7157 review
7158 .excesses()
7159 .iter()
7160 .any(|finding| finding.subject == "NoNewPrivileges"),
7161 "{review}"
7162 );
7163 }
7164
7165 #[test]
7166 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
7167 let plan = linux_plan(StartMode::Boot);
7168 let rendered = systemd_unit(&plan);
7169 let widened = edited(
7170 &rendered,
7171 "CapabilityBoundingSet=\n",
7172 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
7173 );
7174 let review = review_least_privilege(
7175 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7176 &plan,
7177 );
7178 assert!(!review.is_least_privilege(), "{review}");
7179 assert!(
7180 review
7181 .excesses()
7182 .iter()
7183 .any(|finding| finding.subject == "CapabilityBoundingSet"),
7184 "{review}"
7185 );
7186 }
7187
7188 #[test]
7189 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
7190 let plan = linux_plan(StartMode::Boot);
7191 let rendered = systemd_unit(&plan);
7192 let widened = edited(
7193 &rendered,
7194 "[Install]",
7195 "ListenStream=127.0.0.1:9000\n\n[Install]",
7196 );
7197 let review = review_least_privilege(
7198 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7199 &plan,
7200 );
7201 assert!(
7202 !review.is_least_privilege(),
7203 "07-security.md rule 2 forbids any inbound surface: {review}"
7204 );
7205 }
7206
7207 #[test]
7208 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
7209 let plan = linux_plan(StartMode::Boot);
7210 let rendered = systemd_unit(&plan);
7211 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
7212 let review = review_least_privilege(
7213 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
7214 &plan,
7215 );
7216 assert!(
7217 review.is_least_privilege(),
7218 "too little authority is not an excess: {review}"
7219 );
7220 assert!(
7221 review
7222 .findings()
7223 .iter()
7224 .any(|finding| finding.kind == FindingKind::Shortfall
7225 && finding.detail.contains("runtime")),
7226 "{review}"
7227 );
7228 }
7229
7230 #[test]
7231 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
7232 let plan = linux_plan(StartMode::Login);
7233 let rendered = launchd_plist(&plan);
7234 let widened = edited(
7235 &rendered,
7236 "<key>ProcessType</key>",
7237 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
7238 );
7239 let review = review_least_privilege(
7240 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7241 &plan,
7242 );
7243 assert!(!review.is_least_privilege(), "{review}");
7244 assert!(
7245 review
7246 .excesses()
7247 .iter()
7248 .any(|finding| finding.subject == "UserName"),
7249 "{review}"
7250 );
7251 }
7252
7253 #[test]
7254 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
7255 let plan = linux_plan(StartMode::Boot);
7256 let rendered = launchd_plist(&plan);
7257 let widened = edited(
7258 &rendered,
7259 "<key>SessionCreate</key>\n <false/>",
7260 "<key>SessionCreate</key>\n <true/>",
7261 );
7262 let review = review_least_privilege(
7263 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7264 &plan,
7265 );
7266 assert!(!review.is_least_privilege(), "{review}");
7267 assert!(
7268 review
7269 .excesses()
7270 .iter()
7271 .any(|finding| finding.subject == "SessionCreate"),
7272 "{review}"
7273 );
7274 }
7275
7276 #[test]
7277 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
7278 let plan = linux_plan(StartMode::Boot);
7279 let rendered = launchd_plist(&plan);
7280 let widened = edited(
7281 &rendered,
7282 "<key>ProcessType</key>",
7283 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
7284 );
7285 let review = review_least_privilege(
7286 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7287 &plan,
7288 );
7289 assert!(!review.is_least_privilege(), "{review}");
7290 }
7291
7292 #[test]
7293 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
7294 let plan = windows_plan(StartMode::Login);
7295 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7296 let widened = edited(
7297 &rendered,
7298 "<RunLevel>LeastPrivilege</RunLevel>",
7299 "<RunLevel>HighestAvailable</RunLevel>",
7300 );
7301 let review = review_least_privilege(
7302 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7303 &plan,
7304 );
7305 assert!(!review.is_least_privilege(), "{review}");
7306 assert!(
7307 review
7308 .excesses()
7309 .iter()
7310 .any(|finding| finding.subject == "RunLevel"),
7311 "{review}"
7312 );
7313 }
7314
7315 #[test]
7316 fn a_task_that_would_store_a_password_is_not_least_privilege() {
7317 let plan = windows_plan(StartMode::Login);
7318 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7319 let widened = edited(
7320 &rendered,
7321 "<LogonType>InteractiveToken</LogonType>",
7322 "<LogonType>Password</LogonType>",
7323 );
7324 let review = review_least_privilege(
7325 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7326 &plan,
7327 );
7328 assert!(!review.is_least_privilege(), "{review}");
7329 }
7330
7331 #[test]
7332 fn an_interactive_windows_service_is_not_least_privilege() {
7333 let plan = windows_plan(StartMode::Boot);
7334 let rendered = windows_service_descriptor(&plan);
7335 let widened = edited(
7336 &rendered,
7337 "ServiceType=OWN_PROCESS",
7338 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
7339 );
7340 let review = review_least_privilege(
7341 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
7342 &plan,
7343 );
7344 assert!(!review.is_least_privilege(), "{review}");
7345 assert!(
7346 review
7347 .excesses()
7348 .iter()
7349 .any(|finding| finding.subject == "ServiceType"),
7350 "{review}"
7351 );
7352 }
7353
7354 #[test]
7355 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
7356 let plan = windows_plan(StartMode::Boot);
7357 let rendered = windows_service_descriptor(&plan);
7358 let changed = edited(
7359 &rendered,
7360 "Account=NT AUTHORITY\\SYSTEM",
7361 "Account=NT AUTHORITY\\LocalService",
7362 );
7363 let review = review_least_privilege(
7364 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
7365 &plan,
7366 );
7367 assert!(review.is_least_privilege(), "{review}");
7373 assert!(
7374 review.findings().iter().any(|finding| {
7375 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
7376 }),
7377 "{review}"
7378 );
7379 }
7380
7381 #[test]
7386 fn a_recorded_path_that_is_still_there_is_current() {
7387 let host = Host::new();
7388 let state = inspect_binary(&host.binary, Some(&host.binary));
7389 assert!(!state.is_error(), "{state}");
7390 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
7391 }
7392
7393 #[test]
7394 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
7395 let host = Host::new();
7396 let recorded = host.binary.clone();
7400 let healthy = inspect_binary(&recorded, Some(&recorded));
7401 assert!(
7402 !healthy.is_error(),
7403 "the discriminator: before the binary moves, this must be healthy"
7404 );
7405
7406 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
7407
7408 let state = inspect_binary(&recorded, Some(&recorded));
7409 assert!(state.is_error(), "{state}");
7410 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
7411 assert!(
7412 state.to_string().contains("npm"),
7413 "the message must name the cause an operator will not otherwise connect: {state}"
7414 );
7415 }
7416
7417 #[test]
7418 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
7419 let root = tempfile::tempdir().expect("a temporary directory");
7420 let state = inspect_binary(root.path(), None);
7421 assert!(state.is_error(), "{state}");
7422 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
7423 }
7424
7425 #[test]
7426 fn a_registration_naming_a_different_binary_is_a_divergence() {
7427 let host = Host::new();
7428 let other = host.binary.with_file_name("something-else");
7429 let state = inspect_binary(&host.binary, Some(&other));
7430 assert!(state.is_error(), "{state}");
7431 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
7432 }
7433
7434 #[test]
7435 fn absence_is_reported_before_divergence() {
7436 let host = Host::new();
7437 let recorded = host.binary.clone();
7438 std::fs::remove_file(&recorded).expect("removable");
7439 let other = recorded.with_file_name("something-else");
7440 assert!(matches!(
7444 inspect_binary(&recorded, Some(&other)),
7445 BinaryPath::Missing { .. }
7446 ));
7447 }
7448
7449 #[test]
7454 fn the_record_round_trips_through_toml() {
7455 let host = Host::new();
7456 let plan = InstallPlan::resolve(
7457 ServiceIdentity::product(),
7458 &host.request(StartMode::Boot),
7459 ServiceDirectories::of(&host.paths),
7460 )
7461 .expect("a resolvable plan");
7462 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7463 let record = InstallRecord::of(&plan, &definition, Utc::now());
7464 record.write(&host.paths).expect("a writable record");
7465 let read = InstallRecord::read(&host.paths)
7466 .expect("a readable record")
7467 .expect("a record is there");
7468 assert_eq!(read, record);
7469 assert_eq!(read.binary, host.binary);
7470 assert!(read.binary.is_absolute());
7471 }
7472
7473 #[cfg(unix)]
7482 #[test]
7483 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
7484 use std::os::unix::fs::PermissionsExt as _;
7485
7486 let host = Host::new();
7487 let plan = InstallPlan::resolve(
7488 ServiceIdentity::product(),
7489 &host.request(StartMode::Boot),
7490 ServiceDirectories::of(&host.paths),
7491 )
7492 .expect("a resolvable plan");
7493 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7494 InstallRecord::of(&plan, &definition, Utc::now())
7495 .write(&host.paths)
7496 .expect("a writable record");
7497
7498 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
7499 .expect("the record is there")
7500 .permissions()
7501 .mode()
7502 & 0o777;
7503 assert_eq!(
7504 mode, 0o644,
7505 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
7506 service install` wrote, and `service status` fails on their own host. It holds no \
7507 credential and sits in a 0700 directory, so 0644 discloses nothing"
7508 );
7509 }
7510
7511 #[test]
7515 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
7516 let host = Host::new();
7517 let path = InstallRecord::path(&host.paths);
7518 std::fs::write(
7519 &path,
7520 format!(
7521 "schema_version = {RECORD_SCHEMA_VERSION}
7522service_name = \"runner-manager\"
7523 manager = \"systemd\"
7524start_mode = \"boot\"
7525account = \"root\"
7526 binary = \"/x\"
7527arguments = []
7528restart_delay_secs = 15
7529 restart_reset_secs = 600
7530log_file = \"/x\"
7531 installed_at = \"2026-01-01T00:00:00Z\"
7532installed_by_version = \"0.1.0\"
7533 [directories]
7534config = \"/a\"
7535state = \"/b\"
7536runtime = \"/c\"
7537logs = \"/d\"
7538"
7539 ),
7540 )
7541 .expect("a writable record");
7542 let read = InstallRecord::read(&host.paths)
7543 .expect("a record missing an optional field is still readable")
7544 .expect("a record is there");
7545 assert_eq!(
7546 read.source_binary, None,
7547 "the legacy layout has no source, and must not invent one"
7548 );
7549 }
7550
7551 #[test]
7553 fn a_registration_remembers_the_file_it_was_copied_from() {
7554 let host = Host::new();
7555 let source = host.binary.with_file_name("npm-installed-runner-manager");
7556 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
7557 let plan = InstallPlan::resolve(
7558 ServiceIdentity::product(),
7559 &host.request(StartMode::Boot).copied_from(&source),
7560 ServiceDirectories::of(&host.paths),
7561 )
7562 .expect("a resolvable plan");
7563 let definition = ServiceDefinition::from_text(
7564 DefinitionKind::SystemdUnit,
7565 "[Service]
7566",
7567 );
7568 let record = InstallRecord::of(&plan, &definition, Utc::now());
7569 record.write(&host.paths).expect("a writable record");
7570
7571 let read = InstallRecord::read(&host.paths)
7572 .expect("a readable record")
7573 .expect("a record is there");
7574 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
7575 assert_ne!(
7576 read.source_binary.as_deref(),
7577 Some(read.binary.as_path()),
7578 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
7579 );
7580 }
7581
7582 #[test]
7583 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
7584 let host = Host::new();
7585 let path = InstallRecord::path(&host.paths);
7586 std::fs::write(
7587 &path,
7588 format!(
7589 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
7590 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
7591 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
7592 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
7593 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
7594 RECORD_SCHEMA_VERSION + 1
7595 ),
7596 )
7597 .expect("a writable record");
7598 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
7599 assert!(
7600 matches!(error, ServiceError::RecordUnreadable { .. }),
7601 "{error}"
7602 );
7603 assert!(
7604 error.to_string().contains("service uninstall"),
7605 "the message must say how to recover: {error}"
7606 );
7607 }
7608
7609 #[test]
7610 fn no_record_is_not_an_error() {
7611 let host = Host::new();
7612 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
7613 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
7614 }
7615
7616 #[test]
7621 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
7622 let host = Host::new();
7623 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
7624 }
7625
7626 #[test]
7627 fn the_heartbeat_round_trips_to_the_second() {
7628 let host = Host::new();
7629 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
7630 .expect("a valid timestamp")
7631 .with_timezone(&Utc);
7632 record_github_contact(&host.paths, at).expect("a writable heartbeat");
7633 assert_eq!(
7634 last_github_contact(&host.paths).expect("readable"),
7635 Some(at)
7636 );
7637 }
7638
7639 #[test]
7640 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
7641 let host = Host::new();
7642 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
7643 let error = last_github_contact(&host.paths)
7644 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
7645 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7646 }
7647
7648 #[test]
7654 fn a_runner_root_refusal_round_trips_for_service_status() {
7655 let host = Host::new();
7656 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
7657
7658 assert!(
7659 runner_root_refusals(&host.paths)
7660 .expect("readable")
7661 .is_empty(),
7662 "no record means every policy is placing runners"
7663 );
7664
7665 record_runner_root_refusal(
7666 &host.paths,
7667 "policy-a",
7668 at,
7669 "denied_by_privacy_policy",
7670 "/Volumes/NVME/runners",
7671 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
7672 )
7673 .expect("a writable record");
7674
7675 let refusals = runner_root_refusals(&host.paths).expect("readable");
7676 assert_eq!(refusals.len(), 1);
7677 assert_eq!(refusals[0].policy, "policy-a");
7678 assert_eq!(refusals[0].at, at);
7679 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
7680 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
7681 assert!(
7682 refusals[0].detail.contains("/Volumes/NVME/runners")
7683 && refusals[0].detail.contains("Full Disk Access"),
7684 "the path and the remediation are the whole point of this file: {refusals:?}"
7685 );
7686 }
7687
7688 #[test]
7696 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
7697 let host = Host::new();
7698 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
7699 record_runner_root_refusal(
7700 &host.paths,
7701 "broken",
7702 at,
7703 "denied_by_privacy_policy",
7704 "/Volumes/NVME/runners",
7705 "detail",
7706 )
7707 .expect("a writable record");
7708 record_runner_root_refusal(
7709 &host.paths,
7710 "also-broken",
7711 at,
7712 "not_writable",
7713 "/srv/other",
7714 "detail",
7715 )
7716 .expect("a writable record");
7717
7718 clear_runner_root_refusal(&host.paths, "healthy")
7720 .expect("clearing an absent policy is fine");
7721 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
7722
7723 let refusals = runner_root_refusals(&host.paths).expect("readable");
7724 assert_eq!(
7725 refusals
7726 .iter()
7727 .map(|r| r.policy.as_str())
7728 .collect::<Vec<_>>(),
7729 vec!["broken"],
7730 "the policy that is still refused must keep its record"
7731 );
7732 }
7733
7734 #[test]
7737 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
7738 let host = Host::new();
7739 record_runner_root_refusal(
7740 &host.paths,
7741 "p",
7742 Utc::now(),
7743 "not_writable",
7744 "/srv/x",
7745 "detail",
7746 )
7747 .expect("a writable record");
7748
7749 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
7750 assert!(
7751 !root_refusal_path(&host.paths).exists(),
7752 "a host with nothing refused leaves nothing behind"
7753 );
7754 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
7755 }
7756
7757 #[test]
7760 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
7761 let host = Host::new();
7762 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
7763 let error = runner_root_refusals(&host.paths)
7764 .expect_err("an unparseable record is not the same as no record");
7765 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7766 }
7767
7768 #[test]
7778 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
7779 let host = Host::new();
7780 record_runner_root_refusal(
7781 &host.paths,
7782 "policy-a",
7783 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
7784 "denied_by_privacy_policy",
7785 "/Volumes/NVME/runners",
7786 "Grant Full Disk Access to the program that runs the service",
7787 )
7788 .expect("a writable record");
7789
7790 let status = host.operations().status().expect("a readable status");
7791 let notes = status.notes().join("\n");
7792
7793 assert!(
7794 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
7795 "the directory and the remediation the log had to scrub must appear here: {notes}"
7796 );
7797 assert!(
7798 notes.contains("policy-a"),
7799 "the operator has to know which target placed no runner: {notes}"
7800 );
7801 assert!(
7802 !status
7803 .problems()
7804 .iter()
7805 .any(|problem| problem.subject == "runner root"),
7806 "a record nothing an operator types can clear must not drive the exit code"
7807 );
7808 }
7809
7810 #[test]
7816 fn install_records_the_absolute_binary_path_and_the_four_directories() {
7817 let host = Host::new();
7818 let installed = host
7819 .operations()
7820 .install(&host.request(StartMode::Boot))
7821 .expect("an install against the recording controls");
7822
7823 assert_eq!(installed.record.binary, host.binary);
7824 assert!(installed.record.binary.is_absolute());
7825 assert_eq!(installed.record.start_mode, StartMode::Boot);
7826 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
7827 assert_eq!(
7828 installed.record.directories,
7829 ServiceDirectories::of(&host.paths)
7830 );
7831 assert_eq!(
7832 installed.record.log_file,
7833 host.paths.logs_dir().join(LOG_FILE_STEM)
7834 );
7835 assert_eq!(installed.record.restart_delay_secs, 15);
7836
7837 let registrations = host.controls.registrations();
7838 assert_eq!(registrations.len(), 1);
7839 assert_eq!(registrations[0].0, StartMode::Boot);
7840 assert_eq!(registrations[0].1, "runner-manager");
7841 }
7842
7843 #[test]
7844 fn install_is_refused_while_the_single_instance_lock_is_held() {
7845 let host = Host::new();
7846 {
7848 let operations = host.operations();
7849 operations
7850 .install(&host.request(StartMode::Boot))
7851 .expect("an install with the lock free");
7852 operations.uninstall().expect("a clean slate");
7853 }
7854
7855 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
7856 .expect("this process takes the lock first");
7857
7858 let error = host
7859 .operations()
7860 .install(&host.request(StartMode::Boot))
7861 .expect_err("a second agent must not be registered while one is running");
7862 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
7863 assert!(
7864 error.to_string().contains("already running"),
7865 "the message must be actionable: {error}"
7866 );
7867 assert!(
7868 host.controls.registrations().is_empty(),
7869 "a refused install must register nothing"
7870 );
7871 assert_eq!(
7872 InstallRecord::read(&host.paths).expect("readable"),
7873 None,
7874 "a refused install must write no record"
7875 );
7876 }
7877
7878 #[test]
7886 fn installing_over_the_same_start_mode_replaces_the_registration() {
7887 let host = Host::new();
7888 let operations = host.operations();
7889 operations
7890 .install(&host.request(StartMode::Boot))
7891 .expect("the first install");
7892
7893 let again = operations
7894 .install(&host.request(StartMode::Boot))
7895 .expect("an install over the same mode replaces rather than refusing");
7896
7897 assert!(
7898 again.replaced_existing,
7899 "the operator is told this replaced something rather than made it"
7900 );
7901 assert_eq!(
7902 host.controls.registrations().len(),
7903 1,
7904 "replacing must not leave two registrations behind"
7905 );
7906 assert_eq!(
7907 InstallRecord::read(&host.paths)
7908 .expect("readable")
7909 .expect("a record")
7910 .start_mode,
7911 StartMode::Boot
7912 );
7913 }
7914
7915 #[test]
7919 fn installing_over_the_other_start_mode_is_refused() {
7920 let host = Host::new();
7921 let operations = host.operations();
7922 operations
7923 .install(&host.request(StartMode::Boot))
7924 .expect("the first install");
7925
7926 let error = operations
7927 .install(&host.request(StartMode::Login))
7928 .expect_err("a mode change is not an install");
7929 assert!(
7930 matches!(
7931 error,
7932 ServiceError::AlreadyInstalled {
7933 existing: StartMode::Boot,
7934 requested: StartMode::Login,
7935 ..
7936 }
7937 ),
7938 "{error}"
7939 );
7940 assert!(
7941 !error
7942 .to_string()
7943 .contains("switch the start mode in place,"),
7944 "the old remedy named a capability no command offers; the terminal UI is where \
7945 the start mode moves: {error}"
7946 );
7947 assert_eq!(host.controls.registrations().len(), 1);
7948 }
7949
7950 #[test]
7951 fn install_rolls_back_the_registration_when_record_persistence_fails() {
7952 let host = Host::new();
7953 let record_path = InstallRecord::path(&host.paths);
7954 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
7955
7956 let error = host
7957 .operations()
7958 .install(&host.request(StartMode::Boot))
7959 .expect_err("record persistence must fail");
7960
7961 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7962 assert!(
7963 host.controls.registrations().is_empty(),
7964 "a failed install must not leave a live unrecorded registration"
7965 );
7966 assert!(
7967 host.controls
7968 .calls()
7969 .iter()
7970 .any(|call| call == "uninstall runner-manager (boot)"),
7971 "the registration must be explicitly rolled back: {:?}",
7972 host.controls.calls()
7973 );
7974 assert!(
7975 !host.runner_root.as_path().exists(),
7976 "the rollback must take the runner root this install created with it; a directory \
7977 prepared for a registration that does not exist is litter, and on Windows it is \
7978 litter with a security descriptor"
7979 );
7980 }
7981
7982 #[test]
7987 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
7988 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
7989
7990 assert_eq!(
7994 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
7995 ServiceAccount::LocalSystem
7996 );
7997 assert_eq!(
7998 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
7999 ServiceAccount::InvokingUser
8000 );
8001
8002 let boot = default_root_sddl(&RootAdmission::LocalSystem);
8003 assert!(!grants_broad_write(&boot), "{boot}");
8004 assert!(
8005 !boot.contains("S-1-5-21"),
8006 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
8007 );
8008
8009 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
8014 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
8015 assert!(!grants_broad_write(&login), "{login}");
8016 }
8017
8018 #[test]
8019 fn an_install_reports_the_runner_root_it_prepared() {
8020 let host = Host::new();
8021 let installed = host
8022 .operations()
8023 .install(&host.request(StartMode::Boot))
8024 .expect("an install");
8025 let rendered = installed.runner_root.to_string();
8026 assert!(
8027 !rendered.contains("S-1-5-21"),
8028 "the report must add no identity to the output: {rendered}"
8029 );
8030 if cfg!(windows) {
8031 assert_eq!(
8032 installed.runner_root.path(),
8033 Some(host.runner_root.as_path())
8034 );
8035 assert!(
8036 host.runner_root.as_path().is_dir(),
8037 "the directory jobs would run in has to exist once the service is registered"
8038 );
8039 } else {
8040 assert_eq!(
8041 installed.runner_root,
8042 crate::runner_root_access::RootAccessSummary::NotApplicable,
8043 "macOS and Linux keep the runtime directory they have always used"
8044 );
8045 }
8046 }
8047
8048 #[test]
8049 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
8050 let host = Host::new();
8051 let operations = host.operations();
8052 operations
8053 .install(&host.request(StartMode::Boot))
8054 .expect("an install at boot");
8055
8056 let change = operations
8057 .set_start_mode(StartMode::Login)
8058 .expect("a switch to login");
8059
8060 assert!(change.changed);
8061 if cfg!(windows) {
8062 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
8063 assert!(
8064 host.runner_root.as_path().is_dir(),
8065 "the switch must not remove the directory it reconciled"
8066 );
8067 }
8068 assert!(
8071 change.to_string().contains("runner root"),
8072 "{}",
8073 change.to_string()
8074 );
8075 }
8076
8077 #[test]
8078 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
8079 let host = Host::new();
8080 let operations = host.operations();
8081 operations
8082 .install(&host.request(StartMode::Boot))
8083 .expect("an install at boot");
8084
8085 let change = operations
8086 .set_start_mode(StartMode::Boot)
8087 .expect("a switch to the mode already in force");
8088
8089 assert!(!change.changed);
8090 assert_eq!(
8091 change.runner_root,
8092 crate::runner_root_access::RootAccessSummary::NotApplicable,
8093 "nothing moves, so nothing about the root's access control has to; reconciling here \
8094 would turn a no-op command into one that can fail on a permission it does not need"
8095 );
8096 }
8097
8098 #[cfg(windows)]
8099 #[test]
8100 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
8101 let host = Host::new();
8102 host.controls
8103 .fail_next_install(StartMode::Boot, "injected registration failure");
8104
8105 let error = host
8106 .operations()
8107 .install(&host.request(StartMode::Boot))
8108 .expect_err("the manager refuses the registration");
8109
8110 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8111 assert!(
8112 !host.runner_root.as_path().exists(),
8113 "the directory was created for a registration that does not exist"
8114 );
8115 }
8116
8117 #[cfg(windows)]
8118 #[test]
8119 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
8120 let host = Host::new();
8121 crate::runner_root_access::create_with_descriptor_for_tests(
8125 host.runner_root.as_path(),
8126 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
8127 )
8128 .expect("a deliberately open runner root");
8129 let before = crate::runner_root_access::report(host.runner_root.as_path());
8130
8131 let error = host
8132 .operations()
8133 .install(&host.request(StartMode::Boot))
8134 .expect_err("an open runner root is refused");
8135
8136 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
8137 assert!(
8138 error.to_string().contains("nothing was registered"),
8139 "{error}"
8140 );
8141 assert!(
8142 host.controls.registrations().is_empty(),
8143 "the refusal has to come before the platform is asked to register anything: {:?}",
8144 host.controls.calls()
8145 );
8146 assert_eq!(
8147 crate::runner_root_access::report(host.runner_root.as_path()),
8148 before,
8149 "an open directory is refused rather than tightened: its contents cannot be trusted, \
8150 so adopting it would be worse than declining it"
8151 );
8152 }
8153
8154 #[cfg(windows)]
8155 #[test]
8156 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
8157 let host = Host::new();
8158 let operations = host.operations();
8159 operations
8160 .install(&host.request(StartMode::Boot))
8161 .expect("an install");
8162 assert!(host.runner_root.as_path().is_dir());
8163
8164 operations.uninstall().expect("an uninstall");
8165
8166 assert!(
8167 host.runner_root.as_path().is_dir(),
8168 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
8169 runner root may hold an operator's retained workspaces."
8170 );
8171 }
8172
8173 #[test]
8174 fn install_reviews_what_it_registered() {
8175 let host = Host::new();
8176 let installed = host
8177 .operations()
8178 .install(&host.request(StartMode::Boot))
8179 .expect("an install");
8180 assert!(
8181 installed.review.is_least_privilege(),
8182 "{}",
8183 installed.review
8184 );
8185 assert!(
8186 !installed.review.controls().is_empty(),
8187 "a review that confirms nothing proves nothing: {}",
8188 installed.review
8189 );
8190 assert_eq!(
8191 installed.review.kind(),
8192 host_definition_kind(StartMode::Boot),
8193 "the review must be of the definition this host's manager was given"
8194 );
8195 assert!(
8196 !installed.review.account().justification().is_empty(),
8197 "a privileged account with no stated reason is an unreviewed one"
8198 );
8199 }
8200
8201 #[test]
8206 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
8207 let host = Host::new();
8208 let operations = host.operations();
8209 operations
8210 .install(&host.request(StartMode::Boot))
8211 .expect("an install");
8212
8213 let config = host.paths.config_dir();
8217 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
8218 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
8219 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
8220 std::fs::write(
8221 host.paths
8222 .state_dir()
8223 .join("packages/2.330.0/runner.tar.gz"),
8224 b"cached package",
8225 )
8226 .expect("writable");
8227 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
8228 std::fs::write(
8229 host.paths.state_dir().join("secrets/user-access-token"),
8230 b"a stand-in for the stored credential",
8231 )
8232 .expect("writable");
8233 std::fs::write(
8234 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
8235 b"diagnostics",
8236 )
8237 .expect("writable");
8238
8239 let roots: Vec<PathBuf> = host
8240 .paths
8241 .all()
8242 .iter()
8243 .map(|(_, path)| (*path).to_path_buf())
8244 .collect();
8245 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
8246 let before = snapshot(&roots);
8247
8248 assert!(
8251 before.len() >= 6,
8252 "the fixture must actually contain the files this test is about, got {before:#?}"
8253 );
8254 let record_path = InstallRecord::path(&host.paths);
8255 assert!(
8256 before.contains_key(&record_path),
8257 "the install record must be present before uninstall"
8258 );
8259
8260 let uninstalled = operations.uninstall().expect("an uninstall");
8261 assert!(uninstalled.removed_registration);
8262 assert!(uninstalled.removed_record);
8263
8264 let after = snapshot(&roots);
8265
8266 let mut expected = before.clone();
8268 expected.remove(&record_path);
8269 assert_eq!(
8270 after, expected,
8271 "uninstall must remove its own record and nothing else"
8272 );
8273 assert!(
8274 !record_path.exists(),
8275 "the record itself must go, or `uninstall` did nothing at all"
8276 );
8277 assert!(
8278 uninstalled
8279 .preserved
8280 .iter()
8281 .all(|path| roots.contains(&path.as_path())),
8282 "the preserved list must name the four directories: {uninstalled}"
8283 );
8284 }
8285
8286 #[test]
8287 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
8288 let host = Host::new();
8289 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
8290 assert!(!uninstalled.removed_registration);
8291 assert!(!uninstalled.removed_record);
8292 }
8293
8294 #[test]
8295 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
8296 let host = Host::new();
8297 let operations = host.operations();
8298 operations
8299 .install(&host.request(StartMode::Boot))
8300 .expect("an install");
8301 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
8302
8303 let uninstalled = operations.uninstall().expect("an uninstall");
8304 assert!(
8305 uninstalled.removed_registration,
8306 "a lost record must not strand a registration"
8307 );
8308 assert!(host.controls.registrations().is_empty());
8309 }
8310
8311 #[test]
8316 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
8317 let host = Host::new();
8318 let operations = host.operations();
8319 operations
8320 .install(&host.request(StartMode::Boot))
8321 .expect("an install at boot");
8322
8323 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
8327
8328 let change = operations
8329 .set_start_mode(StartMode::Login)
8330 .expect("a switch that does not reinstall the product");
8331 assert!(change.changed);
8332 assert_eq!(change.from, StartMode::Boot);
8333 assert_eq!(change.to, StartMode::Login);
8334 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
8335
8336 let record = InstallRecord::read(&host.paths)
8337 .expect("readable")
8338 .expect("a record");
8339 assert_eq!(record.start_mode, StartMode::Login);
8340 assert_eq!(
8341 record.binary, host.binary,
8342 "the recorded path must survive the switch untouched"
8343 );
8344
8345 let registrations = host.controls.registrations();
8346 assert_eq!(registrations.len(), 1, "{registrations:?}");
8347 assert_eq!(registrations[0].0, StartMode::Login);
8348 assert!(
8349 registrations[0]
8350 .2
8351 .command_line
8352 .contains(&host.binary.to_string_lossy().into_owned()),
8353 "{:?}",
8354 registrations[0].2
8355 );
8356 }
8357
8358 #[test]
8359 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
8360 let host = Host::new();
8361 let operations = host.operations();
8362 operations
8363 .install(&host.request(StartMode::Boot))
8364 .expect("an install at boot");
8365 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8366 host.controls
8367 .fail_next_install(StartMode::Login, "injected target failure");
8368
8369 let error = operations
8370 .set_start_mode(StartMode::Login)
8371 .expect_err("the target manager refuses the install");
8372
8373 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8374 assert_eq!(
8375 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8376 record_before
8377 );
8378 let registrations = host.controls.registrations();
8379 assert_eq!(registrations.len(), 1, "{registrations:?}");
8380 assert_eq!(registrations[0].0, StartMode::Boot);
8381 }
8382
8383 #[test]
8384 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
8385 let host = Host::new();
8386 let operations = host.operations();
8387 operations
8388 .install(&host.request(StartMode::Boot))
8389 .expect("an install at boot");
8390 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8391 let config = host.paths.config_dir().to_path_buf();
8392 let hidden = config.with_file_name("config-hidden-by-fault");
8393 host.controls.hide_directory_after_install(
8394 StartMode::Login,
8395 config.clone(),
8396 hidden.clone(),
8397 );
8398
8399 let error = operations
8400 .set_start_mode(StartMode::Login)
8401 .expect_err("the injected filesystem fault prevents persistence");
8402
8403 std::fs::remove_file(&config).expect("remove the injected blocker");
8404 std::fs::rename(&hidden, &config).expect("restore the record directory");
8405 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8406 assert_eq!(
8407 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8408 record_before
8409 );
8410 let registrations = host.controls.registrations();
8411 assert_eq!(registrations.len(), 1, "{registrations:?}");
8412 assert_eq!(registrations[0].0, StartMode::Boot);
8413 assert!(
8414 host.controls
8415 .calls()
8416 .iter()
8417 .any(|call| call == "uninstall runner-manager (login)"),
8418 "the target must be rolled back: {:?}",
8419 host.controls.calls()
8420 );
8421 }
8422
8423 #[test]
8424 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
8425 let host = Host::new();
8426 let operations = host.operations();
8427 operations
8428 .install(&host.request(StartMode::Boot))
8429 .expect("an install");
8430 let before = host.controls.calls().len();
8431
8432 let change = operations
8433 .set_start_mode(StartMode::Boot)
8434 .expect("a no-op switch");
8435 assert!(!change.changed);
8436 assert_eq!(
8437 host.controls.calls().len(),
8438 before,
8439 "a no-op switch must not touch the service manager"
8440 );
8441 }
8442
8443 #[test]
8444 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
8445 let host = Host::new();
8446 let error = host
8447 .operations()
8448 .set_start_mode(StartMode::Login)
8449 .expect_err("there is nothing to switch");
8450 assert!(
8451 matches!(error, ServiceError::NotInstalled { .. }),
8452 "{error}"
8453 );
8454 }
8455
8456 #[test]
8461 fn status_reports_the_four_facts_journey_five_asks_for() {
8462 let host = Host::new();
8463 let operations = host.operations();
8464 operations
8465 .install(&host.request(StartMode::Boot))
8466 .expect("an install");
8467 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
8468 .expect("a valid timestamp")
8469 .with_timezone(&Utc);
8470 record_github_contact(&host.paths, at).expect("a heartbeat");
8471
8472 let status = operations.status().expect("a status");
8473 assert_eq!(status.start_mode(), Some(StartMode::Boot));
8474 assert_eq!(
8475 status.binary().map(BinaryPath::recorded),
8476 Some(host.binary.as_path())
8477 );
8478 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
8479 assert_eq!(status.last_github_contact(), Some(at));
8480 assert!(status.is_installed());
8481 assert!(status.is_healthy(), "{status}");
8482
8483 let printed = status.to_string();
8484 for fragment in [
8485 "start mode",
8486 "diagnostic log",
8487 "last GitHub contact",
8488 "binary",
8489 ] {
8490 assert!(printed.contains(fragment), "{printed}");
8491 }
8492 }
8493
8494 #[cfg(unix)]
8506 #[test]
8507 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
8508 use std::os::unix::fs::PermissionsExt as _;
8509
8510 if unsafe { libc::geteuid() } == 0 {
8514 return;
8515 }
8516
8517 let host = Host::new();
8518 let operations = host.operations();
8519 operations
8520 .install(&host.request(StartMode::Boot))
8521 .expect("an install");
8522 assert!(
8523 operations.status().expect("a status").is_healthy(),
8524 "the discriminator: healthy before the record is made unreadable"
8525 );
8526
8527 let record = InstallRecord::path(&host.paths);
8528 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
8529 .expect("the mode is applied");
8530
8531 let status = operations
8532 .status()
8533 .expect("a record this account may not read is reported, not thrown");
8534 assert!(status.is_installed(), "{status}");
8535 assert!(!status.is_healthy(), "{status}");
8536
8537 let printed = status.to_string();
8538 assert!(
8539 printed.contains("this account may not read it"),
8540 "the operator is told which of the two states this is: {printed}"
8541 );
8542 assert!(
8543 !printed.contains("there is no install record"),
8544 "a record that is there and unreadable is not a record that is missing, and the \
8545 missing one's remedy starts with `service uninstall`: {printed}"
8546 );
8547
8548 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
8551 .expect("the mode is restored");
8552 }
8553
8554 #[test]
8555 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
8556 let host = Host::new();
8557 let operations = host.operations();
8558 operations
8559 .install(&host.request(StartMode::Boot))
8560 .expect("an install");
8561
8562 assert!(
8565 operations.status().expect("a status").is_healthy(),
8566 "the freshly installed host must be healthy"
8567 );
8568
8569 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
8570
8571 let status = operations.status().expect("a status");
8572 assert!(!status.is_healthy(), "{status}");
8573 assert!(
8574 status
8575 .problems()
8576 .iter()
8577 .any(|problem| problem.subject == "binary"),
8578 "{status}"
8579 );
8580 assert!(status.to_string().contains("STALE"), "{status}");
8581 }
8582
8583 #[test]
8584 fn status_reports_a_registration_that_would_not_start_at_boot() {
8585 let host = Host::new();
8586 let operations = host.operations();
8587 operations
8588 .install(&host.request(StartMode::Boot))
8589 .expect("an install");
8590 assert!(operations.status().expect("a status").is_healthy());
8591
8592 host.controls.edit("runner-manager", |registration| {
8593 registration.starts_automatically = false;
8594 });
8595
8596 let status = operations.status().expect("a status");
8597 assert!(!status.is_healthy(), "{status}");
8598 assert!(
8599 status
8600 .problems()
8601 .iter()
8602 .any(|problem| problem.detail.contains("after a reboot")),
8603 "{status}"
8604 );
8605 }
8606
8607 #[test]
8608 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
8609 let host = Host::new();
8610 let operations = host.operations();
8611 operations
8612 .install(&host.request(StartMode::Login))
8613 .expect("install and immediate start");
8614
8615 assert!(
8616 operations.status().expect("running status").is_running(),
8617 "install must not wait for the next login trigger"
8618 );
8619 operations.stop().expect("stop the registration");
8620 let status = operations.status().expect("stopped status");
8621 assert!(!status.is_healthy(), "{status}");
8622 assert!(
8623 status
8624 .problems()
8625 .iter()
8626 .any(|problem| problem.subject == "runtime"),
8627 "{status}"
8628 );
8629 let rendered = status.to_string();
8630 assert!(
8631 rendered.contains("runner-manager service install --start-at login"),
8632 "{rendered}"
8633 );
8634 assert!(
8635 !rendered.contains("runner-manager service start"),
8636 "{rendered}"
8637 );
8638 }
8639
8640 #[test]
8641 fn status_reports_a_restart_policy_something_else_edited() {
8642 let host = Host::new();
8643 let operations = host.operations();
8644 operations
8645 .install(&host.request(StartMode::Boot))
8646 .expect("an install");
8647 assert!(operations.status().expect("a status").is_healthy());
8648
8649 host.controls.edit("runner-manager", |registration| {
8650 registration.restart_delay = Some(Duration::from_secs(1));
8651 });
8652
8653 let status = operations.status().expect("a status");
8654 assert!(!status.is_healthy(), "{status}");
8655 assert!(
8656 status
8657 .problems()
8658 .iter()
8659 .any(|problem| problem.subject == "restart policy"),
8660 "{status}"
8661 );
8662 }
8663
8664 #[test]
8665 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
8666 let host = Host::new();
8667 let operations = host.operations();
8668 operations
8669 .install(&host.request(StartMode::Boot))
8670 .expect("an install");
8671 let other = host.binary.with_file_name("someone-elses.exe");
8672 std::fs::write(&other, b"x").expect("writable");
8673
8674 host.controls.edit("runner-manager", |registration| {
8675 registration.command_line = quote_argument(&other.to_string_lossy());
8676 });
8677
8678 let status = operations.status().expect("a status");
8679 assert!(!status.is_healthy(), "{status}");
8680 assert!(
8681 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
8682 "{status}"
8683 );
8684 }
8685
8686 #[test]
8687 fn status_reports_a_record_no_service_manager_knows_about() {
8688 let host = Host::new();
8689 let operations = host.operations();
8690 operations
8691 .install(&host.request(StartMode::Boot))
8692 .expect("an install");
8693 for mode in [StartMode::Boot, StartMode::Login] {
8695 host.controls
8696 .control(mode)
8697 .expect("a control")
8698 .uninstall(&ServiceIdentity::product())
8699 .expect("removed");
8700 }
8701
8702 let status = operations.status().expect("a status");
8703 assert!(!status.is_healthy(), "{status}");
8704 assert!(
8705 status
8706 .problems()
8707 .iter()
8708 .any(|problem| problem.subject == "registration"),
8709 "{status}"
8710 );
8711 }
8712
8713 #[test]
8714 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
8715 let host = Host::new();
8716 let status = host.operations().status().expect("a status");
8717 assert!(!status.is_installed());
8718 assert!(
8719 status.is_healthy(),
8720 "a host that never installed the service has no fault to report: {status}"
8721 );
8722 assert!(status.to_string().contains("installed"), "{status}");
8723 }
8724
8725 #[test]
8726 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
8727 let host = Host::new();
8728 let operations = host.operations();
8729 operations
8730 .install(&host.request(StartMode::Login))
8731 .expect("an install at login");
8732 let status = operations.status().expect("a status");
8733 assert!(
8734 status
8735 .notes()
8736 .iter()
8737 .any(|note| note.contains("does not run until the operator signs in")),
8738 "05-infrastructure.md requires `service status` to say so: {status}"
8739 );
8740 }
8741
8742 #[test]
8747 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
8748 let host = Host::new();
8749 let operations = host.operations();
8750 operations
8751 .install(&host.request(StartMode::Login))
8752 .expect("an install at login");
8753 operations.start().expect("a start");
8754 assert!(operations.status().expect("a status").is_running());
8755 assert!(operations.stop().expect("a stop"));
8756 assert!(!operations.status().expect("a status").is_running());
8757 }
8758
8759 #[test]
8760 fn starting_a_host_with_no_registration_is_refused() {
8761 let host = Host::new();
8762 let error = host.operations().start().expect_err("nothing to start");
8763 assert!(
8764 matches!(error, ServiceError::NotInstalled { .. }),
8765 "{error}"
8766 );
8767 }
8768
8769 #[cfg(windows)]
8785 #[test]
8786 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
8787 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
8788
8789 let root = tempfile::tempdir().expect("a temporary directory");
8790 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
8791 .expect("a rooted machine-scoped store");
8792 store
8793 .store(&secrecy::SecretString::from("a stand-in for the token"))
8794 .expect("the store accepts a value");
8795 let protection = store.protection().expect("the DACL can be read back");
8796
8797 assert!(
8798 protection.description().contains(";;;SY)"),
8799 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
8800 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
8801 protection.description()
8802 );
8803 assert_eq!(
8804 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
8805 ServiceAccount::LocalSystem,
8806 "and that is the account this installer registers, which is why SY is what matters"
8807 );
8808 assert!(
8809 !protection.readable_by_other_local_users(),
8810 "the same DACL must still exclude ordinary local users: {}",
8811 protection.description()
8812 );
8813
8814 for rejected in [";;;LS)", ";;;NS)"] {
8818 assert!(
8819 !protection.description().contains(rejected),
8820 "if the store ever admitted {rejected}, the least-privilege analysis in \
8821 docs/service-account.md would need redoing: {}",
8822 protection.description()
8823 );
8824 }
8825 }
8826}