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)
953 .ok()
954 .map(|store| store.guard());
955 Ok(Self {
956 identity,
957 start_mode: request.start_mode,
958 binary,
959 source_binary: request.source_binary.clone(),
960 arguments: request.arguments.clone(),
961 account: ServiceAccount::for_start_mode(request.start_mode),
962 restart: request.restart,
963 directories,
964 secret_guard,
965 on_demand: request.on_demand,
966 })
967 }
968
969 #[must_use]
976 pub fn unchecked(
977 identity: ServiceIdentity,
978 start_mode: StartMode,
979 binary: impl Into<PathBuf>,
980 directories: ServiceDirectories,
981 ) -> Self {
982 Self {
983 identity,
984 start_mode,
985 binary: binary.into(),
986 source_binary: None,
987 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
988 account: ServiceAccount::for_start_mode(start_mode),
989 restart: RestartPolicy::default(),
990 directories,
991 secret_guard: None,
992 on_demand: false,
993 }
994 }
995
996 #[must_use]
999 pub const fn started_on_demand(mut self) -> Self {
1000 self.on_demand = true;
1001 self
1002 }
1003
1004 #[must_use]
1006 pub const fn is_on_demand(&self) -> bool {
1007 self.on_demand
1008 }
1009
1010 #[must_use]
1017 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1018 self.secret_guard = Some(guard.into());
1019 self
1020 }
1021
1022 #[must_use]
1024 pub fn secret_guard(&self) -> Option<&Path> {
1025 self.secret_guard.as_deref()
1026 }
1027
1028 #[must_use]
1030 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1031 self.restart = restart;
1032 self
1033 }
1034
1035 #[must_use]
1037 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1038 where
1039 I: IntoIterator<Item = S>,
1040 S: Into<OsString>,
1041 {
1042 self.arguments = arguments.into_iter().map(Into::into).collect();
1043 self
1044 }
1045
1046 #[must_use]
1048 pub const fn identity(&self) -> &ServiceIdentity {
1049 &self.identity
1050 }
1051
1052 #[must_use]
1054 pub const fn start_mode(&self) -> StartMode {
1055 self.start_mode
1056 }
1057
1058 #[must_use]
1060 pub fn binary(&self) -> &Path {
1061 &self.binary
1062 }
1063
1064 #[must_use]
1070 pub fn source_binary(&self) -> Option<&Path> {
1071 self.source_binary.as_deref()
1072 }
1073
1074 #[must_use]
1076 pub fn arguments(&self) -> &[OsString] {
1077 &self.arguments
1078 }
1079
1080 #[must_use]
1082 pub const fn account(&self) -> &ServiceAccount {
1083 &self.account
1084 }
1085
1086 #[must_use]
1088 pub const fn restart(&self) -> RestartPolicy {
1089 self.restart
1090 }
1091
1092 #[must_use]
1094 pub const fn directories(&self) -> &ServiceDirectories {
1095 &self.directories
1096 }
1097
1098 #[must_use]
1100 pub fn command_line(&self) -> String {
1101 let mut out = quote_argument(&self.binary.to_string_lossy());
1102 for argument in &self.arguments {
1103 out.push(' ');
1104 out.push_str("e_argument(&argument.to_string_lossy()));
1105 }
1106 out
1107 }
1108}
1109
1110fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1118 let reversal = change.revert();
1119 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1120}
1121
1122fn rolled_back<T>(
1135 retained: Option<String>,
1136 rollback: Result<T, ServiceError>,
1137 operation: &'static str,
1138 identity: &ServiceIdentity,
1139 cause: ServiceError,
1140) -> ServiceError {
1141 let left_behind = match (rollback.err(), retained) {
1142 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1143 (Some(rollback), None) => Some(rollback.to_string()),
1144 (None, retained) => retained,
1145 };
1146 match left_behind {
1147 Some(rollback) => ServiceError::Rollback {
1148 operation,
1149 name: identity.name().to_string(),
1150 cause: cause.to_string(),
1151 rollback,
1152 },
1153 None => cause,
1154 }
1155}
1156
1157fn undo_runner_root(
1160 change: &RootAccessChange,
1161 operation: &'static str,
1162 identity: &ServiceIdentity,
1163 cause: ServiceError,
1164) -> ServiceError {
1165 rolled_back(
1166 retained_runner_root(change),
1167 Ok(()),
1168 operation,
1169 identity,
1170 cause,
1171 )
1172}
1173
1174fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1188 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1189 detail: format!("{} could not be made absolute: {error}", path.display()),
1190 })
1191}
1192
1193fn running_executable() -> Result<PathBuf, ServiceError> {
1195 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1196 detail: error.to_string(),
1197 })?;
1198 absolute(&raw)
1199}
1200
1201pub(crate) fn quote_argument(argument: &str) -> String {
1210 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1211 return argument.to_string();
1212 }
1213 let mut out = String::with_capacity(argument.len() + 2);
1214 out.push('"');
1215 let mut backslashes = 0usize;
1216 for c in argument.chars() {
1217 match c {
1218 '\\' => {
1219 backslashes += 1;
1220 out.push('\\');
1221 }
1222 '"' => {
1223 for _ in 0..=backslashes {
1226 out.push('\\');
1227 }
1228 out.push('"');
1229 backslashes = 0;
1230 }
1231 other => {
1232 backslashes = 0;
1233 out.push(other);
1234 }
1235 }
1236 }
1237 for _ in 0..backslashes {
1239 out.push('\\');
1240 }
1241 out.push('"');
1242 out
1243}
1244
1245#[must_use]
1256pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1257 let trimmed = command_line.trim_start();
1258 if trimmed.is_empty() {
1259 return None;
1260 }
1261 let mut out = String::new();
1262 let mut chars = trimmed.chars().peekable();
1263 let quoted = chars.peek() == Some(&'"');
1264 if quoted {
1265 chars.next();
1266 let mut backslashes = 0usize;
1267 for c in chars {
1268 match c {
1269 '\\' => {
1270 backslashes += 1;
1271 }
1272 '"' => {
1273 out.extend(std::iter::repeat_n('\\', backslashes / 2));
1276 if backslashes.is_multiple_of(2) {
1277 break;
1278 }
1279 backslashes = 0;
1280 out.push('"');
1281 }
1282 other => {
1283 out.extend(std::iter::repeat_n('\\', backslashes));
1284 backslashes = 0;
1285 out.push(other);
1286 }
1287 }
1288 }
1289 } else {
1290 for c in chars {
1291 if c == ' ' || c == '\t' {
1292 break;
1293 }
1294 out.push(c);
1295 }
1296 }
1297 if out.is_empty() {
1298 None
1299 } else {
1300 Some(PathBuf::from(out))
1301 }
1302}
1303
1304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1310pub enum DefinitionKind {
1311 WindowsService,
1315 WindowsScheduledTask,
1317 LaunchdPlist,
1319 SystemdUnit,
1321}
1322
1323impl DefinitionKind {
1324 #[must_use]
1326 pub const fn manager(self) -> &'static str {
1327 match self {
1328 Self::WindowsService => "the Windows Service Control Manager",
1329 Self::WindowsScheduledTask => "Windows Task Scheduler",
1330 Self::LaunchdPlist => "launchd",
1331 Self::SystemdUnit => "systemd",
1332 }
1333 }
1334}
1335
1336impl fmt::Display for DefinitionKind {
1337 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1338 f.write_str(self.manager())
1339 }
1340}
1341
1342#[derive(Debug, Clone, PartialEq, Eq)]
1349pub struct ServiceDefinition {
1350 kind: DefinitionKind,
1351 text: String,
1352 install_path: Option<PathBuf>,
1353}
1354
1355impl ServiceDefinition {
1356 #[must_use]
1358 pub const fn kind(&self) -> DefinitionKind {
1359 self.kind
1360 }
1361
1362 #[must_use]
1364 pub fn text(&self) -> &str {
1365 &self.text
1366 }
1367
1368 #[must_use]
1375 pub fn install_path(&self) -> Option<&Path> {
1376 self.install_path.as_deref()
1377 }
1378
1379 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1392 Ok(match host_definition_kind(plan.start_mode()) {
1393 DefinitionKind::WindowsService => Self::windows_service(plan),
1394 DefinitionKind::WindowsScheduledTask => {
1395 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1396 }
1397 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1398 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1399 })
1400 }
1401
1402 #[must_use]
1404 pub fn windows_service(plan: &InstallPlan) -> Self {
1405 Self {
1406 kind: DefinitionKind::WindowsService,
1407 text: windows_service_descriptor(plan),
1408 install_path: None,
1409 }
1410 }
1411
1412 #[must_use]
1414 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1415 Self {
1416 kind: DefinitionKind::WindowsScheduledTask,
1417 text: windows_scheduled_task_xml(plan, principal),
1418 install_path: None,
1419 }
1420 }
1421
1422 #[must_use]
1427 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1428 let file = format!("{}.plist", plan.identity().launchd_label());
1429 let install_path = match plan.start_mode() {
1430 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1431 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1432 };
1433 Self {
1434 kind: DefinitionKind::LaunchdPlist,
1435 text: launchd_plist(plan),
1436 install_path,
1437 }
1438 }
1439
1440 #[must_use]
1448 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1449 Self {
1450 kind,
1451 text: text.into(),
1452 install_path: None,
1453 }
1454 }
1455
1456 #[must_use]
1461 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1462 let file = plan.identity().systemd_unit();
1463 let install_path = match plan.start_mode() {
1464 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1465 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1466 };
1467 Self {
1468 kind: DefinitionKind::SystemdUnit,
1469 text: systemd_unit(plan),
1470 install_path,
1471 }
1472 }
1473}
1474
1475pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1477pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1479pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1481pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1483
1484const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1487
1488pub const START_LIMIT_BURST: u32 = 5;
1496
1497#[must_use]
1516pub fn systemd_unit(plan: &InstallPlan) -> String {
1517 let identity = plan.identity();
1518 let restart = plan.restart();
1519 let directories = plan.directories();
1520
1521 let mut out = String::new();
1522 out.push_str("[Unit]\n");
1523 out.push_str(&format!("Description={}\n", identity.display_name()));
1524 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1525 out.push_str("After=network-online.target\n");
1526 out.push_str("Wants=network-online.target\n");
1527 out.push_str(&format!(
1530 "StartLimitIntervalSec={}\n",
1531 restart.reset_after().as_secs()
1532 ));
1533 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1534
1535 out.push_str("\n[Service]\n");
1536 out.push_str("Type=simple\n");
1537 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1538 out.push_str(&format!(
1539 "WorkingDirectory={}\n",
1540 directories.state.display()
1541 ));
1542 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1543 out.push_str("Restart=on-failure\n");
1544 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1545
1546 if plan.start_mode() == StartMode::Boot
1550 && let Some(guard) = plan.secret_guard()
1551 {
1552 out.push_str(&format!(
1553 "LoadCredential={}:{}\n",
1554 crate::secrets::SYSTEMD_CREDENTIAL,
1555 guard.display()
1556 ));
1557 }
1558
1559 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1560 for directive in SYSTEMD_HARDENING {
1561 out.push_str(directive);
1562 out.push('\n');
1563 }
1564 out.push_str(&format!(
1565 "ReadWritePaths={}\n",
1566 directories
1567 .all()
1568 .iter()
1569 .map(|path| quote_argument(&path.to_string_lossy()))
1570 .collect::<Vec<_>>()
1571 .join(" ")
1572 ));
1573
1574 out.push_str("\n[Install]\n");
1575 out.push_str(match plan.start_mode() {
1576 StartMode::Boot => "WantedBy=multi-user.target\n",
1577 StartMode::Login => "WantedBy=default.target\n",
1578 });
1579 out
1580}
1581
1582pub const SYSTEMD_HARDENING: [&str; 13] = [
1589 "NoNewPrivileges=yes",
1590 "CapabilityBoundingSet=",
1591 "AmbientCapabilities=",
1592 "PrivateTmp=yes",
1593 "PrivateDevices=yes",
1594 "ProtectSystem=strict",
1595 "ProtectKernelTunables=yes",
1596 "ProtectKernelModules=yes",
1597 "ProtectControlGroups=yes",
1598 "RestrictNamespaces=yes",
1599 "RestrictRealtime=yes",
1600 "RestrictSUIDSGID=yes",
1601 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1602];
1603
1604#[must_use]
1613pub fn launchd_plist(plan: &InstallPlan) -> String {
1614 let identity = plan.identity();
1615 let directories = plan.directories();
1616 let mut out = String::new();
1617 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1618 out.push_str(
1619 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1620 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1621 );
1622 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1623 out.push_str(&plist_string("Label", &identity.launchd_label()));
1624
1625 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1626 out.push_str(&format!(
1627 " <string>{}</string>\n",
1628 xml_escape(&plan.binary().to_string_lossy())
1629 ));
1630 for argument in plan.arguments() {
1631 out.push_str(&format!(
1632 " <string>{}</string>\n",
1633 xml_escape(&argument.to_string_lossy())
1634 ));
1635 }
1636 out.push_str(" </array>\n");
1637
1638 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1639 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1640 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1641 out.push_str(" </dict>\n");
1642 out.push_str(&format!(
1643 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1644 plan.restart().delay().as_secs()
1645 ));
1646 out.push_str(&plist_string("ProcessType", "Background"));
1649 out.push_str(&plist_string(
1650 "WorkingDirectory",
1651 &directories.state.to_string_lossy(),
1652 ));
1653 out.push_str(&plist_string(
1654 "StandardOutPath",
1655 &directories
1656 .logs
1657 .join("runner-manager.launchd.out.log")
1658 .to_string_lossy(),
1659 ));
1660 out.push_str(&plist_string(
1661 "StandardErrorPath",
1662 &directories
1663 .logs
1664 .join("runner-manager.launchd.err.log")
1665 .to_string_lossy(),
1666 ));
1667
1668 match plan.start_mode() {
1669 StartMode::Boot => {
1670 out.push_str(&plist_string(
1673 "UserName",
1674 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1675 .as_str(),
1676 ));
1677 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1679 }
1680 StartMode::Login => {
1681 }
1685 }
1686
1687 out.push_str("</dict>\n</plist>\n");
1688 out
1689}
1690
1691fn plist_string(key: &str, value: &str) -> String {
1693 format!(
1694 " <key>{}</key>\n <string>{}</string>\n",
1695 xml_escape(key),
1696 xml_escape(value)
1697 )
1698}
1699
1700#[derive(Debug, Clone, PartialEq, Eq)]
1708pub struct TaskPrincipal {
1709 user_id: String,
1710}
1711
1712impl TaskPrincipal {
1713 pub fn current() -> Result<Self, ServiceError> {
1721 let user = std::env::var("USERNAME")
1722 .ok()
1723 .filter(|value| !value.trim().is_empty());
1724 let Some(user) = user else {
1725 return Err(ServiceError::Control {
1726 operation: "identify the account for",
1727 name: SERVICE_NAME.to_string(),
1728 manager: "Windows Task Scheduler",
1729 detail: "this session reports no %USERNAME%, so there is no principal to \
1730 register a logon-triggered task for"
1731 .to_string(),
1732 });
1733 };
1734 let domain = std::env::var("USERDOMAIN")
1735 .ok()
1736 .filter(|value| !value.trim().is_empty());
1737 Ok(Self {
1738 user_id: match domain {
1739 Some(domain) => format!("{domain}\\{user}"),
1740 None => user,
1741 },
1742 })
1743 }
1744
1745 #[must_use]
1747 pub fn named(user_id: impl Into<String>) -> Self {
1748 Self {
1749 user_id: user_id.into(),
1750 }
1751 }
1752
1753 #[must_use]
1755 pub fn user_id(&self) -> &str {
1756 &self.user_id
1757 }
1758}
1759
1760#[must_use]
1767pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1768 let identity = plan.identity();
1769 let user = xml_escape(principal.user_id());
1770 let arguments = plan
1771 .arguments()
1772 .iter()
1773 .map(|argument| quote_argument(&argument.to_string_lossy()))
1774 .collect::<Vec<_>>()
1775 .join(" ");
1776 let mut out = String::new();
1777 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1778 out.push_str(
1779 "<Task version=\"1.4\" \
1780 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1781 );
1782 out.push_str(" <RegistrationInfo>\n");
1783 out.push_str(&format!(
1784 " <Description>{}</Description>\n",
1785 xml_escape(identity.description())
1786 ));
1787 out.push_str(&format!(
1788 " <URI>\\{}</URI>\n",
1789 xml_escape(identity.name())
1790 ));
1791 out.push_str(" </RegistrationInfo>\n");
1792
1793 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1794 out.push_str(" <Enabled>true</Enabled>\n");
1795 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1796 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1797
1798 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1799 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1800 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1801 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1802 out.push_str(" </Principal>\n </Principals>\n");
1803
1804 out.push_str(" <Settings>\n");
1805 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1809 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1810 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1811 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1812 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1813 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1814 out.push_str(" <IdleSettings>\n");
1815 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1816 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1817 out.push_str(" </IdleSettings>\n");
1818 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1819 out.push_str(" <Enabled>true</Enabled>\n");
1820 out.push_str(" <Hidden>false</Hidden>\n");
1821 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1822 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1823 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1825 out.push_str(" <Priority>7</Priority>\n");
1826 out.push_str(" <RestartOnFailure>\n");
1827 out.push_str(&format!(
1828 " <Interval>{}</Interval>\n",
1829 iso8601_minutes(
1830 plan.restart()
1831 .effective_delay(DefinitionKind::WindowsScheduledTask)
1832 )
1833 ));
1834 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1835 out.push_str(" </RestartOnFailure>\n");
1836 out.push_str(" </Settings>\n");
1837
1838 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1839 out.push_str(&format!(
1840 " <Command>{}</Command>\n",
1841 xml_escape(&plan.binary().to_string_lossy())
1842 ));
1843 if !arguments.is_empty() {
1844 out.push_str(&format!(
1845 " <Arguments>{}</Arguments>\n",
1846 xml_escape(&arguments)
1847 ));
1848 }
1849 out.push_str(&format!(
1850 " <WorkingDirectory>{}</WorkingDirectory>\n",
1851 xml_escape(&plan.directories().state.to_string_lossy())
1852 ));
1853 out.push_str(" </Exec>\n </Actions>\n");
1854 out.push_str("</Task>\n");
1855 out
1856}
1857
1858fn iso8601_minutes(duration: Duration) -> String {
1861 format!("PT{}M", duration.as_secs() / 60)
1862}
1863
1864pub(crate) fn xml_escape(value: &str) -> String {
1872 let mut out = String::with_capacity(value.len());
1873 for c in value.chars() {
1874 match c {
1875 '&' => out.push_str("&"),
1876 '<' => out.push_str("<"),
1877 '>' => out.push_str(">"),
1878 '"' => out.push_str("""),
1879 '\'' => out.push_str("'"),
1880 other => out.push(other),
1881 }
1882 }
1883 out
1884}
1885
1886fn xml_unescape(value: &str) -> String {
1892 value
1893 .replace("<", "<")
1894 .replace(">", ">")
1895 .replace(""", "\"")
1896 .replace("'", "'")
1897 .replace("&", "&")
1898}
1899
1900#[derive(Debug, Clone, PartialEq, Eq)]
1909pub struct WindowsServiceSpec {
1910 pub name: String,
1912 pub display_name: String,
1914 pub description: String,
1916 pub automatic_start: bool,
1918 pub account: Option<String>,
1921 pub command_line: String,
1923 pub restart: RestartPolicy,
1925}
1926
1927#[must_use]
1929pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1930 WindowsServiceSpec {
1931 name: plan.identity().name().to_string(),
1932 display_name: plan.identity().display_name().to_string(),
1933 description: plan.identity().description().to_string(),
1934 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1939 account: match ServiceAccount::for_definition(
1940 DefinitionKind::WindowsService,
1941 plan.start_mode(),
1942 ) {
1943 ServiceAccount::LocalSystem => None,
1946 other => Some(other.as_str().to_string()),
1947 },
1948 command_line: plan.command_line(),
1949 restart: plan.restart(),
1950 }
1951}
1952
1953#[must_use]
1956fn windows_service_descriptor(plan: &InstallPlan) -> String {
1957 let spec = windows_service_spec(plan);
1958 let mut out = String::new();
1959 out.push_str("[windows-service]\n");
1960 out.push_str(&format!("Name={}\n", spec.name));
1961 out.push_str(&format!("DisplayName={}\n", spec.display_name));
1962 out.push_str(&format!("Description={}\n", spec.description));
1963 out.push_str("ServiceType=OWN_PROCESS\n");
1967 out.push_str(&format!(
1968 "StartType={}\n",
1969 if spec.automatic_start {
1970 "AutoStart"
1971 } else {
1972 "OnDemand"
1973 }
1974 ));
1975 out.push_str("ErrorControl=Normal\n");
1976 out.push_str(&format!(
1977 "Account={}\n",
1978 spec.account
1979 .as_deref()
1980 .unwrap_or(ServiceAccount::LocalSystem.as_str())
1981 ));
1982 out.push_str(&format!("CommandLine={}\n", spec.command_line));
1983 out.push_str(&format!(
1984 "FailureActionRestartDelaySecs={}\n",
1985 spec.restart.delay().as_secs()
1986 ));
1987 out.push_str(&format!(
1988 "FailureActionsResetPeriodSecs={}\n",
1989 spec.restart.reset_after().as_secs()
1990 ));
1991 out.push_str("FailureActionsOnNonCrashFailures=true\n");
1995 out.push_str(&format!(
1996 "ReadWritePaths={}\n",
1997 plan.directories()
1998 .all()
1999 .iter()
2000 .map(|path| quote_argument(&path.to_string_lossy()))
2001 .collect::<Vec<_>>()
2002 .join(" ")
2003 ));
2004 out
2005}
2006
2007#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2013pub enum FindingKind {
2014 Excess,
2017 Shortfall,
2020}
2021
2022impl fmt::Display for FindingKind {
2023 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2024 f.write_str(match self {
2025 Self::Excess => "excess",
2026 Self::Shortfall => "shortfall",
2027 })
2028 }
2029}
2030
2031#[derive(Debug, Clone, PartialEq, Eq)]
2033pub struct PrivilegeFinding {
2034 pub kind: FindingKind,
2036 pub subject: String,
2038 pub detail: String,
2040}
2041
2042impl fmt::Display for PrivilegeFinding {
2043 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2044 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2045 }
2046}
2047
2048#[derive(Debug, Clone, PartialEq, Eq)]
2062pub struct PrivilegeReview {
2063 kind: DefinitionKind,
2064 account: ServiceAccount,
2065 controls: Vec<String>,
2066 findings: Vec<PrivilegeFinding>,
2067}
2068
2069impl PrivilegeReview {
2070 #[must_use]
2072 pub fn is_least_privilege(&self) -> bool {
2073 !self
2074 .findings
2075 .iter()
2076 .any(|finding| finding.kind == FindingKind::Excess)
2077 }
2078
2079 #[must_use]
2081 pub fn findings(&self) -> &[PrivilegeFinding] {
2082 &self.findings
2083 }
2084
2085 #[must_use]
2088 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2089 self.findings
2090 .iter()
2091 .filter(|finding| finding.kind == FindingKind::Excess)
2092 .collect()
2093 }
2094
2095 #[must_use]
2101 pub fn controls(&self) -> &[String] {
2102 &self.controls
2103 }
2104
2105 #[must_use]
2107 pub const fn account(&self) -> &ServiceAccount {
2108 &self.account
2109 }
2110
2111 #[must_use]
2113 pub const fn kind(&self) -> DefinitionKind {
2114 self.kind
2115 }
2116}
2117
2118impl fmt::Display for PrivilegeReview {
2119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2120 writeln!(
2121 f,
2122 "{} runs as {} ({})",
2123 self.kind,
2124 self.account,
2125 self.account.justification()
2126 )?;
2127 for control in &self.controls {
2128 writeln!(f, " confirmed {control}")?;
2129 }
2130 for finding in &self.findings {
2131 writeln!(f, " {finding}")?;
2132 }
2133 if self.is_least_privilege() {
2134 write!(f, " verdict least privilege")
2135 } else {
2136 write!(
2137 f,
2138 " verdict NOT least privilege: {} excess(es)",
2139 self.excesses().len()
2140 )
2141 }
2142 }
2143}
2144
2145#[must_use]
2151pub fn review_least_privilege(
2152 definition: &ServiceDefinition,
2153 plan: &InstallPlan,
2154) -> PrivilegeReview {
2155 let mut controls = Vec::new();
2156 let mut findings = Vec::new();
2157 match definition.kind() {
2158 DefinitionKind::SystemdUnit => {
2159 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2160 }
2161 DefinitionKind::LaunchdPlist => {
2162 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2163 }
2164 DefinitionKind::WindowsScheduledTask => {
2165 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2166 }
2167 DefinitionKind::WindowsService => {
2168 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2169 }
2170 }
2171 PrivilegeReview {
2172 kind: definition.kind(),
2173 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2178 controls,
2179 findings,
2180 }
2181}
2182
2183fn permitted_paths(plan: &InstallPlan) -> Vec<String> {
2185 plan.directories()
2186 .all()
2187 .iter()
2188 .map(|path| path.to_string_lossy().into_owned())
2189 .collect()
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(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 four \
2237 application-data directories"
2238 ),
2239 });
2240 }
2241 }
2242 for allowed in &permitted {
2243 if !listed
2244 .iter()
2245 .any(|entry| same_path_for(kind, allowed, entry))
2246 {
2247 findings.push(PrivilegeFinding {
2248 kind: FindingKind::Shortfall,
2249 subject: subject.to_string(),
2250 detail: format!(
2251 "{allowed} is one of this registration's directories but is not writable, \
2252 so the daemon cannot use it"
2253 ),
2254 });
2255 }
2256 }
2257 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2258 controls.push(format!(
2259 "{subject} names exactly the four application-data directories"
2260 ));
2261 }
2262}
2263
2264fn review_inbound_surface(
2272 text: &str,
2273 markers: &[(&str, &str)],
2274 controls: &mut Vec<String>,
2275 findings: &mut Vec<PrivilegeFinding>,
2276) {
2277 let mut clean = true;
2278 for (marker, detail) in markers {
2279 if text.contains(marker) {
2280 clean = false;
2281 findings.push(PrivilegeFinding {
2282 kind: FindingKind::Excess,
2283 subject: (*marker).to_string(),
2284 detail: (*detail).to_string(),
2285 });
2286 }
2287 }
2288 if clean {
2289 controls.push(
2290 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2291 );
2292 }
2293}
2294
2295fn review_systemd(
2296 text: &str,
2297 plan: &InstallPlan,
2298 controls: &mut Vec<String>,
2299 findings: &mut Vec<PrivilegeFinding>,
2300) {
2301 let directives = ini_directives(text, "Service");
2302 for expected in SYSTEMD_HARDENING {
2303 let (key, value) = expected
2304 .split_once('=')
2305 .expect("every hardening directive is written as key=value");
2306 match directives.get(key) {
2307 Some(actual) if actual == value => controls.push((*expected).to_string()),
2308 Some(actual) => findings.push(PrivilegeFinding {
2309 kind: FindingKind::Excess,
2310 subject: key.to_string(),
2311 detail: format!(
2312 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2313 requirement does not ask for"
2314 ),
2315 }),
2316 None => findings.push(PrivilegeFinding {
2317 kind: FindingKind::Excess,
2318 subject: key.to_string(),
2319 detail: format!(
2320 "is absent, so the unit inherits systemd's default rather than `{value}`"
2321 ),
2322 }),
2323 }
2324 }
2325
2326 match directives.get("ReadWritePaths") {
2327 Some(value) => {
2328 let listed = split_quoted(value);
2329 review_writable_paths(
2330 DefinitionKind::SystemdUnit,
2331 "ReadWritePaths",
2332 &listed,
2333 plan,
2334 controls,
2335 findings,
2336 );
2337 }
2338 None => findings.push(PrivilegeFinding {
2339 kind: FindingKind::Shortfall,
2340 subject: "ReadWritePaths".to_string(),
2341 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2342 .to_string(),
2343 }),
2344 }
2345
2346 if directives.contains_key("PrivateUsers")
2349 && directives.get("PrivateUsers") == Some(&"no".to_string())
2350 {
2351 findings.push(PrivilegeFinding {
2352 kind: FindingKind::Excess,
2353 subject: "PrivateUsers".to_string(),
2354 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2355 .to_string(),
2356 });
2357 }
2358
2359 review_inbound_surface(
2360 text,
2361 &[
2362 (
2363 "ListenStream=",
2364 "asks systemd to open a listening socket for this service, which \
2365 07-security.md rule 2 forbids the product to have",
2366 ),
2367 (
2368 "ListenDatagram=",
2369 "asks systemd to open a listening socket for this service, which \
2370 07-security.md rule 2 forbids the product to have",
2371 ),
2372 ],
2373 controls,
2374 findings,
2375 );
2376}
2377
2378fn review_launchd(
2379 text: &str,
2380 plan: &InstallPlan,
2381 controls: &mut Vec<String>,
2382 findings: &mut Vec<PrivilegeFinding>,
2383) {
2384 match plist_string_value(text, "ProcessType").as_deref() {
2385 Some("Background") => controls.push("ProcessType=Background".to_string()),
2386 Some(other) => findings.push(PrivilegeFinding {
2387 kind: FindingKind::Excess,
2388 subject: "ProcessType".to_string(),
2389 detail: format!(
2390 "is `{other}`, which asks the scheduler for more CPU and I/O than a background \
2391 daemon needs"
2392 ),
2393 }),
2394 None => findings.push(PrivilegeFinding {
2395 kind: FindingKind::Excess,
2396 subject: "ProcessType".to_string(),
2397 detail: "is absent, so launchd applies its `Standard` default rather than \
2398 `Background`"
2399 .to_string(),
2400 }),
2401 }
2402
2403 match plan.start_mode() {
2404 StartMode::Boot => {
2405 if plist_bool_value(text, "SessionCreate") == Some(true) {
2406 findings.push(PrivilegeFinding {
2407 kind: FindingKind::Excess,
2408 subject: "SessionCreate".to_string(),
2409 detail: "asks launchd to create a security session for a job that runs \
2410 outside every login session and has no use for one"
2411 .to_string(),
2412 });
2413 } else {
2414 controls.push("SessionCreate is not requested".to_string());
2415 }
2416 match plist_string_value(text, "UserName").as_deref() {
2417 Some("root") => {
2418 controls.push("UserName=root, stated rather than inherited".to_string())
2419 }
2420 Some(other) => findings.push(PrivilegeFinding {
2421 kind: FindingKind::Shortfall,
2422 subject: "UserName".to_string(),
2423 detail: format!(
2424 "is `{other}`, which cannot unlock the System Keychain: \
2425 /var/db/SystemKey is root-only, so the daemon would start and then \
2426 find no credential"
2427 ),
2428 }),
2429 None => findings.push(PrivilegeFinding {
2430 kind: FindingKind::Shortfall,
2431 subject: "UserName".to_string(),
2432 detail: "is absent, so the account is launchd's implicit default and this \
2433 review cannot confirm it"
2434 .to_string(),
2435 }),
2436 }
2437 }
2438 StartMode::Login => {
2439 if let Some(named) = plist_string_value(text, "UserName") {
2440 findings.push(PrivilegeFinding {
2441 kind: FindingKind::Excess,
2442 subject: "UserName".to_string(),
2443 detail: format!(
2444 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2445 naming an account here asks launchd for a switch a login-mode \
2446 registration has no reason to want"
2447 ),
2448 });
2449 } else {
2450 controls
2451 .push("no UserName: the agent runs as the operator and no other".to_string());
2452 }
2453 }
2454 }
2455
2456 review_inbound_surface(
2457 text,
2458 &[
2459 (
2460 "<key>Sockets</key>",
2461 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2462 forbids the product to have",
2463 ),
2464 (
2465 "<key>MachServices</key>",
2466 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2467 forbids the product to have",
2468 ),
2469 ],
2470 controls,
2471 findings,
2472 );
2473}
2474
2475fn review_scheduled_task(
2476 text: &str,
2477 controls: &mut Vec<String>,
2478 findings: &mut Vec<PrivilegeFinding>,
2479) {
2480 match xml_value(text, "RunLevel").as_deref() {
2481 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2482 Some(other) => findings.push(PrivilegeFinding {
2483 kind: FindingKind::Excess,
2484 subject: "RunLevel".to_string(),
2485 detail: format!(
2486 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2487 an administrator"
2488 ),
2489 }),
2490 None => findings.push(PrivilegeFinding {
2491 kind: FindingKind::Excess,
2492 subject: "RunLevel".to_string(),
2493 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2494 .to_string(),
2495 }),
2496 }
2497
2498 match xml_value(text, "LogonType").as_deref() {
2499 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2500 Some(other) => findings.push(PrivilegeFinding {
2501 kind: FindingKind::Excess,
2502 subject: "LogonType".to_string(),
2503 detail: format!(
2504 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2505 task; an interactive token needs neither"
2506 ),
2507 }),
2508 None => findings.push(PrivilegeFinding {
2509 kind: FindingKind::Shortfall,
2510 subject: "LogonType".to_string(),
2511 detail: "is absent, so this review cannot confirm that no credential is stored"
2512 .to_string(),
2513 }),
2514 }
2515}
2516
2517fn review_windows_service(
2518 text: &str,
2519 plan: &InstallPlan,
2520 controls: &mut Vec<String>,
2521 findings: &mut Vec<PrivilegeFinding>,
2522) {
2523 let directives = ini_directives(text, "windows-service");
2524
2525 match directives.get("ServiceType").map(String::as_str) {
2526 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2527 Some(other) => findings.push(PrivilegeFinding {
2528 kind: FindingKind::Excess,
2529 subject: "ServiceType".to_string(),
2530 detail: format!(
2531 "is `{other}`; an interactive or shared-process service reaches further than a \
2532 daemon that only talks to GitHub over HTTPS"
2533 ),
2534 }),
2535 None => findings.push(PrivilegeFinding {
2536 kind: FindingKind::Shortfall,
2537 subject: "ServiceType".to_string(),
2538 detail: "is absent, so this review cannot confirm the service is not interactive"
2539 .to_string(),
2540 }),
2541 }
2542
2543 match directives.get("Account").map(String::as_str) {
2547 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2548 controls.push(format!(
2549 "Account={account}: the only stock account the machine-scoped store's DACL \
2550 (SY, BA, OW) admits"
2551 ));
2552 }
2553 Some(other) => findings.push(PrivilegeFinding {
2554 kind: FindingKind::Shortfall,
2555 subject: "Account".to_string(),
2556 detail: format!(
2557 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2558 daemon would start and then find no credential. Widening that DACL is not this \
2559 registration's to do: an ACE reaching `{other}` would also reach every other \
2560 service running under it"
2561 ),
2562 }),
2563 None => findings.push(PrivilegeFinding {
2564 kind: FindingKind::Shortfall,
2565 subject: "Account".to_string(),
2566 detail: "is absent, so this review cannot confirm which account was registered"
2567 .to_string(),
2568 }),
2569 }
2570
2571 match directives.get("ReadWritePaths") {
2572 Some(value) => {
2573 let listed = split_quoted(value);
2574 review_writable_paths(
2575 DefinitionKind::WindowsService,
2576 "ReadWritePaths",
2577 &listed,
2578 plan,
2579 controls,
2580 findings,
2581 );
2582 }
2583 None => findings.push(PrivilegeFinding {
2584 kind: FindingKind::Shortfall,
2585 subject: "ReadWritePaths".to_string(),
2586 detail: "is absent, so the directories the service was installed against are not \
2587 recorded"
2588 .to_string(),
2589 }),
2590 }
2591}
2592
2593fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2601 let mut out = BTreeMap::new();
2602 let mut inside = false;
2603 for line in text.lines() {
2604 let line = line.trim();
2605 if line.starts_with('[') && line.ends_with(']') {
2606 inside = &line[1..line.len() - 1] == section;
2607 continue;
2608 }
2609 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2610 continue;
2611 }
2612 if let Some((key, value)) = line.split_once('=') {
2613 out.insert(key.trim().to_string(), value.trim().to_string());
2614 }
2615 }
2616 out
2617}
2618
2619fn split_quoted(value: &str) -> Vec<String> {
2622 let mut out = Vec::new();
2623 let mut rest = value.trim();
2624 while !rest.is_empty() {
2625 if rest.starts_with('"') {
2626 if let Some(parsed) = executable_from_command_line(rest) {
2629 out.push(parsed.to_string_lossy().into_owned());
2630 }
2631 let mut depth = 0usize;
2633 let mut end = rest.len();
2634 for (index, c) in rest.char_indices() {
2635 match c {
2636 '\\' => depth += 1,
2637 '"' => {
2638 if depth.is_multiple_of(2) && index > 0 {
2639 end = index + 1;
2640 break;
2641 }
2642 depth = 0;
2643 }
2644 _ => depth = 0,
2645 }
2646 }
2647 rest = rest[end..].trim_start();
2648 } else {
2649 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2650 out.push(rest[..end].to_string());
2651 rest = rest[end..].trim_start();
2652 }
2653 }
2654 out
2655}
2656
2657pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2669 let open = format!("<{tag}>");
2670 let close = format!("</{tag}>");
2671 let start = text.find(&open)? + open.len();
2672 let end = text[start..].find(&close)? + start;
2673 Some(xml_unescape(text[start..end].trim()))
2674}
2675
2676fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2678 let marker = format!("<key>{key}</key>");
2679 let start = text.find(&marker)? + marker.len();
2680 Some(text[start..].trim_start())
2681}
2682
2683fn plist_string_value(text: &str, key: &str) -> Option<String> {
2684 let rest = plist_value_after_key(text, key)?;
2685 if !rest.starts_with("<string>") {
2686 return None;
2687 }
2688 xml_value(rest, "string")
2689}
2690
2691fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2692 let rest = plist_value_after_key(text, key)?;
2693 if rest.starts_with("<true/>") {
2694 Some(true)
2695 } else if rest.starts_with("<false/>") {
2696 Some(false)
2697 } else {
2698 None
2699 }
2700}
2701
2702pub const RECORD_SCHEMA_VERSION: u32 = 1;
2713
2714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2726pub struct InstallRecord {
2727 pub schema_version: u32,
2729 pub service_name: String,
2731 pub manager: String,
2733 pub start_mode: StartMode,
2735 pub account: ServiceAccount,
2737 pub binary: PathBuf,
2739 #[serde(default)]
2747 pub source_binary: Option<PathBuf>,
2748 pub arguments: Vec<String>,
2750 pub restart_delay_secs: u64,
2752 pub restart_reset_secs: u64,
2754 pub log_file: PathBuf,
2756 #[serde(default)]
2764 pub starts_on_demand: bool,
2765 pub definition_path: Option<PathBuf>,
2767 pub installed_at: DateTime<Utc>,
2769 pub installed_by_version: String,
2771 pub directories: ServiceDirectories,
2776}
2777
2778impl InstallRecord {
2779 #[must_use]
2781 pub fn path(paths: &AppPaths) -> PathBuf {
2782 paths.config_dir().join(RECORD_FILE)
2783 }
2784
2785 #[must_use]
2787 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2788 Self {
2789 schema_version: RECORD_SCHEMA_VERSION,
2790 service_name: plan.identity().name().to_string(),
2791 manager: definition.kind().manager().to_string(),
2792 start_mode: plan.start_mode(),
2793 account: plan.account().clone(),
2794 binary: plan.binary().to_path_buf(),
2795 arguments: plan
2796 .arguments()
2797 .iter()
2798 .map(|argument| argument.to_string_lossy().into_owned())
2799 .collect(),
2800 restart_delay_secs: plan.restart().delay().as_secs(),
2801 restart_reset_secs: plan.restart().reset_after().as_secs(),
2802 starts_on_demand: plan.is_on_demand(),
2803 source_binary: plan.source_binary().map(Path::to_path_buf),
2804 log_file: plan.directories().log_file(),
2805 definition_path: definition.install_path().map(Path::to_path_buf),
2806 installed_at: at,
2807 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2808 directories: plan.directories().clone(),
2809 }
2810 }
2811
2812 #[must_use]
2816 pub fn restart(&self) -> RestartPolicy {
2817 RestartPolicy::new(
2818 Duration::from_secs(self.restart_delay_secs),
2819 Duration::from_secs(self.restart_reset_secs),
2820 )
2821 .unwrap_or_default()
2822 }
2823
2824 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2835 let path = Self::path(paths);
2836 let text = match std::fs::read_to_string(&path) {
2837 Ok(text) => text,
2838 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2839 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2842 return Err(ServiceError::RecordNotPermitted {
2843 path,
2844 detail: error.to_string(),
2845 });
2846 }
2847 Err(error) => {
2848 return Err(ServiceError::Record {
2849 operation: "read",
2850 path,
2851 detail: error.to_string(),
2852 });
2853 }
2854 };
2855 let record: Self =
2856 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2857 path: path.clone(),
2858 detail: error.to_string(),
2859 })?;
2860 if record.schema_version != RECORD_SCHEMA_VERSION {
2861 return Err(ServiceError::RecordUnreadable {
2862 path,
2863 detail: format!(
2864 "it declares schema version {} and this build reads version {}",
2865 record.schema_version, RECORD_SCHEMA_VERSION
2866 ),
2867 });
2868 }
2869 Ok(Some(record))
2870 }
2871
2872 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2878 use std::io::Write as _;
2879
2880 let path = Self::path(paths);
2881 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2882 operation: "encode",
2883 path: path.clone(),
2884 detail: error.to_string(),
2885 })?;
2886 if let Some(parent) = path.parent() {
2887 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2888 operation: "write",
2889 path: path.clone(),
2890 detail: error.to_string(),
2891 })?;
2892 }
2893 let parent = path.parent().ok_or_else(|| ServiceError::Record {
2894 operation: "write",
2895 path: path.clone(),
2896 detail: "the record path has no parent directory".to_string(),
2897 })?;
2898 let mut temporary =
2899 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2900 operation: "write",
2901 path: path.clone(),
2902 detail: error.to_string(),
2903 })?;
2904 temporary
2905 .write_all(text.as_bytes())
2906 .and_then(|()| temporary.as_file().sync_all())
2907 .map_err(|error| ServiceError::Record {
2908 operation: "write",
2909 path: path.clone(),
2910 detail: error.to_string(),
2911 })?;
2912 #[cfg(unix)]
2928 {
2929 use std::os::unix::fs::PermissionsExt as _;
2930
2931 temporary
2932 .as_file()
2933 .set_permissions(std::fs::Permissions::from_mode(0o644))
2934 .map_err(|error| ServiceError::Record {
2935 operation: "write",
2936 path: path.clone(),
2937 detail: error.to_string(),
2938 })?;
2939 }
2940 temporary
2941 .persist(&path)
2942 .map(|_| ())
2943 .map_err(|error| ServiceError::Record {
2944 operation: "write",
2945 path,
2946 detail: error.error.to_string(),
2947 })
2948 }
2949
2950 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
2956 let path = Self::path(paths);
2957 match std::fs::remove_file(&path) {
2958 Ok(()) => Ok(true),
2959 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
2960 Err(error) => Err(ServiceError::Record {
2961 operation: "remove",
2962 path,
2963 detail: error.to_string(),
2964 }),
2965 }
2966 }
2967}
2968
2969const CONTACT_SCHEMA_VERSION: u32 = 1;
2975
2976#[derive(Debug, Clone, Serialize, Deserialize)]
2977struct ContactRecord {
2978 schema_version: u32,
2979 last_success: DateTime<Utc>,
2980}
2981
2982pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3003 let path = contact_path(paths);
3004 let record = ContactRecord {
3005 schema_version: CONTACT_SCHEMA_VERSION,
3006 last_success: at,
3007 };
3008 let failed = |detail: String| ServiceError::Record {
3009 operation: "write",
3010 path: path.clone(),
3011 detail,
3012 };
3013 let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3014 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3015 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3016 let temporary = path.with_extension("toml.new");
3017 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3018 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3019}
3020
3021pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3033 let path = contact_path(paths);
3034 let text = match std::fs::read_to_string(&path) {
3035 Ok(text) => text,
3036 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3037 Err(error) => {
3038 return Err(ServiceError::Record {
3039 operation: "read",
3040 path,
3041 detail: error.to_string(),
3042 });
3043 }
3044 };
3045 let record: ContactRecord = toml::from_str(&text).map_err(|error| ServiceError::Record {
3046 operation: "read",
3047 path,
3048 detail: error.to_string(),
3049 })?;
3050 Ok(Some(record.last_success))
3051}
3052
3053#[must_use]
3055pub fn contact_path(paths: &AppPaths) -> PathBuf {
3056 paths.state_dir().join(CONTACT_FILE)
3057}
3058
3059const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3064
3065#[derive(Debug, Clone, Serialize, Deserialize)]
3066struct RootRefusalFile {
3067 schema_version: u32,
3068 #[serde(default)]
3070 refusals: BTreeMap<String, RootRefusalEntry>,
3071}
3072
3073#[derive(Debug, Clone, Serialize, Deserialize)]
3074struct RootRefusalEntry {
3075 at: DateTime<Utc>,
3076 kind: String,
3077 root: String,
3078 detail: String,
3079}
3080
3081#[derive(Debug, Clone, PartialEq, Eq)]
3083pub struct RunnerRootRefusal {
3084 pub policy: String,
3086 pub at: DateTime<Utc>,
3088 pub kind: String,
3090 pub root: String,
3092 pub detail: String,
3095}
3096
3097pub fn record_runner_root_refusal(
3131 paths: &AppPaths,
3132 policy: &str,
3133 at: DateTime<Utc>,
3134 kind: &str,
3135 root: &str,
3136 detail: &str,
3137) -> Result<(), ServiceError> {
3138 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3139 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3140 refusals: BTreeMap::new(),
3141 });
3142 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3143 file.refusals.insert(
3144 policy.to_owned(),
3145 RootRefusalEntry {
3146 at,
3147 kind: kind.to_owned(),
3148 root: root.to_owned(),
3149 detail: detail.to_owned(),
3150 },
3151 );
3152 write_refusal_file(paths, &file)
3153}
3154
3155pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3166 let Some(mut file) = read_refusal_file(paths)? else {
3167 return Ok(());
3168 };
3169 if file.refusals.remove(policy).is_none() {
3170 return Ok(());
3171 }
3172 if file.refusals.is_empty() {
3173 let path = root_refusal_path(paths);
3174 return match std::fs::remove_file(&path) {
3175 Ok(()) => Ok(()),
3176 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3177 Err(error) => Err(ServiceError::Record {
3178 operation: "remove",
3179 path,
3180 detail: error.to_string(),
3181 }),
3182 };
3183 }
3184 write_refusal_file(paths, &file)
3185}
3186
3187pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3199 Ok(read_refusal_file(paths)?
3200 .map(|file| {
3201 file.refusals
3202 .into_iter()
3203 .map(|(policy, entry)| RunnerRootRefusal {
3204 policy,
3205 at: entry.at,
3206 kind: entry.kind,
3207 root: entry.root,
3208 detail: entry.detail,
3209 })
3210 .collect()
3211 })
3212 .unwrap_or_default())
3213}
3214
3215fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3216 let path = root_refusal_path(paths);
3217 let text = match std::fs::read_to_string(&path) {
3218 Ok(text) => text,
3219 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3220 Err(error) => {
3221 return Err(ServiceError::Record {
3222 operation: "read",
3223 path,
3224 detail: error.to_string(),
3225 });
3226 }
3227 };
3228 toml::from_str(&text)
3229 .map(Some)
3230 .map_err(|error| ServiceError::Record {
3231 operation: "read",
3232 path,
3233 detail: error.to_string(),
3234 })
3235}
3236
3237fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3238 let path = root_refusal_path(paths);
3239 let failed = |detail: String| ServiceError::Record {
3240 operation: "write",
3241 path: path.clone(),
3242 detail,
3243 };
3244 let text = toml::to_string_pretty(file).map_err(|error| failed(error.to_string()))?;
3245 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3246 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3247 let temporary = path.with_extension("toml.new");
3248 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3249 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3250}
3251
3252#[must_use]
3254pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3255 paths.state_dir().join(ROOT_REFUSAL_FILE)
3256}
3257
3258#[derive(Debug, Clone, PartialEq, Eq)]
3269pub enum BinaryPath {
3270 Current {
3272 path: PathBuf,
3274 },
3275 Missing {
3282 recorded: PathBuf,
3284 },
3285 NotExecutable {
3288 recorded: PathBuf,
3290 detail: String,
3292 },
3293 Diverged {
3295 recorded: PathBuf,
3297 registered: PathBuf,
3299 },
3300}
3301
3302impl BinaryPath {
3303 #[must_use]
3305 pub const fn is_error(&self) -> bool {
3306 !matches!(self, Self::Current { .. })
3307 }
3308
3309 #[must_use]
3311 pub fn recorded(&self) -> &Path {
3312 match self {
3313 Self::Current { path } => path,
3314 Self::Missing { recorded }
3315 | Self::NotExecutable { recorded, .. }
3316 | Self::Diverged { recorded, .. } => recorded,
3317 }
3318 }
3319}
3320
3321impl fmt::Display for BinaryPath {
3322 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3323 match self {
3324 Self::Current { path } => write!(f, "{}", path.display()),
3325 Self::Missing { recorded } => write!(
3326 f,
3327 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3328 package manager that moved the binary is the usual cause; an `npm i -g` \
3329 installation moves with the active Node version. Run `service install` again \
3330 from the binary that is now installed.",
3331 recorded.display()
3332 ),
3333 Self::NotExecutable { recorded, detail } => write!(
3334 f,
3335 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3336 from the installed binary.",
3337 recorded.display()
3338 ),
3339 Self::Diverged {
3340 recorded,
3341 registered,
3342 } => write!(
3343 f,
3344 "{} -- STALE: the service manager is registered to start {} instead. Something \
3345 has edited the registration since it was installed. Run `service uninstall` and \
3346 `service install`; neither touches configuration, secrets, or the cache.",
3347 recorded.display(),
3348 registered.display()
3349 ),
3350 }
3351 }
3352}
3353
3354#[must_use]
3362pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3363 match std::fs::metadata(recorded) {
3364 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3365 return BinaryPath::Missing {
3366 recorded: recorded.to_path_buf(),
3367 };
3368 }
3369 Err(error) => {
3370 return BinaryPath::NotExecutable {
3371 recorded: recorded.to_path_buf(),
3372 detail: format!("it cannot be inspected ({error})"),
3373 };
3374 }
3375 Ok(metadata) if !metadata.is_file() => {
3376 return BinaryPath::NotExecutable {
3377 recorded: recorded.to_path_buf(),
3378 detail: "what is there is not a file".to_string(),
3379 };
3380 }
3381 Ok(_) => {}
3382 }
3383 if let Some(registered) = registered
3384 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3385 {
3386 return BinaryPath::Diverged {
3387 recorded: recorded.to_path_buf(),
3388 registered: registered.to_path_buf(),
3389 };
3390 }
3391 BinaryPath::Current {
3392 path: recorded.to_path_buf(),
3393 }
3394}
3395
3396#[derive(Debug, Clone, PartialEq, Eq)]
3402pub struct Registration {
3403 pub manager: DefinitionKind,
3408 pub start_mode: StartMode,
3410 pub command_line: String,
3412 pub account: Option<String>,
3414 pub running: bool,
3416 pub starts_automatically: bool,
3423 pub restart_delay: Option<Duration>,
3432}
3433
3434impl Registration {
3435 #[must_use]
3437 pub fn binary(&self) -> Option<PathBuf> {
3438 executable_from_command_line(&self.command_line)
3439 }
3440}
3441
3442pub trait ServiceControl: fmt::Debug {
3448 fn manager(&self) -> DefinitionKind;
3450
3451 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3457
3458 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3469
3470 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3476
3477 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3484
3485 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3491}
3492
3493pub trait ControlFactory: fmt::Debug + Send + Sync {
3499 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3505}
3506
3507#[derive(Debug, Clone, Copy, Default)]
3509pub struct HostControls;
3510
3511#[derive(Debug, Clone)]
3517pub struct Installed {
3518 pub plan: InstallPlan,
3520 pub definition: ServiceDefinition,
3522 pub record: InstallRecord,
3524 pub review: PrivilegeReview,
3526 pub runner_root: RootAccessSummary,
3531 pub replaced_existing: bool,
3539}
3540
3541#[derive(Debug, Clone, PartialEq, Eq)]
3543pub struct Uninstalled {
3544 pub removed_registration: bool,
3546 pub removed_record: bool,
3548 pub removed_definition: Option<PathBuf>,
3550 pub preserved: Vec<PathBuf>,
3557}
3558
3559impl fmt::Display for Uninstalled {
3560 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3561 if self.removed_registration {
3562 writeln!(f, "The service registration was removed.")?;
3563 } else {
3564 writeln!(f, "There was no service registration to remove.")?;
3565 }
3566 writeln!(f, "Nothing else was deleted. These are untouched:")?;
3567 for path in &self.preserved {
3568 writeln!(f, " {}", path.display())?;
3569 }
3570 write!(
3571 f,
3572 "The stored GitHub token is untouched too; `auth logout` is what purges it."
3573 )
3574 }
3575}
3576
3577#[derive(Debug, Clone, PartialEq, Eq)]
3579pub struct StartModeChange {
3580 pub from: StartMode,
3582 pub to: StartMode,
3584 pub changed: bool,
3586 pub store_scope: crate::secrets::SecretScope,
3589 pub runner_root: RootAccessSummary,
3595}
3596
3597impl fmt::Display for StartModeChange {
3598 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3599 if !self.changed {
3600 return write!(f, "The service already starts at {}.", self.to);
3601 }
3602 write!(
3603 f,
3604 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3605 if the token was stored under the other scope, run `auth login` again. {}",
3606 self.to, self.from, self.store_scope, self.runner_root
3607 )
3608 }
3609}
3610
3611#[derive(Debug, Clone)]
3619pub struct ServiceOperations {
3620 paths: AppPaths,
3621 identity: ServiceIdentity,
3622 controls: std::sync::Arc<dyn ControlFactory>,
3623 runner_root: Option<LocalAbsolutePath>,
3624}
3625
3626impl ServiceOperations {
3627 #[must_use]
3629 pub fn on_this_host(paths: AppPaths) -> Self {
3630 Self::with_controls(
3631 paths,
3632 ServiceIdentity::product(),
3633 std::sync::Arc::new(HostControls),
3634 )
3635 }
3636
3637 #[must_use]
3645 pub fn with_controls(
3646 paths: AppPaths,
3647 identity: ServiceIdentity,
3648 controls: std::sync::Arc<dyn ControlFactory>,
3649 ) -> Self {
3650 Self {
3651 paths,
3652 identity,
3653 controls,
3654 runner_root: None,
3655 }
3656 }
3657
3658 #[must_use]
3677 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
3678 self.runner_root = Some(root);
3679 self
3680 }
3681
3682 #[must_use]
3684 pub const fn paths(&self) -> &AppPaths {
3685 &self.paths
3686 }
3687
3688 #[must_use]
3690 pub const fn identity(&self) -> &ServiceIdentity {
3691 &self.identity
3692 }
3693
3694 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
3714 self.paths
3715 .create_all()
3716 .map_err(|source| ServiceError::Paths {
3717 source: Box::new(source),
3718 })?;
3719
3720 let _guard = self.refuse_while_an_agent_runs()?;
3723
3724 let replacing = match self.find_registration()? {
3753 Some((existing, _)) if existing == request.start_mode() => true,
3754 Some((existing, _)) => {
3755 return Err(ServiceError::AlreadyInstalled {
3756 name: self.identity.name().to_string(),
3757 existing,
3758 requested: request.start_mode(),
3759 });
3760 }
3761 None => false,
3762 };
3763
3764 let plan = InstallPlan::resolve(
3765 self.identity.clone(),
3766 request,
3767 ServiceDirectories::of(&self.paths),
3768 )?;
3769
3770 let control = self.controls.control(plan.start_mode())?;
3775
3776 let root = self.prepare_runner_root(plan.start_mode())?;
3782
3783 let previous = if replacing {
3788 InstallRecord::read(&self.paths).ok().flatten()
3789 } else {
3790 None
3791 };
3792 if replacing && let Err(cause) = control.uninstall(&self.identity) {
3793 return Err(undo_runner_root(&root, "install", &self.identity, cause));
3794 }
3795
3796 let definition = match control.install(&plan) {
3797 Ok(definition) => definition,
3798 Err(cause) => {
3799 let restored = self.reinstate(control.as_ref(), previous.as_ref());
3802 return Err(rolled_back(
3803 retained_runner_root(&root),
3804 restored,
3805 "install",
3806 &self.identity,
3807 cause,
3808 ));
3809 }
3810 };
3811 let review = review_least_privilege(&definition, &plan);
3812 let record = InstallRecord::of(&plan, &definition, Utc::now());
3813 if let Err(cause) = record.write(&self.paths) {
3814 return Err(rolled_back(
3815 retained_runner_root(&root),
3816 control.uninstall(&self.identity),
3817 "install",
3818 &self.identity,
3819 cause,
3820 ));
3821 }
3822 Ok(Installed {
3823 plan,
3824 definition,
3825 record,
3826 review,
3827 runner_root: root.summary().clone(),
3828 replaced_existing: replacing,
3829 })
3830 }
3831
3832 fn reinstate(
3843 &self,
3844 control: &dyn ServiceControl,
3845 previous: Option<&InstallRecord>,
3846 ) -> Result<(), ServiceError> {
3847 let Some(record) = previous else {
3848 return Ok(());
3849 };
3850 let plan = InstallPlan::unchecked(
3851 self.identity.clone(),
3852 record.start_mode,
3853 record.binary.clone(),
3854 record.directories.clone(),
3855 )
3856 .with_arguments(record.arguments.clone())
3857 .with_restart(record.restart());
3858 let plan = if record.starts_on_demand {
3859 plan.started_on_demand()
3860 } else {
3861 plan
3862 };
3863 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode) {
3864 Ok(store) => plan.with_secret_guard(store.guard()),
3865 Err(_) => plan,
3866 };
3867 control.install(&plan).map(|_| ())
3868 }
3869
3870 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
3879 let record = InstallRecord::read(&self.paths).ok().flatten();
3880 let removed_definition = record
3881 .as_ref()
3882 .and_then(|record| record.definition_path.clone());
3883
3884 let mut removed_registration = false;
3885 for mode in [StartMode::Boot, StartMode::Login] {
3889 let control = self.controls.control(mode)?;
3890 if control.uninstall(&self.identity)? {
3891 removed_registration = true;
3892 }
3893 }
3894 let removed_record = InstallRecord::remove(&self.paths)?;
3895 Ok(Uninstalled {
3896 removed_registration,
3897 removed_record,
3898 removed_definition: removed_definition.filter(|_| removed_registration),
3899 preserved: self
3900 .paths
3901 .all()
3902 .iter()
3903 .map(|(_, path)| (*path).to_path_buf())
3904 .collect(),
3905 })
3906 }
3907
3908 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
3930 let Some(record) = InstallRecord::read(&self.paths)? else {
3931 return Err(ServiceError::NotInstalled {
3932 name: self.identity.name().to_string(),
3933 operation: "switch the start mode of",
3934 });
3935 };
3936 let from = record.start_mode;
3937 if from == to {
3938 return Ok(StartModeChange {
3942 from,
3943 to,
3944 changed: false,
3945 store_scope: crate::secrets::SecretScope::for_start_mode(to),
3946 runner_root: RootAccessSummary::NotApplicable,
3947 });
3948 }
3949
3950 #[cfg(windows)]
3951 let arguments = {
3952 let mut arguments = record.arguments.clone();
3953 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
3954 if to == StartMode::Boot {
3955 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
3956 }
3957 arguments
3958 };
3959 #[cfg(not(windows))]
3960 let arguments = record.arguments.clone();
3961 let plan = InstallPlan::unchecked(
3962 self.identity.clone(),
3963 to,
3964 record.binary.clone(),
3965 record.directories.clone(),
3966 )
3967 .with_arguments(arguments)
3968 .with_restart(record.restart());
3969 let plan = if record.starts_on_demand {
3970 plan.started_on_demand()
3971 } else {
3972 plan
3973 };
3974 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
3975 Ok(store) => plan.with_secret_guard(store.guard()),
3976 Err(_) => plan,
3977 };
3978
3979 let target = self.controls.control(to)?;
3986 let previous = self.controls.control(from)?;
3991
3992 let root = self.prepare_runner_root(to)?;
4000
4001 let definition = match target.install(&plan) {
4002 Ok(definition) => definition,
4003 Err(cause) => {
4004 return Err(undo_runner_root(
4005 &root,
4006 "switch start mode",
4007 &self.identity,
4008 cause,
4009 ));
4010 }
4011 };
4012 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4013 if let Err(cause) = next_record.write(&self.paths) {
4014 return Err(rolled_back(
4015 retained_runner_root(&root),
4016 target.uninstall(&self.identity),
4017 "switch start mode",
4018 &self.identity,
4019 cause,
4020 ));
4021 }
4022
4023 if let Err(cause) = previous.uninstall(&self.identity) {
4027 let target_rollback = target.uninstall(&self.identity);
4028 let record_rollback = record.write(&self.paths);
4029 return Err(rolled_back(
4030 retained_runner_root(&root),
4031 target_rollback.and(record_rollback),
4032 "switch start mode",
4033 &self.identity,
4034 cause,
4035 ));
4036 }
4037 Ok(StartModeChange {
4038 from,
4039 to,
4040 changed: true,
4041 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4042 runner_root: root.summary().clone(),
4043 })
4044 }
4045
4046 pub fn start(&self) -> Result<(), ServiceError> {
4052 let Some((mode, _)) = self.find_registration()? else {
4053 return Err(ServiceError::NotInstalled {
4054 name: self.identity.name().to_string(),
4055 operation: "start",
4056 });
4057 };
4058 self.controls.control(mode)?.start(&self.identity)
4059 }
4060
4061 pub fn stop(&self) -> Result<bool, ServiceError> {
4067 let Some((mode, _)) = self.find_registration()? else {
4068 return Err(ServiceError::NotInstalled {
4069 name: self.identity.name().to_string(),
4070 operation: "stop",
4071 });
4072 };
4073 self.controls.control(mode)?.stop(&self.identity)
4074 }
4075
4076 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4095 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4096 Ok(record) => (record, None),
4097 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4098 Err(error) => return Err(error),
4099 };
4100 let found = self.find_registration()?;
4101 let last_github_contact = last_github_contact(&self.paths)?;
4102 Ok(ServiceStatus::compose(
4103 self.identity.clone(),
4104 record,
4105 record_refused.as_ref(),
4106 found.map(|(_, registration)| registration),
4107 last_github_contact,
4108 &self.paths,
4109 ))
4110 }
4111
4112 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4122 #[cfg(not(windows))]
4123 {
4124 let _ = (mode, &self.runner_root);
4127 Ok(RootAccessChange::not_applicable())
4128 }
4129 #[cfg(windows)]
4130 {
4131 let wrap = |source| ServiceError::RunnerRoot {
4132 source: Box::new(source),
4133 };
4134 if let Some(root) = self
4152 .runner_root
4153 .as_ref()
4154 .filter(|_| self.identity.is_fixture() || cfg!(test))
4155 {
4156 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4157 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4158 .map_err(wrap);
4159 }
4160
4161 let admission = match ServiceAccount::for_start_mode(mode) {
4162 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4165 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4169 RootAdmission::of_this_account().map_err(wrap)?
4170 }
4171 };
4172 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4173 }
4174 }
4175
4176 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4178 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4179 |source| match source {
4180 held @ LockError::Held { .. } => ServiceError::LockHeld {
4181 source: Box::new(held),
4182 },
4183 other => ServiceError::LockUnreadable {
4184 source: Box::new(other),
4185 },
4186 },
4187 )
4188 }
4189
4190 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4192 for mode in [StartMode::Boot, StartMode::Login] {
4193 let control = self.controls.control(mode)?;
4194 if let Some(registration) = control.query(&self.identity)? {
4195 return Ok(Some((registration.start_mode, registration)));
4196 }
4197 }
4198 Ok(None)
4199 }
4200}
4201
4202#[derive(Debug, Clone, PartialEq, Eq)]
4208pub struct StatusProblem {
4209 pub subject: &'static str,
4211 pub detail: String,
4213}
4214
4215impl fmt::Display for StatusProblem {
4216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4217 write!(f, "{}: {}", self.subject, self.detail)
4218 }
4219}
4220
4221#[derive(Debug, Clone)]
4230pub struct ServiceStatus {
4231 identity: ServiceIdentity,
4232 record: Option<InstallRecord>,
4233 registration: Option<Registration>,
4234 binary: Option<BinaryPath>,
4235 log_file: PathBuf,
4236 store: Option<crate::secrets::ActiveStore>,
4237 last_github_contact: Option<DateTime<Utc>>,
4238 runner_root: Option<(PathBuf, RootAccessReport)>,
4239 problems: Vec<StatusProblem>,
4240 notes: Vec<String>,
4241}
4242
4243impl ServiceStatus {
4244 fn compose(
4249 identity: ServiceIdentity,
4250 record: Option<InstallRecord>,
4251 record_refused: Option<&ServiceError>,
4252 registration: Option<Registration>,
4253 last_github_contact: Option<DateTime<Utc>>,
4254 paths: &AppPaths,
4255 ) -> Self {
4256 let mut problems = Vec::new();
4257 let mut notes = Vec::new();
4258
4259 if let Some(refusal) = record_refused {
4260 problems.push(StatusProblem {
4265 subject: "install record",
4266 detail: refusal.to_string(),
4267 });
4268 }
4269
4270 let log_file = record.as_ref().map_or_else(
4271 || ServiceDirectories::of(paths).log_file(),
4272 |record| record.log_file.clone(),
4273 );
4274
4275 let binary = record.as_ref().map(|record| {
4276 inspect_binary(
4277 &record.binary,
4278 registration
4279 .as_ref()
4280 .and_then(Registration::binary)
4281 .as_deref(),
4282 )
4283 });
4284 if let Some(state) = &binary
4285 && state.is_error()
4286 {
4287 problems.push(StatusProblem {
4288 subject: "binary",
4289 detail: state.to_string(),
4290 });
4291 }
4292
4293 match (&record, ®istration) {
4294 (Some(_), None) => problems.push(StatusProblem {
4295 subject: "registration",
4296 detail: "this host has a service record but no service manager knows the \
4297 registration. Run `service install` again; it deletes nothing."
4298 .to_string(),
4299 }),
4300 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4305 subject: "record",
4306 detail: format!(
4307 "{} holds a registration for this service but there is no install record, so \
4308 the path it was installed from and the directories it was installed against \
4309 are unknown. Run `service uninstall` and `service install`.",
4310 found.manager
4311 ),
4312 }),
4313 (None, Some(_)) => {}
4314 (Some(record), Some(found)) => {
4315 if record.start_mode != found.start_mode {
4316 problems.push(StatusProblem {
4317 subject: "start mode",
4318 detail: format!(
4319 "the record says {} and {} holds a {} registration. Switch the start \
4320 mode again to make them agree.",
4321 record.start_mode, found.manager, found.start_mode
4322 ),
4323 });
4324 }
4325 if record.start_mode == StartMode::Boot && !found.starts_automatically {
4326 problems.push(StatusProblem {
4327 subject: "start mode",
4328 detail: format!(
4329 "{} holds the registration but will not start it by itself, so this \
4330 host does not resume work after a reboot.",
4331 found.manager
4332 ),
4333 });
4334 }
4335 if let Some(actual) = found.restart_delay {
4336 let expected = record.restart().effective_delay(found.manager);
4337 if actual != expected {
4338 problems.push(StatusProblem {
4339 subject: "restart policy",
4340 detail: format!(
4341 "the record says the service restarts after {}s and {} reports \
4342 {}s. Something has edited the registration since it was \
4343 installed.",
4344 expected.as_secs(),
4345 found.manager,
4346 actual.as_secs()
4347 ),
4348 });
4349 } else if expected != record.restart().delay() {
4350 notes.push(format!(
4355 "{} expresses the restart delay in whole minutes, so the {}s asked \
4356 for is enforced as {}s. The service therefore never restarts faster \
4357 than the configured bound.",
4358 found.manager,
4359 record.restart().delay().as_secs(),
4360 expected.as_secs()
4361 ));
4362 }
4363 }
4364 }
4365 (None, None) => {}
4366 }
4367
4368 let store = record.as_ref().and_then(|record| {
4373 let registered_mode = registration
4374 .as_ref()
4375 .map_or(record.start_mode, |found| found.start_mode);
4376 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4377 .ok()
4378 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4379 });
4380 if let Some(store) = &store
4381 && !store.agrees_with_start_mode()
4382 {
4383 problems.push(StatusProblem {
4384 subject: "secret store",
4385 detail: format!(
4386 "{store}. Run `auth login` again so the token is stored where the registered \
4387 start mode can read it."
4388 ),
4389 });
4390 }
4391
4392 if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4393 notes.push(
4394 "This registration starts at login, so the agent does not run until the operator \
4395 signs in; this host does not resume work after an unattended reboot."
4396 .to_string(),
4397 );
4398 }
4399 if last_github_contact.is_none() {
4400 notes.push(
4401 "GitHub has not been reached successfully since this host's state directory was \
4402 created."
4403 .to_string(),
4404 );
4405 }
4406
4407 let runner_root = crate::runner_root::default_runner_root(paths)
4414 .ok()
4415 .map(|root| {
4416 let path = root.as_path().to_path_buf();
4417 let report = crate::runner_root_access::report(&path);
4418 (path, report)
4419 });
4420 if let Some((
4430 path,
4431 RootAccessReport::Present {
4432 broad_write: true, ..
4433 },
4434 )) = &runner_root
4435 {
4436 notes.push(format!(
4437 "the platform default runner root {} can be written by ordinary local users, so \
4438 it is not a safe place to run jobs. `service install` refuses it rather than \
4439 tightening it, because the contents of a directory anybody could write cannot \
4440 be trusted: remove or empty it, or choose another root with `runner-manager \
4441 host set-runtime-root --path <PATH>`.",
4442 path.display()
4443 ));
4444 }
4445
4446 match runner_root_refusals(paths) {
4469 Ok(refusals) => {
4470 for refusal in refusals {
4471 notes.push(format!(
4472 "policy {} started no runner: its runner root {} refused the launch \
4473 ({}), last at {}. {} This clears when that policy next places a \
4474 runner.",
4475 refusal.policy,
4476 refusal.root,
4477 refusal.kind,
4478 refusal.at.to_rfc3339(),
4479 refusal.detail,
4480 ));
4481 }
4482 }
4483 Err(error) => notes.push(format!(
4484 "whether the agent could use its runner roots could not be read: {error}"
4485 )),
4486 }
4487
4488 Self {
4489 identity,
4490 record,
4491 registration,
4492 binary,
4493 log_file,
4494 store,
4495 last_github_contact,
4496 runner_root,
4497 problems,
4498 notes,
4499 }
4500 }
4501
4502 #[must_use]
4510 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
4511 self.runner_root
4512 .as_ref()
4513 .map(|(path, report)| (path.as_path(), report))
4514 }
4515
4516 #[must_use]
4518 pub const fn is_installed(&self) -> bool {
4519 self.registration.is_some() || self.record.is_some()
4520 }
4521
4522 #[must_use]
4524 pub fn is_running(&self) -> bool {
4525 self.registration
4526 .as_ref()
4527 .is_some_and(|registration| registration.running)
4528 }
4529
4530 #[must_use]
4532 pub fn is_healthy(&self) -> bool {
4533 self.problems.is_empty()
4534 }
4535
4536 #[must_use]
4538 pub fn problems(&self) -> &[StatusProblem] {
4539 &self.problems
4540 }
4541
4542 #[must_use]
4545 pub fn notes(&self) -> &[String] {
4546 &self.notes
4547 }
4548
4549 #[must_use]
4551 pub fn start_mode(&self) -> Option<StartMode> {
4552 self.record.as_ref().map(|record| record.start_mode)
4553 }
4554
4555 #[must_use]
4558 pub const fn binary(&self) -> Option<&BinaryPath> {
4559 self.binary.as_ref()
4560 }
4561
4562 #[must_use]
4564 pub fn log_file(&self) -> &Path {
4565 &self.log_file
4566 }
4567
4568 #[must_use]
4570 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
4571 self.last_github_contact
4572 }
4573
4574 #[must_use]
4577 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
4578 self.store.as_ref()
4579 }
4580
4581 #[must_use]
4583 pub const fn record(&self) -> Option<&InstallRecord> {
4584 self.record.as_ref()
4585 }
4586
4587 #[must_use]
4589 pub const fn registration(&self) -> Option<&Registration> {
4590 self.registration.as_ref()
4591 }
4592}
4593
4594impl fmt::Display for ServiceStatus {
4595 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4596 writeln!(f, "Service: {}", self.identity)?;
4597 match (&self.record, &self.registration) {
4598 (None, None) => {
4599 writeln!(
4600 f,
4601 " installed no. `service install` registers `{} {}`.",
4602 SERVICE_NAME,
4603 DAEMON_ARGUMENTS.join(" ")
4604 )?;
4605 }
4606 _ => {
4607 let manager = self
4608 .registration
4609 .as_ref()
4610 .map(|registration| registration.manager.manager());
4611 writeln!(
4612 f,
4613 " installed {}",
4614 manager.unwrap_or("yes, but no service manager knows it")
4615 )?;
4616 writeln!(
4617 f,
4618 " state {}",
4619 if self.is_running() {
4620 "running"
4621 } else {
4622 "not running"
4623 }
4624 )?;
4625 }
4626 }
4627 if let Some(record) = &self.record {
4628 writeln!(f, " start mode {}", record.start_mode)?;
4629 writeln!(f, " account {}", record.account)?;
4630 writeln!(f, " restart on failure {}", record.restart())?;
4631 writeln!(
4632 f,
4633 " arguments {}",
4634 record.arguments.join(" ")
4635 )?;
4636 }
4637 if let Some(binary) = &self.binary {
4638 writeln!(f, " binary {binary}")?;
4639 }
4640 writeln!(f, " diagnostic log {}", self.log_file.display())?;
4641 if let Some((path, report)) = &self.runner_root {
4642 writeln!(f, " default runner root {}", path.display())?;
4649 if *report != RootAccessReport::NotApplicable {
4650 writeln!(f, " default root access {report}")?;
4651 }
4652 }
4653 if let Some(store) = &self.store {
4654 writeln!(f, " secret store {store}")?;
4655 }
4656 writeln!(
4657 f,
4658 " last GitHub contact {}",
4659 match self.last_github_contact {
4660 Some(at) => at.to_rfc3339(),
4661 None => "never".to_string(),
4662 }
4663 )?;
4664 for note in &self.notes {
4665 writeln!(f, " note {note}")?;
4666 }
4667 for problem in &self.problems {
4668 writeln!(f, " ERROR {problem}")?;
4669 }
4670 write!(
4671 f,
4672 " verdict {}",
4673 if self.is_healthy() {
4674 "healthy"
4675 } else {
4676 "NOT healthy"
4677 }
4678 )
4679 }
4680}
4681
4682#[derive(Debug, Clone, Default)]
4699pub struct RecordingControls {
4700 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
4701}
4702
4703#[derive(Debug, Default)]
4704struct RecordingState {
4705 registrations: BTreeMap<(StartMode, String), Registration>,
4706 definitions: BTreeMap<String, ServiceDefinition>,
4707 calls: Vec<String>,
4708 #[cfg(test)]
4709 install_failures: BTreeMap<StartMode, String>,
4710 #[cfg(test)]
4711 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
4712}
4713
4714#[cfg(test)]
4715#[derive(Debug, Clone)]
4716enum TestInstallSideEffect {
4717 HideDirectory { directory: PathBuf, hidden: PathBuf },
4718}
4719
4720impl RecordingControls {
4721 #[must_use]
4723 pub fn new() -> Self {
4724 Self::default()
4725 }
4726
4727 #[must_use]
4730 pub fn calls(&self) -> Vec<String> {
4731 self.state.lock().expect("not poisoned").calls.clone()
4732 }
4733
4734 #[must_use]
4736 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
4737 self.state
4738 .lock()
4739 .expect("not poisoned")
4740 .registrations
4741 .iter()
4742 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
4743 .collect()
4744 }
4745
4746 #[must_use]
4748 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
4749 self.state
4750 .lock()
4751 .expect("not poisoned")
4752 .definitions
4753 .get(name)
4754 .cloned()
4755 }
4756
4757 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
4767 let mut state = self.state.lock().expect("not poisoned");
4768 if let Some((_, registration)) = state
4769 .registrations
4770 .iter_mut()
4771 .find(|((_, held), _)| held == name)
4772 {
4773 edit(registration);
4774 }
4775 }
4776
4777 #[cfg(test)]
4778 fn fail_next_install(&self, mode: StartMode, detail: &str) {
4779 self.state
4780 .lock()
4781 .expect("not poisoned")
4782 .install_failures
4783 .insert(mode, detail.to_string());
4784 }
4785
4786 #[cfg(test)]
4787 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
4788 self.state
4789 .lock()
4790 .expect("not poisoned")
4791 .after_install
4792 .insert(
4793 mode,
4794 TestInstallSideEffect::HideDirectory { directory, hidden },
4795 );
4796 }
4797}
4798
4799impl ControlFactory for RecordingControls {
4800 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
4801 Ok(Box::new(RecordingControl {
4802 mode,
4803 state: std::sync::Arc::clone(&self.state),
4804 }))
4805 }
4806}
4807
4808#[derive(Debug)]
4809struct RecordingControl {
4810 mode: StartMode,
4811 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
4812}
4813
4814impl RecordingControl {
4815 fn note(&self, operation: &str, name: &str) {
4816 self.state
4817 .lock()
4818 .expect("not poisoned")
4819 .calls
4820 .push(format!("{operation} {name} ({})", self.mode));
4821 }
4822}
4823
4824impl ServiceControl for RecordingControl {
4825 fn manager(&self) -> DefinitionKind {
4826 host_definition_kind(self.mode)
4830 }
4831
4832 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
4833 self.note("install", plan.identity().name());
4834 #[cfg(test)]
4835 if let Some(detail) = self
4836 .state
4837 .lock()
4838 .expect("not poisoned")
4839 .install_failures
4840 .remove(&self.mode)
4841 {
4842 return Err(ServiceError::Control {
4843 operation: "install",
4844 name: plan.identity().name().to_string(),
4845 manager: "recording control",
4846 detail,
4847 });
4848 }
4849 let definition = ServiceDefinition::for_host(plan)?;
4854 let mut state = self.state.lock().expect("not poisoned");
4855 state.registrations.insert(
4856 (self.mode, plan.identity().name().to_string()),
4857 Registration {
4858 manager: host_definition_kind(self.mode),
4859 start_mode: self.mode,
4860 command_line: plan.command_line(),
4861 account: Some(plan.account().as_str().to_string()),
4862 running: false,
4863 starts_automatically: true,
4864 restart_delay: Some(plan.restart().delay()),
4865 },
4866 );
4867 state
4868 .definitions
4869 .insert(plan.identity().name().to_string(), definition.clone());
4870 #[cfg(test)]
4871 let side_effect = state.after_install.remove(&self.mode);
4872 drop(state);
4873 #[cfg(test)]
4874 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
4875 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
4876 std::fs::write(&directory, b"blocks recreation")
4877 .expect("test fault can block record directory recreation");
4878 }
4879 Ok(definition)
4880 }
4881
4882 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
4883 self.note("uninstall", identity.name());
4884 let mut state = self.state.lock().expect("not poisoned");
4885 state.definitions.remove(identity.name());
4886 Ok(state
4887 .registrations
4888 .remove(&(self.mode, identity.name().to_string()))
4889 .is_some())
4890 }
4891
4892 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
4893 self.note("query", identity.name());
4894 Ok(self
4895 .state
4896 .lock()
4897 .expect("not poisoned")
4898 .registrations
4899 .get(&(self.mode, identity.name().to_string()))
4900 .cloned())
4901 }
4902
4903 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
4904 self.note("start", identity.name());
4905 let mut state = self.state.lock().expect("not poisoned");
4906 match state
4907 .registrations
4908 .get_mut(&(self.mode, identity.name().to_string()))
4909 {
4910 Some(registration) => {
4911 registration.running = true;
4912 Ok(())
4913 }
4914 None => Err(ServiceError::NotInstalled {
4915 name: identity.name().to_string(),
4916 operation: "start",
4917 }),
4918 }
4919 }
4920
4921 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
4922 self.note("stop", identity.name());
4923 let mut state = self.state.lock().expect("not poisoned");
4924 match state
4925 .registrations
4926 .get_mut(&(self.mode, identity.name().to_string()))
4927 {
4928 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
4929 None => Err(ServiceError::NotInstalled {
4930 name: identity.name().to_string(),
4931 operation: "stop",
4932 }),
4933 }
4934 }
4935}
4936
4937impl ControlFactory for HostControls {
4938 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
4939 sys::control(mode)
4940 }
4941}
4942
4943fn host_home() -> Option<PathBuf> {
4950 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
4951}
4952
4953#[cfg(unix)]
4955fn home_directory() -> Option<PathBuf> {
4956 host_home()
4957}
4958
4959fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
4968 let output = std::process::Command::new(program)
4969 .args(arguments)
4970 .output()?;
4971 Ok((
4972 output.status.success(),
4973 String::from_utf8_lossy(&output.stdout).into_owned(),
4974 String::from_utf8_lossy(&output.stderr).into_owned(),
4975 ))
4976}
4977
4978#[must_use]
4981pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
4982 if cfg!(windows) {
4983 match mode {
4984 StartMode::Boot => DefinitionKind::WindowsService,
4985 StartMode::Login => DefinitionKind::WindowsScheduledTask,
4986 }
4987 } else if cfg!(target_os = "macos") {
4988 DefinitionKind::LaunchdPlist
4989 } else {
4990 DefinitionKind::SystemdUnit
4991 }
4992}
4993
4994#[cfg(any(target_os = "macos", test))]
5001fn enable_launchd_registration(
5002 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5003 domain: &str,
5004 service_target: &str,
5005 plist: &Path,
5006 name: &str,
5007 elevation_remedy: &'static str,
5008) -> Result<(), ServiceError> {
5009 let (enabled, cause) = launchctl(&[
5010 std::ffi::OsStr::new("enable"),
5011 std::ffi::OsStr::new(service_target),
5012 ]);
5013 if enabled {
5014 return Ok(());
5015 }
5016
5017 let (booted_out, bootout_detail) = launchctl(&[
5018 std::ffi::OsStr::new("bootout"),
5019 std::ffi::OsStr::new(service_target),
5020 ]);
5021 let removed = std::fs::remove_file(plist);
5022 if !booted_out || removed.is_err() {
5023 return Err(ServiceError::Rollback {
5024 operation: "enable launchd registration",
5025 name: name.to_string(),
5026 cause,
5027 rollback: format!(
5028 "launchctl bootout {domain}: {}; remove {}: {}",
5029 if booted_out {
5030 "succeeded".to_string()
5031 } else {
5032 bootout_detail
5033 },
5034 plist.display(),
5035 removed
5036 .err()
5037 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5038 ),
5039 });
5040 }
5041
5042 if cause.to_ascii_lowercase().contains("permission denied") {
5043 Err(ServiceError::NeedsElevation {
5044 operation: "enable",
5045 name: name.to_string(),
5046 detail: cause,
5047 remedy: elevation_remedy,
5048 })
5049 } else {
5050 Err(ServiceError::Control {
5051 operation: "enable",
5052 name: name.to_string(),
5053 manager: "launchd",
5054 detail: cause,
5055 })
5056 }
5057}
5058
5059#[derive(Debug)]
5068pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5069
5070impl ServiceShutdown {
5071 pub async fn wait(mut self) {
5073 if !*self.0.borrow() {
5074 let _ = self.0.changed().await;
5075 }
5076 }
5077}
5078
5079#[cfg(windows)]
5086pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5087where
5088 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5089{
5090 windows_host::run(Box::new(run))
5091}
5092
5093#[cfg(windows)]
5094mod windows_host {
5095 use std::ffi::OsString;
5096 use std::sync::{Arc, Mutex, OnceLock, mpsc};
5097 use std::time::Duration;
5098
5099 use windows_service::service::{
5100 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5101 ServiceType,
5102 };
5103 use windows_service::service_control_handler::{
5104 self, ServiceControlHandlerResult, ServiceStatusHandle,
5105 };
5106
5107 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5108
5109 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5110
5111 struct Invocation {
5112 run: Runner,
5113 result: mpsc::SyncSender<Result<u8, String>>,
5114 }
5115
5116 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5117
5118 windows_service::define_windows_service!(ffi_service_main, service_main);
5119
5120 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5121 let (result_tx, result_rx) = mpsc::sync_channel(1);
5122 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5123 let mut invocation = slot
5124 .lock()
5125 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5126 if invocation.is_some() {
5127 return Err(host_error(
5128 "prepare",
5129 "the service-host slot was already used",
5130 ));
5131 }
5132 *invocation = Some(Invocation {
5133 run,
5134 result: result_tx,
5135 });
5136 drop(invocation);
5137
5138 windows_service::service_dispatcher::start("", ffi_service_main)
5139 .map_err(|error| host_error("connect", &error.to_string()))?;
5140 result_rx
5141 .recv()
5142 .map_err(|error| host_error("finish", &error.to_string()))?
5143 .map_err(|detail| host_error("run", &detail))
5144 }
5145
5146 fn service_main(_arguments: Vec<OsString>) {
5147 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5148 return;
5149 };
5150 let result = run_service(invocation.run);
5151 let _ = invocation.result.send(result);
5152 }
5153
5154 fn run_service(run: Runner) -> Result<u8, String> {
5155 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5156 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5157 let handler_status = Arc::clone(&status);
5158 let handler = move |control| match control {
5159 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5160 ServiceControl::Stop | ServiceControl::Shutdown => {
5161 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5162 let _ = handle.set_service_status(service_status(
5163 ServiceState::StopPending,
5164 ServiceControlAccept::empty(),
5165 1,
5166 Duration::from_secs(300),
5167 0,
5168 ));
5169 }
5170 let _ = stop_tx.send(true);
5171 ServiceControlHandlerResult::NoError
5172 }
5173 _ => ServiceControlHandlerResult::NotImplemented,
5174 };
5175 let handle = service_control_handler::register("", handler)
5176 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5177 *status
5178 .lock()
5179 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5180 handle
5181 .set_service_status(service_status(
5182 ServiceState::Running,
5183 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5184 0,
5185 Duration::default(),
5186 0,
5187 ))
5188 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5189
5190 let exit = run(ServiceShutdown(stop_rx));
5191 handle
5192 .set_service_status(service_status(
5193 ServiceState::Stopped,
5194 ServiceControlAccept::empty(),
5195 0,
5196 Duration::default(),
5197 u32::from(exit),
5198 ))
5199 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5200 Ok(exit)
5201 }
5202
5203 fn service_status(
5204 state: ServiceState,
5205 accepted: ServiceControlAccept,
5206 checkpoint: u32,
5207 wait_hint: Duration,
5208 exit: u32,
5209 ) -> ServiceStatus {
5210 ServiceStatus {
5211 service_type: ServiceType::OWN_PROCESS,
5212 current_state: state,
5213 controls_accepted: accepted,
5214 exit_code: if exit == 0 {
5215 ServiceExitCode::Win32(0)
5216 } else {
5217 ServiceExitCode::ServiceSpecific(exit)
5218 },
5219 checkpoint,
5220 wait_hint,
5221 process_id: None,
5222 }
5223 }
5224
5225 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5226 ServiceError::Control {
5227 operation,
5228 name: SERVICE_NAME.to_string(),
5229 manager: "the Windows Service Control Manager",
5230 detail: detail.to_string(),
5231 }
5232 }
5233}
5234
5235#[cfg(windows)]
5236mod sys {
5237 use std::ffi::{OsStr, OsString};
5247 use std::time::{Duration, Instant};
5248
5249 use runner_manager_domain::model::StartMode;
5250 use windows_service::service::{
5251 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5252 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5253 ServiceState, ServiceType,
5254 };
5255 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5256
5257 use super::{
5258 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5259 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5260 };
5261
5262 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5265 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5269 const ACCESS_DENIED: i32 = 5;
5271 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5272 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5273
5274 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5275 choose \"Run as administrator\".";
5276
5277 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5278 Ok(match mode {
5279 StartMode::Boot => Box::new(ScmControl),
5280 StartMode::Login => Box::new(TaskControl),
5281 })
5282 }
5283
5284 #[derive(Debug)]
5287 struct ScmControl;
5288
5289 fn scm_error(
5293 operation: &'static str,
5294 name: &str,
5295 error: &windows_service::Error,
5296 ) -> ServiceError {
5297 let raw = match error {
5298 windows_service::Error::Winapi(io) => io.raw_os_error(),
5299 _ => None,
5300 };
5301 let detail = match error {
5302 windows_service::Error::Winapi(io) => io.to_string(),
5303 other => other.to_string(),
5304 };
5305 if raw == Some(ACCESS_DENIED) {
5306 return ServiceError::NeedsElevation {
5307 operation,
5308 name: name.to_string(),
5309 detail,
5310 remedy: ELEVATION_REMEDY,
5311 };
5312 }
5313 ServiceError::Control {
5314 operation,
5315 name: name.to_string(),
5316 manager: "the Windows Service Control Manager",
5317 detail,
5318 }
5319 }
5320
5321 fn open_manager(
5322 access: ServiceManagerAccess,
5323 operation: &'static str,
5324 name: &str,
5325 ) -> Result<ServiceManager, ServiceError> {
5326 ServiceManager::local_computer(None::<&OsStr>, access)
5327 .map_err(|error| scm_error(operation, name, &error))
5328 }
5329
5330 impl ServiceControl for ScmControl {
5331 fn manager(&self) -> DefinitionKind {
5332 DefinitionKind::WindowsService
5333 }
5334
5335 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5336 let spec = windows_service_spec(plan);
5337 let manager = open_manager(
5338 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5339 "install",
5340 &spec.name,
5341 )?;
5342 let info = ServiceInfo {
5343 name: OsString::from(&spec.name),
5344 display_name: OsString::from(&spec.display_name),
5345 service_type: ServiceType::OWN_PROCESS,
5347 start_type: if spec.automatic_start {
5348 ServiceStartType::AutoStart
5349 } else {
5350 ServiceStartType::OnDemand
5351 },
5352 error_control: ServiceErrorControl::Normal,
5353 executable_path: plan.binary().to_path_buf(),
5354 launch_arguments: plan.arguments().to_vec(),
5355 dependencies: Vec::new(),
5356 account_name: spec.account.as_ref().map(OsString::from),
5358 account_password: None,
5361 };
5362 let service = manager
5363 .create_service(
5364 &info,
5365 ServiceAccess::CHANGE_CONFIG
5366 | ServiceAccess::QUERY_CONFIG
5367 | ServiceAccess::QUERY_STATUS
5368 | ServiceAccess::START
5369 | ServiceAccess::STOP
5370 | ServiceAccess::DELETE,
5371 )
5372 .map_err(|error| scm_error("install", &spec.name, &error))?;
5373 service
5374 .set_description(&spec.description)
5375 .map_err(|error| scm_error("describe", &spec.name, &error))?;
5376 service
5377 .update_failure_actions(ServiceFailureActions {
5378 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5379 reboot_msg: None,
5380 command: None,
5381 actions: Some(vec![
5388 ServiceAction {
5389 action_type: ServiceActionType::Restart,
5390 delay: spec.restart.delay(),
5391 };
5392 3
5393 ]),
5394 })
5395 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5396 service
5399 .set_failure_actions_on_non_crash_failures(true)
5400 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5401 Ok(ServiceDefinition::windows_service(plan))
5402 }
5403
5404 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5405 let manager =
5406 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
5407 let service = match manager.open_service(
5408 identity.name(),
5409 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
5410 ) {
5411 Ok(service) => service,
5412 Err(error) if is_missing(&error) => return Ok(false),
5413 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
5414 };
5415 if let Ok(status) = service.query_status()
5419 && status.current_state != ServiceState::Stopped
5420 {
5421 let _ = service.stop();
5422 }
5423 service
5424 .delete()
5425 .map_err(|error| scm_error("uninstall", identity.name(), &error))?;
5426
5427 drop(service);
5434 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
5435 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
5436 Ok(service) => {
5437 drop(service);
5438 Ok(false)
5439 }
5440 Err(error) if is_missing(&error) => Ok(true),
5441 Err(error) if is_marked_for_delete(&error) => Ok(false),
5442 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
5443 }
5444 })?;
5445 if !absent {
5446 return Err(ServiceError::Control {
5447 operation: "verify uninstall of",
5448 name: identity.name().to_string(),
5449 manager: "the Windows Service Control Manager",
5450 detail: format!(
5451 "the registration was still visible {} seconds after DeleteService; \
5452 retry `service uninstall` from an elevated prompt",
5453 DELETE_TIMEOUT.as_secs()
5454 ),
5455 });
5456 }
5457 Ok(true)
5458 }
5459
5460 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5461 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
5462 let service = match manager.open_service(
5463 identity.name(),
5464 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
5465 ) {
5466 Ok(service) => service,
5467 Err(error) if is_missing(&error) => return Ok(None),
5468 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
5469 };
5470 let config = service
5471 .query_config()
5472 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5473 let status = service
5474 .query_status()
5475 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5476 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
5477 actions
5478 .actions
5479 .and_then(|actions| actions.into_iter().next())
5480 .filter(|action| action.action_type == ServiceActionType::Restart)
5481 .map(|action| action.delay)
5482 });
5483 Ok(Some(Registration {
5484 manager: DefinitionKind::WindowsService,
5485 start_mode: StartMode::Boot,
5486 command_line: config.executable_path.to_string_lossy().into_owned(),
5490 account: config
5491 .account_name
5492 .map(|account| account.to_string_lossy().into_owned()),
5493 running: status.current_state == ServiceState::Running,
5494 starts_automatically: config.start_type == ServiceStartType::AutoStart,
5495 restart_delay,
5496 }))
5497 }
5498
5499 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5500 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
5501 let service = manager
5502 .open_service(identity.name(), ServiceAccess::START)
5503 .map_err(|error| scm_error("start", identity.name(), &error))?;
5504 service
5505 .start::<&OsStr>(&[])
5506 .map_err(|error| scm_error("start", identity.name(), &error))
5507 }
5508
5509 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5510 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
5511 let service = manager
5512 .open_service(
5513 identity.name(),
5514 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
5515 )
5516 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5517 let status = service
5518 .query_status()
5519 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5520 if status.current_state == ServiceState::Stopped {
5521 return Ok(false);
5522 }
5523 service
5524 .stop()
5525 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5526 Ok(true)
5527 }
5528 }
5529
5530 fn is_missing(error: &windows_service::Error) -> bool {
5531 matches!(error, windows_service::Error::Winapi(io)
5532 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
5533 }
5534
5535 fn is_marked_for_delete(error: &windows_service::Error) -> bool {
5536 matches!(error, windows_service::Error::Winapi(io)
5537 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
5538 }
5539
5540 pub(super) fn wait_until_scm_absent(
5541 timeout: Duration,
5542 poll_interval: Duration,
5543 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
5544 ) -> Result<bool, ServiceError> {
5545 let deadline = Instant::now() + timeout;
5546 loop {
5547 if probe_absent()? {
5548 return Ok(true);
5549 }
5550 if Instant::now() >= deadline {
5551 return Ok(false);
5552 }
5553 std::thread::sleep(poll_interval);
5554 }
5555 }
5556
5557 #[derive(Debug)]
5560 struct TaskControl;
5561
5562 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
5563 if detail.to_ascii_lowercase().contains("access is denied") {
5564 return ServiceError::NeedsElevation {
5565 operation,
5566 name: name.to_string(),
5567 detail,
5568 remedy: ELEVATION_REMEDY,
5569 };
5570 }
5571 ServiceError::Control {
5572 operation,
5573 name: name.to_string(),
5574 manager: "Windows Task Scheduler",
5575 detail,
5576 }
5577 }
5578
5579 fn schtasks(
5580 operation: &'static str,
5581 name: &str,
5582 arguments: &[&OsStr],
5583 ) -> Result<(bool, String), ServiceError> {
5584 match run("schtasks.exe", arguments) {
5585 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
5586 Err(error) => Err(task_error(operation, name, error.to_string())),
5587 }
5588 }
5589
5590 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
5593 let mut bytes = vec![0xFF, 0xFE];
5594 for unit in text.encode_utf16() {
5595 bytes.extend_from_slice(&unit.to_le_bytes());
5596 }
5597 std::fs::write(path, bytes)
5598 }
5599
5600 impl ServiceControl for TaskControl {
5601 fn manager(&self) -> DefinitionKind {
5602 DefinitionKind::WindowsScheduledTask
5603 }
5604
5605 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5606 let name = plan.identity().name().to_string();
5607 let principal = TaskPrincipal::current()?;
5608 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
5609 let directory = tempfile::tempdir()
5610 .map_err(|error| task_error("install", &name, error.to_string()))?;
5611 let document = directory.path().join("task.xml");
5612 write_utf16(&document, definition.text())
5613 .map_err(|error| task_error("install", &name, error.to_string()))?;
5614 let (ok, message) = schtasks(
5615 "install",
5616 &name,
5617 &[
5618 OsStr::new("/Create"),
5619 OsStr::new("/TN"),
5620 OsStr::new(&name),
5621 OsStr::new("/XML"),
5622 document.as_os_str(),
5623 ],
5624 )?;
5625 if !ok {
5626 return Err(task_error("install", &name, message));
5627 }
5628 Ok(definition)
5629 }
5630
5631 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5632 if self.query(identity)?.is_none() {
5633 return Ok(false);
5634 }
5635 let name = identity.name().to_string();
5636 let (ok, message) = schtasks(
5637 "uninstall",
5638 &name,
5639 &[
5640 OsStr::new("/Delete"),
5641 OsStr::new("/TN"),
5642 OsStr::new(&name),
5643 OsStr::new("/F"),
5644 ],
5645 )?;
5646 if !ok {
5647 return Err(task_error("uninstall", &name, message));
5648 }
5649 Ok(true)
5650 }
5651
5652 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5653 let name = identity.name().to_string();
5654 let (ok, document) = schtasks(
5655 "inspect",
5656 &name,
5657 &[
5658 OsStr::new("/Query"),
5659 OsStr::new("/TN"),
5660 OsStr::new(&name),
5661 OsStr::new("/XML"),
5662 OsStr::new("ONE"),
5663 ],
5664 )?;
5665 if !ok {
5666 return Ok(None);
5672 }
5673 let command = xml_value(&document, "Command").unwrap_or_default();
5674 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
5675 let command_line = if arguments.is_empty() {
5676 super::quote_argument(&command)
5677 } else {
5678 format!("{} {arguments}", super::quote_argument(&command))
5679 };
5680 Ok(Some(Registration {
5681 manager: DefinitionKind::WindowsScheduledTask,
5682 start_mode: StartMode::Login,
5683 command_line,
5684 account: xml_value(&document, "UserId"),
5685 running: task_is_running(&name),
5686 starts_automatically: document.contains("<LogonTrigger>")
5687 && xml_value(&document, "Enabled").as_deref() == Some("true"),
5688 restart_delay: xml_value(&document, "Interval")
5689 .as_deref()
5690 .and_then(parse_iso8601),
5691 }))
5692 }
5693
5694 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5695 let name = identity.name().to_string();
5696 let (ok, message) = schtasks(
5697 "start",
5698 &name,
5699 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
5700 )?;
5701 if ok {
5702 Ok(())
5703 } else {
5704 Err(task_error("start", &name, message))
5705 }
5706 }
5707
5708 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5709 let running = self
5710 .query(identity)?
5711 .is_some_and(|registration| registration.running);
5712 if !running {
5713 return Ok(false);
5714 }
5715 let name = identity.name().to_string();
5716 let (ok, message) = schtasks(
5717 "stop",
5718 &name,
5719 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
5720 )?;
5721 if ok {
5722 Ok(true)
5723 } else {
5724 Err(task_error("stop", &name, message))
5725 }
5726 }
5727 }
5728
5729 fn task_is_running(name: &str) -> bool {
5743 let Ok((true, stdout, _)) = run(
5744 "schtasks.exe",
5745 &[
5746 OsStr::new("/Query"),
5747 OsStr::new("/TN"),
5748 OsStr::new(name),
5749 OsStr::new("/FO"),
5750 OsStr::new("CSV"),
5751 OsStr::new("/NH"),
5752 ],
5753 ) else {
5754 return false;
5755 };
5756 stdout
5757 .lines()
5758 .filter_map(|line| line.rsplit(',').next())
5759 .any(|status| {
5760 status
5761 .trim()
5762 .trim_matches('"')
5763 .eq_ignore_ascii_case("running")
5764 })
5765 }
5766
5767 fn parse_iso8601(value: &str) -> Option<Duration> {
5771 let rest = value.strip_prefix("PT")?;
5772 if let Some(minutes) = rest.strip_suffix('M') {
5773 return minutes
5774 .parse::<u64>()
5775 .ok()
5776 .map(|minutes| Duration::from_secs(minutes * 60));
5777 }
5778 rest.strip_suffix('S')?
5779 .parse::<u64>()
5780 .ok()
5781 .map(Duration::from_secs)
5782 }
5783}
5784
5785#[cfg(unix)]
5797fn write_definition(
5798 operation: &'static str,
5799 name: &str,
5800 path: &Path,
5801 text: &str,
5802 remedy: &'static str,
5803) -> Result<(), ServiceError> {
5804 if let Some(parent) = path.parent()
5805 && let Err(error) = std::fs::create_dir_all(parent)
5806 && error.kind() != std::io::ErrorKind::AlreadyExists
5807 {
5808 return Err(definition_error(operation, name, error, remedy, parent));
5809 }
5810 std::fs::write(path, text)
5811 .map_err(|error| definition_error(operation, name, error, remedy, path))
5812}
5813
5814#[cfg(unix)]
5815fn definition_error(
5816 operation: &'static str,
5817 name: &str,
5818 error: std::io::Error,
5819 remedy: &'static str,
5820 path: &Path,
5821) -> ServiceError {
5822 let detail = format!("{}: {error}", path.display());
5823 if error.kind() == std::io::ErrorKind::PermissionDenied {
5824 ServiceError::NeedsElevation {
5825 operation,
5826 name: name.to_string(),
5827 detail,
5828 remedy,
5829 }
5830 } else {
5831 ServiceError::Control {
5832 operation,
5833 name: name.to_string(),
5834 manager: "the local service manager",
5835 detail,
5836 }
5837 }
5838}
5839
5840#[cfg(unix)]
5842const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
5843 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
5844 agent not running until you sign in.";
5845
5846#[cfg(target_os = "macos")]
5851mod sys {
5852 use std::ffi::OsStr;
5865 use std::path::PathBuf;
5866 use std::time::Duration;
5867
5868 use runner_manager_domain::model::StartMode;
5869
5870 use super::{
5871 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
5872 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
5873 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
5874 write_definition, xml_value,
5875 };
5876
5877 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5878 Ok(Box::new(LaunchdControl { mode }))
5879 }
5880
5881 #[derive(Debug)]
5882 struct LaunchdControl {
5883 mode: StartMode,
5884 }
5885
5886 impl LaunchdControl {
5887 fn domain(&self) -> String {
5889 match self.mode {
5890 StartMode::Boot => "system".to_string(),
5891 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
5894 }
5895 }
5896
5897 fn service_target(&self, identity: &ServiceIdentity) -> String {
5898 format!("{}/{}", self.domain(), identity.launchd_label())
5899 }
5900
5901 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
5904 let file = format!("{}.plist", identity.launchd_label());
5905 match self.mode {
5906 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
5907 StartMode::Login => {
5908 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
5909 }
5910 }
5911 }
5912
5913 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
5914 ServiceError::Control {
5915 operation,
5916 name: name.to_string(),
5917 manager: "launchd",
5918 detail,
5919 }
5920 }
5921
5922 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
5923 match run("launchctl", arguments) {
5924 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
5925 Err(error) => (false, error.to_string()),
5926 }
5927 }
5928 }
5929
5930 impl ServiceControl for LaunchdControl {
5931 fn manager(&self) -> DefinitionKind {
5932 DefinitionKind::LaunchdPlist
5933 }
5934
5935 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5936 let name = plan.identity().name().to_string();
5937 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
5938 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
5939 return Err(self.failed(
5940 "install",
5941 &name,
5942 "this account has no home directory, so there is nowhere to put a \
5943 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
5944 /Library/LaunchDaemons."
5945 .to_string(),
5946 ));
5947 };
5948 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
5949 let target = self.domain();
5950 let (ok, message) = self.launchctl(&[
5951 OsStr::new("bootstrap"),
5952 OsStr::new(&target),
5953 path.as_os_str(),
5954 ]);
5955 if !ok {
5956 let _ = std::fs::remove_file(&path);
5960 if message.to_ascii_lowercase().contains("permission denied") {
5961 return Err(ServiceError::NeedsElevation {
5962 operation: "install",
5963 name,
5964 detail: message,
5965 remedy: SUDO_REMEDY,
5966 });
5967 }
5968 return Err(self.failed("install", &name, message));
5969 }
5970 let service_target = self.service_target(plan.identity());
5973 enable_launchd_registration(
5974 |arguments| self.launchctl(arguments),
5975 &target,
5976 &service_target,
5977 &path,
5978 &name,
5979 SUDO_REMEDY,
5980 )?;
5981 Ok(definition)
5982 }
5983
5984 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5985 let Some(path) = self.plist_path(identity) else {
5986 return Ok(false);
5987 };
5988 if !path.exists() {
5989 return Ok(false);
5990 }
5991 let target = self.service_target(identity);
5992 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
5993 if !ok
5994 && !message.to_ascii_lowercase().contains("no such process")
5995 && !message.contains("113")
5996 {
5997 if message.to_ascii_lowercase().contains("permission denied") {
5998 return Err(ServiceError::NeedsElevation {
5999 operation: "uninstall",
6000 name: identity.name().to_string(),
6001 detail: message,
6002 remedy: SUDO_REMEDY,
6003 });
6004 }
6005 return Err(self.failed("uninstall", identity.name(), message));
6006 }
6007 std::fs::remove_file(&path).map_err(|error| {
6010 super::definition_error(
6011 "uninstall",
6012 identity.name(),
6013 error,
6014 SUDO_REMEDY,
6015 path.as_path(),
6016 )
6017 })?;
6018 Ok(true)
6019 }
6020
6021 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6022 let Some(path) = self.plist_path(identity) else {
6023 return Ok(None);
6024 };
6025 let Ok(document) = std::fs::read_to_string(&path) else {
6026 return Ok(None);
6027 };
6028 let target = self.service_target(identity);
6029 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6030 Ok(Some(Registration {
6031 manager: DefinitionKind::LaunchdPlist,
6032 start_mode: self.mode,
6033 command_line: program_arguments(&document),
6034 account: plist_string_value(&document, "UserName")
6035 .or_else(|| Some("the invoking user".to_string())),
6036 running: loaded && printed.contains("state = running"),
6037 starts_automatically: document.contains("<key>RunAtLoad</key>")
6038 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6039 restart_delay: xml_value(
6040 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6041 "integer",
6042 )
6043 .and_then(|value| value.parse::<u64>().ok())
6044 .map(Duration::from_secs),
6045 }))
6046 }
6047
6048 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6049 let target = self.service_target(identity);
6050 let (ok, message) = self.launchctl(&[
6051 OsStr::new("kickstart"),
6052 OsStr::new("-k"),
6053 OsStr::new(&target),
6054 ]);
6055 if ok {
6056 Ok(())
6057 } else {
6058 Err(self.failed("start", identity.name(), message))
6059 }
6060 }
6061
6062 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6063 let running = self
6064 .query(identity)?
6065 .is_some_and(|registration| registration.running);
6066 if !running {
6067 return Ok(false);
6068 }
6069 let target = self.service_target(identity);
6070 let (ok, message) = self.launchctl(&[
6071 OsStr::new("kill"),
6072 OsStr::new("SIGTERM"),
6073 OsStr::new(&target),
6074 ]);
6075 if ok {
6076 Ok(true)
6077 } else {
6078 Err(self.failed("stop", identity.name(), message))
6079 }
6080 }
6081 }
6082
6083 fn program_arguments(document: &str) -> String {
6085 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6086 return String::new();
6087 };
6088 let Some(end) = rest.find("</array>") else {
6089 return String::new();
6090 };
6091 let mut out = Vec::new();
6092 let mut cursor = &rest[..end];
6093 while let Some(open) = cursor.find("<string>") {
6094 let after = &cursor[open + "<string>".len()..];
6095 let Some(close) = after.find("</string>") else {
6096 break;
6097 };
6098 out.push(quote_argument(&super::xml_unescape(&after[..close])));
6099 cursor = &after[close..];
6100 }
6101 out.join(" ")
6102 }
6103}
6104
6105#[cfg(all(unix, not(target_os = "macos")))]
6110mod sys {
6111 use std::ffi::OsStr;
6121 use std::path::PathBuf;
6122 use std::time::Duration;
6123
6124 use runner_manager_domain::model::StartMode;
6125
6126 use super::{
6127 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6128 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6129 home_directory, ini_directives, run, write_definition,
6130 };
6131
6132 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6133 Ok(Box::new(SystemdControl { mode }))
6134 }
6135
6136 #[derive(Debug)]
6137 struct SystemdControl {
6138 mode: StartMode,
6139 }
6140
6141 impl SystemdControl {
6142 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6143 let file = identity.systemd_unit();
6144 match self.mode {
6145 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6146 StartMode::Login => {
6147 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6148 }
6149 }
6150 }
6151
6152 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6154 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6155 if self.mode == StartMode::Login {
6156 all.push(OsStr::new("--user"));
6157 }
6158 all.extend(arguments.iter().map(OsStr::new));
6159 match run("systemctl", &all) {
6160 Ok((ok, stdout, stderr)) => (
6161 ok,
6162 if stdout.trim().is_empty() {
6163 stderr
6164 } else {
6165 stdout
6166 },
6167 ),
6168 Err(error) => (false, error.to_string()),
6169 }
6170 }
6171
6172 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6173 ServiceError::Control {
6174 operation,
6175 name: name.to_string(),
6176 manager: "systemd",
6177 detail,
6178 }
6179 }
6180 }
6181
6182 impl ServiceControl for SystemdControl {
6183 fn manager(&self) -> DefinitionKind {
6184 DefinitionKind::SystemdUnit
6185 }
6186
6187 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6188 let name = plan.identity().name().to_string();
6189 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6190 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6191 return Err(self.failed(
6192 "install",
6193 &name,
6194 "this account has no home directory, so there is nowhere to put a systemd \
6195 user unit. Use --start-at boot, which installs a system unit under \
6196 /etc/systemd/system."
6197 .to_string(),
6198 ));
6199 };
6200 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6201 let unit = plan.identity().systemd_unit();
6202 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6203 if !reloaded {
6204 let _ = std::fs::remove_file(&path);
6205 return Err(self.failed("install", &name, message));
6206 }
6207 let (enabled, message) = self.systemctl(&["enable", &unit]);
6208 if !enabled {
6209 let _ = std::fs::remove_file(&path);
6210 let _ = self.systemctl(&["daemon-reload"]);
6211 return Err(self.failed("install", &name, message));
6212 }
6213 Ok(definition)
6214 }
6215
6216 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6217 let Some(path) = self.unit_path(identity) else {
6218 return Ok(false);
6219 };
6220 if !path.exists() {
6221 return Ok(false);
6222 }
6223 let unit = identity.systemd_unit();
6224 let _ = self.systemctl(&["disable", "--now", &unit]);
6229 std::fs::remove_file(&path).map_err(|error| {
6230 super::definition_error(
6231 "uninstall",
6232 identity.name(),
6233 error,
6234 SUDO_REMEDY,
6235 path.as_path(),
6236 )
6237 })?;
6238 let _ = self.systemctl(&["daemon-reload"]);
6239 Ok(true)
6240 }
6241
6242 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6243 let Some(path) = self.unit_path(identity) else {
6244 return Ok(None);
6245 };
6246 let Ok(document) = std::fs::read_to_string(&path) else {
6247 return Ok(None);
6248 };
6249 let unit = identity.systemd_unit();
6250 let directives = ini_directives(&document, "Service");
6251 let (_, active) = self.systemctl(&["is-active", &unit]);
6252 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6253 Ok(Some(Registration {
6254 manager: DefinitionKind::SystemdUnit,
6255 start_mode: self.mode,
6256 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6257 account: directives.get("User").cloned().or_else(|| {
6258 Some(match self.mode {
6259 StartMode::Boot => "root".to_string(),
6260 StartMode::Login => "the invoking user".to_string(),
6261 })
6262 }),
6263 running: active.trim() == "active",
6264 starts_automatically: enabled.trim() == "enabled",
6265 restart_delay: directives
6266 .get("RestartSec")
6267 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6268 .map(Duration::from_secs),
6269 }))
6270 }
6271
6272 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6273 let unit = identity.systemd_unit();
6274 let (ok, message) = self.systemctl(&["start", &unit]);
6275 if ok {
6276 Ok(())
6277 } else {
6278 Err(self.failed("start", identity.name(), message))
6279 }
6280 }
6281
6282 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6283 let running = self
6284 .query(identity)?
6285 .is_some_and(|registration| registration.running);
6286 if !running {
6287 return Ok(false);
6288 }
6289 let unit = identity.systemd_unit();
6290 let (ok, message) = self.systemctl(&["stop", &unit]);
6291 if ok {
6292 Ok(true)
6293 } else {
6294 Err(self.failed("stop", identity.name(), message))
6295 }
6296 }
6297 }
6298}
6299
6300#[cfg(test)]
6301mod tests {
6302 use super::*;
6303
6304 use std::collections::BTreeMap;
6305
6306 fn linux_plan(mode: StartMode) -> InstallPlan {
6313 InstallPlan::unchecked(
6314 ServiceIdentity::product(),
6315 mode,
6316 "/opt/runner-manager/bin/runner-manager",
6317 ServiceDirectories {
6318 config: PathBuf::from("/var/lib/runner-manager/config"),
6319 state: PathBuf::from("/var/lib/runner-manager/state"),
6320 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
6321 logs: PathBuf::from("/var/log/runner-manager"),
6322 },
6323 )
6324 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
6325 }
6326
6327 fn windows_plan(mode: StartMode) -> InstallPlan {
6328 InstallPlan::unchecked(
6329 ServiceIdentity::product(),
6330 mode,
6331 "C:\\Program Files\\runner-manager\\runner-manager.exe",
6332 ServiceDirectories {
6333 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
6334 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
6335 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
6336 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
6337 },
6338 )
6339 }
6340
6341 fn edited(text: &str, from: &str, to: &str) -> String {
6349 assert!(
6350 text.contains(from),
6351 "the rendered definition does not contain `{from}`, so this test would assert \
6352 nothing about a widened one"
6353 );
6354 text.replace(from, to)
6355 }
6356
6357 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
6359 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
6360 let Ok(entries) = std::fs::read_dir(directory) else {
6361 return;
6362 };
6363 for entry in entries.flatten() {
6364 let path = entry.path();
6365 if path.is_dir() {
6366 walk(&path, out);
6367 } else if let Ok(bytes) = std::fs::read(&path) {
6368 out.insert(path, bytes);
6369 }
6370 }
6371 }
6372 let mut out = BTreeMap::new();
6373 for root in roots {
6374 walk(root, &mut out);
6375 }
6376 out
6377 }
6378
6379 struct Host {
6380 _root: tempfile::TempDir,
6381 paths: AppPaths,
6382 binary: PathBuf,
6383 runner_root: LocalAbsolutePath,
6392 controls: RecordingControls,
6393 }
6394
6395 impl Host {
6396 fn new() -> Self {
6397 let root = tempfile::tempdir().expect("a temporary directory");
6398 let paths = AppPaths::rooted_at(root.path());
6399 paths.create_all().expect("the four directories");
6400 let binary = root.path().join(if cfg!(windows) {
6401 "runner-manager.exe"
6402 } else {
6403 "runner-manager"
6404 });
6405 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
6406 let runner_root = LocalAbsolutePath::new(
6407 root.path()
6408 .join("runner-root")
6409 .to_str()
6410 .expect("a unicode temporary path"),
6411 )
6412 .expect("a local absolute path");
6413 Self {
6414 _root: root,
6415 paths,
6416 binary,
6417 runner_root,
6418 controls: RecordingControls::new(),
6419 }
6420 }
6421
6422 fn operations(&self) -> ServiceOperations {
6423 ServiceOperations::with_controls(
6424 self.paths.clone(),
6425 ServiceIdentity::product(),
6426 std::sync::Arc::new(self.controls.clone()),
6427 )
6428 .with_runner_root(self.runner_root.clone())
6429 }
6430
6431 fn request(&self, mode: StartMode) -> InstallRequest {
6432 InstallRequest::new(mode).for_binary(&self.binary)
6433 }
6434 }
6435
6436 #[cfg(windows)]
6437 #[test]
6438 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
6439 let probes = std::cell::Cell::new(0);
6440 let absent =
6441 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
6442 let next = probes.get() + 1;
6443 probes.set(next);
6444 Ok(next == 3)
6445 })
6446 .expect("the simulated SCM probe succeeds");
6447
6448 assert!(absent);
6449 assert_eq!(
6450 probes.get(),
6451 3,
6452 "uninstall must recheck after transient presence instead of treating it as a leak"
6453 );
6454 }
6455
6456 #[test]
6457 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
6458 let root = tempfile::tempdir().expect("a temporary directory");
6459 let plist = root.path().join("fixture.plist");
6460 std::fs::write(&plist, b"fixture").expect("a plist fixture");
6461 let calls = std::cell::RefCell::new(Vec::new());
6462
6463 let error = enable_launchd_registration(
6464 |arguments| {
6465 let call = arguments
6466 .iter()
6467 .map(|argument| argument.to_string_lossy().into_owned())
6468 .collect::<Vec<_>>();
6469 let operation = call[0].clone();
6470 calls.borrow_mut().push(call);
6471 if operation == "enable" {
6472 (false, "label remains disabled".to_string())
6473 } else {
6474 (true, String::new())
6475 }
6476 },
6477 "system",
6478 "system/com.openai.runner-manager-selftest",
6479 &plist,
6480 "runner-manager-selftest",
6481 "rerun with administrative rights",
6482 )
6483 .expect_err("enable failure must fail the install");
6484
6485 assert!(
6486 matches!(
6487 error,
6488 ServiceError::Control {
6489 operation: "enable",
6490 ..
6491 }
6492 ),
6493 "{error}"
6494 );
6495 assert_eq!(calls.borrow().len(), 2);
6496 assert_eq!(calls.borrow()[0][0], "enable");
6497 assert_eq!(calls.borrow()[1][0], "bootout");
6498 assert!(!plist.exists(), "rollback must remove the plist");
6499 }
6500
6501 #[test]
6506 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
6507 let plan = windows_plan(StartMode::Boot);
6508 let command_line = plan.command_line();
6509 assert!(
6510 command_line.starts_with('"'),
6511 "a path with a space must be quoted, got {command_line}"
6512 );
6513 assert_eq!(
6514 executable_from_command_line(&command_line).as_deref(),
6515 Some(plan.binary())
6516 );
6517 }
6518
6519 #[test]
6520 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
6521 let plan = linux_plan(StartMode::Boot);
6522 let command_line = plan.command_line();
6523 assert!(!command_line.starts_with('"'), "got {command_line}");
6524 assert_eq!(
6525 executable_from_command_line(&command_line).as_deref(),
6526 Some(plan.binary())
6527 );
6528 }
6529
6530 #[test]
6531 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
6532 let awkward = r#"C:\odd "name"\rm.exe"#;
6535 let quoted = quote_argument(awkward);
6536 assert_eq!(
6537 executable_from_command_line(&format!("{quoted} daemon run"))
6538 .as_deref()
6539 .map(Path::to_string_lossy)
6540 .as_deref(),
6541 Some(awkward)
6542 );
6543 }
6544
6545 #[test]
6546 fn an_empty_command_line_has_no_executable() {
6547 assert_eq!(executable_from_command_line(" "), None);
6548 assert_eq!(executable_from_command_line(""), None);
6549 }
6550
6551 #[test]
6552 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
6553 let awkward = r#"DOMAIN\R&D <team> "ops""#;
6554 assert_eq!(
6555 xml_escape(awkward),
6556 "DOMAIN\\R&D <team> "ops""
6557 );
6558 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
6559 }
6560
6561 #[test]
6566 fn a_restart_delay_under_the_floor_is_refused() {
6567 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
6568 .expect_err("half a second is under the one-second floor");
6569 assert!(
6570 matches!(error, ServiceError::RestartDelay { .. }),
6571 "{error}"
6572 );
6573 }
6574
6575 #[test]
6576 fn a_restart_delay_over_the_ceiling_is_refused() {
6577 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
6578 .expect_err("an hour is over the five-minute ceiling");
6579 assert!(
6580 matches!(error, ServiceError::RestartDelay { .. }),
6581 "{error}"
6582 );
6583 }
6584
6585 #[test]
6586 fn a_reset_window_no_longer_than_the_delay_is_refused() {
6587 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
6588 .expect_err("a window equal to the delay can never elapse between restarts");
6589 assert!(
6590 matches!(error, ServiceError::RestartResetWindow { .. }),
6591 "{error}"
6592 );
6593 }
6594
6595 #[test]
6596 fn a_delay_inside_the_bound_is_accepted() {
6597 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
6598 .expect("twenty seconds is inside the bound");
6599 assert_eq!(policy.delay(), Duration::from_secs(20));
6600 assert_eq!(policy.reset_after(), Duration::from_secs(300));
6601 }
6602
6603 #[test]
6608 fn a_fixture_identity_can_never_be_the_product_identity() {
6609 let fixture = ServiceIdentity::fixture("abc123");
6610 assert!(fixture.is_fixture());
6611 assert!(!ServiceIdentity::product().is_fixture());
6612 assert_ne!(fixture.name(), ServiceIdentity::product().name());
6613 assert_ne!(
6614 fixture.launchd_label(),
6615 ServiceIdentity::product().launchd_label()
6616 );
6617 assert_ne!(
6618 fixture.systemd_unit(),
6619 ServiceIdentity::product().systemd_unit()
6620 );
6621 }
6622
6623 #[test]
6624 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
6625 let fixture = ServiceIdentity::fixture("A b/c:\\d");
6626 assert!(
6627 fixture
6628 .name()
6629 .chars()
6630 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
6631 "got {}",
6632 fixture.name()
6633 );
6634 }
6635
6636 #[test]
6641 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
6642 let unit = systemd_unit(&linux_plan(StartMode::Boot));
6643 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
6644 assert!(unit.contains("RestartSec=15\n"), "{unit}");
6645 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
6646 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
6647 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
6648 }
6649
6650 #[test]
6651 fn the_boot_unit_reads_the_token_through_the_credential_d2_publishes() {
6652 let unit = systemd_unit(&linux_plan(StartMode::Boot));
6653 assert!(
6654 unit.contains(&format!(
6655 "LoadCredential={}:/var/lib/runner-manager/secrets/user-access-token\n",
6656 crate::secrets::SYSTEMD_CREDENTIAL
6657 )),
6658 "the unit must name the credential `d2` reads, got:\n{unit}"
6659 );
6660 }
6661
6662 #[test]
6663 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
6664 let unit = systemd_unit(&linux_plan(StartMode::Login));
6665 assert!(
6666 !unit.contains("LoadCredential="),
6667 "a user unit must not name a root-owned credential file, got:\n{unit}"
6668 );
6669 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
6670 }
6671
6672 #[test]
6673 fn the_unit_makes_exactly_the_four_directories_writable() {
6674 let plan = linux_plan(StartMode::Boot);
6675 let unit = systemd_unit(&plan);
6676 let directives = ini_directives(&unit, "Service");
6677 let listed = split_quoted(
6678 directives
6679 .get("ReadWritePaths")
6680 .expect("the unit names its writable paths"),
6681 );
6682 assert_eq!(listed.len(), 4, "{listed:?}");
6683 for path in plan.directories().all() {
6684 assert!(
6685 listed.iter().any(|entry| entry == &path.to_string_lossy()),
6686 "{} is missing from {listed:?}",
6687 path.display()
6688 );
6689 }
6690 }
6691
6692 #[test]
6693 fn the_unit_records_the_absolute_binary_path() {
6694 let plan = linux_plan(StartMode::Boot);
6695 let unit = systemd_unit(&plan);
6696 assert!(
6697 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
6698 "{unit}"
6699 );
6700 }
6701
6702 #[test]
6707 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
6708 let plist = launchd_plist(&linux_plan(StartMode::Boot));
6709 assert!(
6712 plist.contains(
6713 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
6714 ),
6715 "{plist}"
6716 );
6717 assert!(
6718 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
6719 "{plist}"
6720 );
6721 }
6722
6723 #[test]
6724 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
6725 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
6726 assert_eq!(
6727 plist_string_value(&daemon, "UserName").as_deref(),
6728 Some("root")
6729 );
6730 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
6731
6732 let agent = launchd_plist(&linux_plan(StartMode::Login));
6733 assert_eq!(
6734 plist_string_value(&agent, "UserName"),
6735 None,
6736 "a LaunchAgent already runs as the operator:\n{agent}"
6737 );
6738 }
6739
6740 #[test]
6741 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
6742 let plist = launchd_plist(&linux_plan(StartMode::Boot));
6743 assert!(
6744 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
6745 "{plist}"
6746 );
6747 assert!(plist.contains("<string>daemon</string>"), "{plist}");
6748 assert!(plist.contains("<string>run</string>"), "{plist}");
6749 }
6750
6751 #[test]
6752 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
6753 assert_eq!(
6754 ServiceIdentity::product().launchd_label(),
6755 "io.github.IvanMurzak.runner-manager"
6756 );
6757 }
6758
6759 #[test]
6764 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
6765 let xml = windows_scheduled_task_xml(
6766 &windows_plan(StartMode::Login),
6767 &TaskPrincipal::named("HOST\\operator"),
6768 );
6769 assert!(xml.contains("<LogonTrigger>"), "{xml}");
6770 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
6771 assert!(
6772 xml.contains("<LogonType>InteractiveToken</LogonType>"),
6773 "{xml}"
6774 );
6775 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
6776 assert!(
6777 xml.contains("<Interval>PT1M</Interval>"),
6778 "Task Scheduler takes whole minutes only, and rejects the registration outright \
6779 for anything finer:\n{xml}"
6780 );
6781 }
6782
6783 #[test]
6784 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
6785 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
6790 let policy =
6791 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
6792 .expect("inside the supported range");
6793 assert_eq!(
6794 policy
6795 .effective_delay(DefinitionKind::WindowsScheduledTask)
6796 .as_secs(),
6797 enforced,
6798 "a {asked}s delay must be enforced as {enforced}s"
6799 );
6800 assert!(
6801 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
6802 "rounding must never shorten the bound"
6803 );
6804 }
6805 }
6806
6807 #[test]
6808 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
6809 let policy = RestartPolicy::default();
6810 for kind in [
6811 DefinitionKind::WindowsService,
6812 DefinitionKind::LaunchdPlist,
6813 DefinitionKind::SystemdUnit,
6814 ] {
6815 assert_eq!(
6816 policy.effective_delay(kind),
6817 policy.delay(),
6818 "{kind:?} takes seconds and enforces exactly what it is given"
6819 );
6820 }
6821 }
6822
6823 #[test]
6824 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
6825 let host = Host::new();
6826 let operations = host.operations();
6827 operations
6828 .install(&host.request(StartMode::Login))
6829 .expect("an install at login");
6830 host.controls.edit("runner-manager", |registration| {
6832 registration.manager = DefinitionKind::WindowsScheduledTask;
6833 registration.restart_delay = Some(Duration::from_secs(60));
6834 });
6835
6836 let status = operations.status().expect("a status");
6837 assert!(
6838 status.is_healthy(),
6839 "minute granularity is the manager's, not a mis-registration: {status}"
6840 );
6841 assert!(
6842 status
6843 .notes()
6844 .iter()
6845 .any(|note| note.contains("whole minutes")),
6846 "but the operator must be told why 15 became 60: {status}"
6847 );
6848
6849 host.controls.edit("runner-manager", |registration| {
6852 registration.restart_delay = Some(Duration::from_secs(1));
6853 });
6854 assert!(
6855 !operations.status().expect("a status").is_healthy(),
6856 "a one-second delay is not what any manager was asked for"
6857 );
6858 }
6859
6860 #[test]
6861 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
6862 let plan = windows_plan(StartMode::Login);
6863 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
6864 assert_eq!(
6865 xml_value(&xml, "Command").as_deref(),
6866 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
6867 "{xml}"
6868 );
6869 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
6870 }
6871
6872 #[test]
6873 fn an_account_name_holding_xml_punctuation_is_escaped() {
6874 let xml = windows_scheduled_task_xml(
6875 &windows_plan(StartMode::Login),
6876 &TaskPrincipal::named("R&D\\ops"),
6877 );
6878 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
6879 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
6880 }
6881
6882 #[test]
6887 fn the_service_starts_automatically_under_the_account_the_store_admits() {
6888 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
6889 let directives = ini_directives(&text, "windows-service");
6890 assert_eq!(
6891 directives.get("StartType").map(String::as_str),
6892 Some("AutoStart")
6893 );
6894 assert_eq!(
6895 directives.get("Account").map(String::as_str),
6896 Some("NT AUTHORITY\\SYSTEM")
6897 );
6898 assert_eq!(
6899 directives.get("ServiceType").map(String::as_str),
6900 Some("OWN_PROCESS")
6901 );
6902 assert_eq!(
6903 directives
6904 .get("FailureActionRestartDelaySecs")
6905 .map(String::as_str),
6906 Some("15")
6907 );
6908 assert_eq!(
6909 directives
6910 .get("FailureActionsOnNonCrashFailures")
6911 .map(String::as_str),
6912 Some("true"),
6913 "without this flag a non-zero exit is not a failure the manager restarts"
6914 );
6915 }
6916
6917 #[test]
6918 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
6919 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
6920 assert_eq!(spec.account, None);
6921 assert!(spec.automatic_start);
6922 assert!(
6923 spec.command_line.contains("daemon run"),
6924 "{}",
6925 spec.command_line
6926 );
6927 }
6928
6929 #[test]
6934 fn each_definition_goes_where_its_platform_expects_it() {
6935 let home = PathBuf::from("/home/op");
6936 assert_eq!(
6937 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
6938 Some(Path::new(
6939 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
6940 ))
6941 );
6942 assert_eq!(
6943 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
6944 Some(Path::new(
6945 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
6946 ))
6947 );
6948 assert_eq!(
6949 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
6950 Some(Path::new("/etc/systemd/system/runner-manager.service"))
6951 );
6952 assert_eq!(
6953 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
6954 Some(Path::new(
6955 "/home/op/.config/systemd/user/runner-manager.service"
6956 ))
6957 );
6958 assert_eq!(
6959 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
6960 None,
6961 "the Service Control Manager has no file"
6962 );
6963 }
6964
6965 #[test]
6966 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
6967 assert_eq!(
6968 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
6969 None
6970 );
6971 assert_eq!(
6972 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
6973 None
6974 );
6975 }
6976
6977 #[test]
6982 fn the_rendered_definitions_are_all_least_privilege() {
6983 let linux = linux_plan(StartMode::Boot);
6984 let windows = windows_plan(StartMode::Boot);
6985 for (definition, plan) in [
6986 (ServiceDefinition::systemd(&linux, None), &linux),
6987 (ServiceDefinition::launchd(&linux, None), &linux),
6988 (ServiceDefinition::windows_service(&windows), &windows),
6989 ] {
6990 let review = review_least_privilege(&definition, plan);
6991 assert!(
6992 review.is_least_privilege(),
6993 "{:?} should be least privilege, got:\n{review}",
6994 definition.kind()
6995 );
6996 assert!(
6997 !review.controls().is_empty(),
6998 "a review that confirms nothing proves nothing: {:?}",
6999 definition.kind()
7000 );
7001 }
7002 }
7003
7004 #[test]
7005 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
7006 let plan = windows_plan(StartMode::Login);
7007 let definition =
7008 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
7009 let review = review_least_privilege(&definition, &plan);
7010 assert!(review.is_least_privilege(), "{review}");
7011 assert!(
7012 review
7013 .controls()
7014 .iter()
7015 .any(|control| control.contains("LeastPrivilege")),
7016 "{review}"
7017 );
7018 }
7019
7020 #[test]
7021 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
7022 let plan = linux_plan(StartMode::Boot);
7023 let rendered = systemd_unit(&plan);
7024 let widened = edited(
7025 &rendered,
7026 "ReadWritePaths=/var/lib/runner-manager/config",
7027 "ReadWritePaths=/etc /var/lib/runner-manager/config",
7028 );
7029 let review = review_least_privilege(
7030 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7031 &plan,
7032 );
7033 assert!(!review.is_least_privilege(), "{review}");
7034 assert!(
7035 review
7036 .excesses()
7037 .iter()
7038 .any(|finding| finding.detail.contains("/etc")),
7039 "the review must name the directory it objects to: {review}"
7040 );
7041 }
7042
7043 #[test]
7044 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
7045 let plan = linux_plan(StartMode::Boot);
7046 let rendered = systemd_unit(&plan);
7047 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
7048 let review = review_least_privilege(
7049 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
7050 &plan,
7051 );
7052 assert!(!review.is_least_privilege(), "{review}");
7053 assert!(
7054 review
7055 .excesses()
7056 .iter()
7057 .any(|finding| finding.subject == "NoNewPrivileges"),
7058 "{review}"
7059 );
7060 }
7061
7062 #[test]
7063 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
7064 let plan = linux_plan(StartMode::Boot);
7065 let rendered = systemd_unit(&plan);
7066 let widened = edited(
7067 &rendered,
7068 "CapabilityBoundingSet=\n",
7069 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
7070 );
7071 let review = review_least_privilege(
7072 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7073 &plan,
7074 );
7075 assert!(!review.is_least_privilege(), "{review}");
7076 assert!(
7077 review
7078 .excesses()
7079 .iter()
7080 .any(|finding| finding.subject == "CapabilityBoundingSet"),
7081 "{review}"
7082 );
7083 }
7084
7085 #[test]
7086 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
7087 let plan = linux_plan(StartMode::Boot);
7088 let rendered = systemd_unit(&plan);
7089 let widened = edited(
7090 &rendered,
7091 "[Install]",
7092 "ListenStream=127.0.0.1:9000\n\n[Install]",
7093 );
7094 let review = review_least_privilege(
7095 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7096 &plan,
7097 );
7098 assert!(
7099 !review.is_least_privilege(),
7100 "07-security.md rule 2 forbids any inbound surface: {review}"
7101 );
7102 }
7103
7104 #[test]
7105 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
7106 let plan = linux_plan(StartMode::Boot);
7107 let rendered = systemd_unit(&plan);
7108 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
7109 let review = review_least_privilege(
7110 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
7111 &plan,
7112 );
7113 assert!(
7114 review.is_least_privilege(),
7115 "too little authority is not an excess: {review}"
7116 );
7117 assert!(
7118 review
7119 .findings()
7120 .iter()
7121 .any(|finding| finding.kind == FindingKind::Shortfall
7122 && finding.detail.contains("runtime")),
7123 "{review}"
7124 );
7125 }
7126
7127 #[test]
7128 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
7129 let plan = linux_plan(StartMode::Login);
7130 let rendered = launchd_plist(&plan);
7131 let widened = edited(
7132 &rendered,
7133 "<key>ProcessType</key>",
7134 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
7135 );
7136 let review = review_least_privilege(
7137 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7138 &plan,
7139 );
7140 assert!(!review.is_least_privilege(), "{review}");
7141 assert!(
7142 review
7143 .excesses()
7144 .iter()
7145 .any(|finding| finding.subject == "UserName"),
7146 "{review}"
7147 );
7148 }
7149
7150 #[test]
7151 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
7152 let plan = linux_plan(StartMode::Boot);
7153 let rendered = launchd_plist(&plan);
7154 let widened = edited(
7155 &rendered,
7156 "<key>SessionCreate</key>\n <false/>",
7157 "<key>SessionCreate</key>\n <true/>",
7158 );
7159 let review = review_least_privilege(
7160 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7161 &plan,
7162 );
7163 assert!(!review.is_least_privilege(), "{review}");
7164 assert!(
7165 review
7166 .excesses()
7167 .iter()
7168 .any(|finding| finding.subject == "SessionCreate"),
7169 "{review}"
7170 );
7171 }
7172
7173 #[test]
7174 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
7175 let plan = linux_plan(StartMode::Boot);
7176 let rendered = launchd_plist(&plan);
7177 let widened = edited(
7178 &rendered,
7179 "<key>ProcessType</key>",
7180 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
7181 );
7182 let review = review_least_privilege(
7183 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7184 &plan,
7185 );
7186 assert!(!review.is_least_privilege(), "{review}");
7187 }
7188
7189 #[test]
7190 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
7191 let plan = windows_plan(StartMode::Login);
7192 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7193 let widened = edited(
7194 &rendered,
7195 "<RunLevel>LeastPrivilege</RunLevel>",
7196 "<RunLevel>HighestAvailable</RunLevel>",
7197 );
7198 let review = review_least_privilege(
7199 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7200 &plan,
7201 );
7202 assert!(!review.is_least_privilege(), "{review}");
7203 assert!(
7204 review
7205 .excesses()
7206 .iter()
7207 .any(|finding| finding.subject == "RunLevel"),
7208 "{review}"
7209 );
7210 }
7211
7212 #[test]
7213 fn a_task_that_would_store_a_password_is_not_least_privilege() {
7214 let plan = windows_plan(StartMode::Login);
7215 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7216 let widened = edited(
7217 &rendered,
7218 "<LogonType>InteractiveToken</LogonType>",
7219 "<LogonType>Password</LogonType>",
7220 );
7221 let review = review_least_privilege(
7222 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7223 &plan,
7224 );
7225 assert!(!review.is_least_privilege(), "{review}");
7226 }
7227
7228 #[test]
7229 fn an_interactive_windows_service_is_not_least_privilege() {
7230 let plan = windows_plan(StartMode::Boot);
7231 let rendered = windows_service_descriptor(&plan);
7232 let widened = edited(
7233 &rendered,
7234 "ServiceType=OWN_PROCESS",
7235 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
7236 );
7237 let review = review_least_privilege(
7238 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
7239 &plan,
7240 );
7241 assert!(!review.is_least_privilege(), "{review}");
7242 assert!(
7243 review
7244 .excesses()
7245 .iter()
7246 .any(|finding| finding.subject == "ServiceType"),
7247 "{review}"
7248 );
7249 }
7250
7251 #[test]
7252 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
7253 let plan = windows_plan(StartMode::Boot);
7254 let rendered = windows_service_descriptor(&plan);
7255 let changed = edited(
7256 &rendered,
7257 "Account=NT AUTHORITY\\SYSTEM",
7258 "Account=NT AUTHORITY\\LocalService",
7259 );
7260 let review = review_least_privilege(
7261 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
7262 &plan,
7263 );
7264 assert!(review.is_least_privilege(), "{review}");
7270 assert!(
7271 review.findings().iter().any(|finding| {
7272 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
7273 }),
7274 "{review}"
7275 );
7276 }
7277
7278 #[test]
7283 fn a_recorded_path_that_is_still_there_is_current() {
7284 let host = Host::new();
7285 let state = inspect_binary(&host.binary, Some(&host.binary));
7286 assert!(!state.is_error(), "{state}");
7287 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
7288 }
7289
7290 #[test]
7291 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
7292 let host = Host::new();
7293 let recorded = host.binary.clone();
7297 let healthy = inspect_binary(&recorded, Some(&recorded));
7298 assert!(
7299 !healthy.is_error(),
7300 "the discriminator: before the binary moves, this must be healthy"
7301 );
7302
7303 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
7304
7305 let state = inspect_binary(&recorded, Some(&recorded));
7306 assert!(state.is_error(), "{state}");
7307 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
7308 assert!(
7309 state.to_string().contains("npm"),
7310 "the message must name the cause an operator will not otherwise connect: {state}"
7311 );
7312 }
7313
7314 #[test]
7315 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
7316 let root = tempfile::tempdir().expect("a temporary directory");
7317 let state = inspect_binary(root.path(), None);
7318 assert!(state.is_error(), "{state}");
7319 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
7320 }
7321
7322 #[test]
7323 fn a_registration_naming_a_different_binary_is_a_divergence() {
7324 let host = Host::new();
7325 let other = host.binary.with_file_name("something-else");
7326 let state = inspect_binary(&host.binary, Some(&other));
7327 assert!(state.is_error(), "{state}");
7328 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
7329 }
7330
7331 #[test]
7332 fn absence_is_reported_before_divergence() {
7333 let host = Host::new();
7334 let recorded = host.binary.clone();
7335 std::fs::remove_file(&recorded).expect("removable");
7336 let other = recorded.with_file_name("something-else");
7337 assert!(matches!(
7341 inspect_binary(&recorded, Some(&other)),
7342 BinaryPath::Missing { .. }
7343 ));
7344 }
7345
7346 #[test]
7351 fn the_record_round_trips_through_toml() {
7352 let host = Host::new();
7353 let plan = InstallPlan::resolve(
7354 ServiceIdentity::product(),
7355 &host.request(StartMode::Boot),
7356 ServiceDirectories::of(&host.paths),
7357 )
7358 .expect("a resolvable plan");
7359 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7360 let record = InstallRecord::of(&plan, &definition, Utc::now());
7361 record.write(&host.paths).expect("a writable record");
7362 let read = InstallRecord::read(&host.paths)
7363 .expect("a readable record")
7364 .expect("a record is there");
7365 assert_eq!(read, record);
7366 assert_eq!(read.binary, host.binary);
7367 assert!(read.binary.is_absolute());
7368 }
7369
7370 #[cfg(unix)]
7379 #[test]
7380 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
7381 use std::os::unix::fs::PermissionsExt as _;
7382
7383 let host = Host::new();
7384 let plan = InstallPlan::resolve(
7385 ServiceIdentity::product(),
7386 &host.request(StartMode::Boot),
7387 ServiceDirectories::of(&host.paths),
7388 )
7389 .expect("a resolvable plan");
7390 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7391 InstallRecord::of(&plan, &definition, Utc::now())
7392 .write(&host.paths)
7393 .expect("a writable record");
7394
7395 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
7396 .expect("the record is there")
7397 .permissions()
7398 .mode()
7399 & 0o777;
7400 assert_eq!(
7401 mode, 0o644,
7402 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
7403 service install` wrote, and `service status` fails on their own host. It holds no \
7404 credential and sits in a 0700 directory, so 0644 discloses nothing"
7405 );
7406 }
7407
7408 #[test]
7412 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
7413 let host = Host::new();
7414 let path = InstallRecord::path(&host.paths);
7415 std::fs::write(
7416 &path,
7417 format!(
7418 "schema_version = {RECORD_SCHEMA_VERSION}
7419service_name = \"runner-manager\"
7420 manager = \"systemd\"
7421start_mode = \"boot\"
7422account = \"root\"
7423 binary = \"/x\"
7424arguments = []
7425restart_delay_secs = 15
7426 restart_reset_secs = 600
7427log_file = \"/x\"
7428 installed_at = \"2026-01-01T00:00:00Z\"
7429installed_by_version = \"0.1.0\"
7430 [directories]
7431config = \"/a\"
7432state = \"/b\"
7433runtime = \"/c\"
7434logs = \"/d\"
7435"
7436 ),
7437 )
7438 .expect("a writable record");
7439 let read = InstallRecord::read(&host.paths)
7440 .expect("a record missing an optional field is still readable")
7441 .expect("a record is there");
7442 assert_eq!(
7443 read.source_binary, None,
7444 "the legacy layout has no source, and must not invent one"
7445 );
7446 }
7447
7448 #[test]
7450 fn a_registration_remembers_the_file_it_was_copied_from() {
7451 let host = Host::new();
7452 let source = host.binary.with_file_name("npm-installed-runner-manager");
7453 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
7454 let plan = InstallPlan::resolve(
7455 ServiceIdentity::product(),
7456 &host.request(StartMode::Boot).copied_from(&source),
7457 ServiceDirectories::of(&host.paths),
7458 )
7459 .expect("a resolvable plan");
7460 let definition = ServiceDefinition::from_text(
7461 DefinitionKind::SystemdUnit,
7462 "[Service]
7463",
7464 );
7465 let record = InstallRecord::of(&plan, &definition, Utc::now());
7466 record.write(&host.paths).expect("a writable record");
7467
7468 let read = InstallRecord::read(&host.paths)
7469 .expect("a readable record")
7470 .expect("a record is there");
7471 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
7472 assert_ne!(
7473 read.source_binary.as_deref(),
7474 Some(read.binary.as_path()),
7475 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
7476 );
7477 }
7478
7479 #[test]
7480 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
7481 let host = Host::new();
7482 let path = InstallRecord::path(&host.paths);
7483 std::fs::write(
7484 &path,
7485 format!(
7486 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
7487 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
7488 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
7489 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
7490 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
7491 RECORD_SCHEMA_VERSION + 1
7492 ),
7493 )
7494 .expect("a writable record");
7495 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
7496 assert!(
7497 matches!(error, ServiceError::RecordUnreadable { .. }),
7498 "{error}"
7499 );
7500 assert!(
7501 error.to_string().contains("service uninstall"),
7502 "the message must say how to recover: {error}"
7503 );
7504 }
7505
7506 #[test]
7507 fn no_record_is_not_an_error() {
7508 let host = Host::new();
7509 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
7510 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
7511 }
7512
7513 #[test]
7518 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
7519 let host = Host::new();
7520 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
7521 }
7522
7523 #[test]
7524 fn the_heartbeat_round_trips_to_the_second() {
7525 let host = Host::new();
7526 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
7527 .expect("a valid timestamp")
7528 .with_timezone(&Utc);
7529 record_github_contact(&host.paths, at).expect("a writable heartbeat");
7530 assert_eq!(
7531 last_github_contact(&host.paths).expect("readable"),
7532 Some(at)
7533 );
7534 }
7535
7536 #[test]
7537 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
7538 let host = Host::new();
7539 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
7540 let error = last_github_contact(&host.paths)
7541 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
7542 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7543 }
7544
7545 #[test]
7551 fn a_runner_root_refusal_round_trips_for_service_status() {
7552 let host = Host::new();
7553 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
7554
7555 assert!(
7556 runner_root_refusals(&host.paths)
7557 .expect("readable")
7558 .is_empty(),
7559 "no record means every policy is placing runners"
7560 );
7561
7562 record_runner_root_refusal(
7563 &host.paths,
7564 "policy-a",
7565 at,
7566 "denied_by_privacy_policy",
7567 "/Volumes/NVME/runners",
7568 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
7569 )
7570 .expect("a writable record");
7571
7572 let refusals = runner_root_refusals(&host.paths).expect("readable");
7573 assert_eq!(refusals.len(), 1);
7574 assert_eq!(refusals[0].policy, "policy-a");
7575 assert_eq!(refusals[0].at, at);
7576 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
7577 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
7578 assert!(
7579 refusals[0].detail.contains("/Volumes/NVME/runners")
7580 && refusals[0].detail.contains("Full Disk Access"),
7581 "the path and the remediation are the whole point of this file: {refusals:?}"
7582 );
7583 }
7584
7585 #[test]
7593 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
7594 let host = Host::new();
7595 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
7596 record_runner_root_refusal(
7597 &host.paths,
7598 "broken",
7599 at,
7600 "denied_by_privacy_policy",
7601 "/Volumes/NVME/runners",
7602 "detail",
7603 )
7604 .expect("a writable record");
7605 record_runner_root_refusal(
7606 &host.paths,
7607 "also-broken",
7608 at,
7609 "not_writable",
7610 "/srv/other",
7611 "detail",
7612 )
7613 .expect("a writable record");
7614
7615 clear_runner_root_refusal(&host.paths, "healthy")
7617 .expect("clearing an absent policy is fine");
7618 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
7619
7620 let refusals = runner_root_refusals(&host.paths).expect("readable");
7621 assert_eq!(
7622 refusals
7623 .iter()
7624 .map(|r| r.policy.as_str())
7625 .collect::<Vec<_>>(),
7626 vec!["broken"],
7627 "the policy that is still refused must keep its record"
7628 );
7629 }
7630
7631 #[test]
7634 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
7635 let host = Host::new();
7636 record_runner_root_refusal(
7637 &host.paths,
7638 "p",
7639 Utc::now(),
7640 "not_writable",
7641 "/srv/x",
7642 "detail",
7643 )
7644 .expect("a writable record");
7645
7646 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
7647 assert!(
7648 !root_refusal_path(&host.paths).exists(),
7649 "a host with nothing refused leaves nothing behind"
7650 );
7651 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
7652 }
7653
7654 #[test]
7657 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
7658 let host = Host::new();
7659 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
7660 let error = runner_root_refusals(&host.paths)
7661 .expect_err("an unparseable record is not the same as no record");
7662 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7663 }
7664
7665 #[test]
7675 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
7676 let host = Host::new();
7677 record_runner_root_refusal(
7678 &host.paths,
7679 "policy-a",
7680 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
7681 "denied_by_privacy_policy",
7682 "/Volumes/NVME/runners",
7683 "Grant Full Disk Access to the program that runs the service",
7684 )
7685 .expect("a writable record");
7686
7687 let status = host.operations().status().expect("a readable status");
7688 let notes = status.notes().join("\n");
7689
7690 assert!(
7691 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
7692 "the directory and the remediation the log had to scrub must appear here: {notes}"
7693 );
7694 assert!(
7695 notes.contains("policy-a"),
7696 "the operator has to know which target placed no runner: {notes}"
7697 );
7698 assert!(
7699 !status
7700 .problems()
7701 .iter()
7702 .any(|problem| problem.subject == "runner root"),
7703 "a record nothing an operator types can clear must not drive the exit code"
7704 );
7705 }
7706
7707 #[test]
7713 fn install_records_the_absolute_binary_path_and_the_four_directories() {
7714 let host = Host::new();
7715 let installed = host
7716 .operations()
7717 .install(&host.request(StartMode::Boot))
7718 .expect("an install against the recording controls");
7719
7720 assert_eq!(installed.record.binary, host.binary);
7721 assert!(installed.record.binary.is_absolute());
7722 assert_eq!(installed.record.start_mode, StartMode::Boot);
7723 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
7724 assert_eq!(
7725 installed.record.directories,
7726 ServiceDirectories::of(&host.paths)
7727 );
7728 assert_eq!(
7729 installed.record.log_file,
7730 host.paths.logs_dir().join(LOG_FILE_STEM)
7731 );
7732 assert_eq!(installed.record.restart_delay_secs, 15);
7733
7734 let registrations = host.controls.registrations();
7735 assert_eq!(registrations.len(), 1);
7736 assert_eq!(registrations[0].0, StartMode::Boot);
7737 assert_eq!(registrations[0].1, "runner-manager");
7738 }
7739
7740 #[test]
7741 fn install_is_refused_while_the_single_instance_lock_is_held() {
7742 let host = Host::new();
7743 {
7745 let operations = host.operations();
7746 operations
7747 .install(&host.request(StartMode::Boot))
7748 .expect("an install with the lock free");
7749 operations.uninstall().expect("a clean slate");
7750 }
7751
7752 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
7753 .expect("this process takes the lock first");
7754
7755 let error = host
7756 .operations()
7757 .install(&host.request(StartMode::Boot))
7758 .expect_err("a second agent must not be registered while one is running");
7759 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
7760 assert!(
7761 error.to_string().contains("already running"),
7762 "the message must be actionable: {error}"
7763 );
7764 assert!(
7765 host.controls.registrations().is_empty(),
7766 "a refused install must register nothing"
7767 );
7768 assert_eq!(
7769 InstallRecord::read(&host.paths).expect("readable"),
7770 None,
7771 "a refused install must write no record"
7772 );
7773 }
7774
7775 #[test]
7783 fn installing_over_the_same_start_mode_replaces_the_registration() {
7784 let host = Host::new();
7785 let operations = host.operations();
7786 operations
7787 .install(&host.request(StartMode::Boot))
7788 .expect("the first install");
7789
7790 let again = operations
7791 .install(&host.request(StartMode::Boot))
7792 .expect("an install over the same mode replaces rather than refusing");
7793
7794 assert!(
7795 again.replaced_existing,
7796 "the operator is told this replaced something rather than made it"
7797 );
7798 assert_eq!(
7799 host.controls.registrations().len(),
7800 1,
7801 "replacing must not leave two registrations behind"
7802 );
7803 assert_eq!(
7804 InstallRecord::read(&host.paths)
7805 .expect("readable")
7806 .expect("a record")
7807 .start_mode,
7808 StartMode::Boot
7809 );
7810 }
7811
7812 #[test]
7816 fn installing_over_the_other_start_mode_is_refused() {
7817 let host = Host::new();
7818 let operations = host.operations();
7819 operations
7820 .install(&host.request(StartMode::Boot))
7821 .expect("the first install");
7822
7823 let error = operations
7824 .install(&host.request(StartMode::Login))
7825 .expect_err("a mode change is not an install");
7826 assert!(
7827 matches!(
7828 error,
7829 ServiceError::AlreadyInstalled {
7830 existing: StartMode::Boot,
7831 requested: StartMode::Login,
7832 ..
7833 }
7834 ),
7835 "{error}"
7836 );
7837 assert!(
7838 !error
7839 .to_string()
7840 .contains("switch the start mode in place,"),
7841 "the old remedy named a capability no command offers; the terminal UI is where \
7842 the start mode moves: {error}"
7843 );
7844 assert_eq!(host.controls.registrations().len(), 1);
7845 }
7846
7847 #[test]
7848 fn install_rolls_back_the_registration_when_record_persistence_fails() {
7849 let host = Host::new();
7850 let record_path = InstallRecord::path(&host.paths);
7851 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
7852
7853 let error = host
7854 .operations()
7855 .install(&host.request(StartMode::Boot))
7856 .expect_err("record persistence must fail");
7857
7858 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7859 assert!(
7860 host.controls.registrations().is_empty(),
7861 "a failed install must not leave a live unrecorded registration"
7862 );
7863 assert!(
7864 host.controls
7865 .calls()
7866 .iter()
7867 .any(|call| call == "uninstall runner-manager (boot)"),
7868 "the registration must be explicitly rolled back: {:?}",
7869 host.controls.calls()
7870 );
7871 assert!(
7872 !host.runner_root.as_path().exists(),
7873 "the rollback must take the runner root this install created with it; a directory \
7874 prepared for a registration that does not exist is litter, and on Windows it is \
7875 litter with a security descriptor"
7876 );
7877 }
7878
7879 #[test]
7884 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
7885 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
7886
7887 assert_eq!(
7891 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
7892 ServiceAccount::LocalSystem
7893 );
7894 assert_eq!(
7895 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
7896 ServiceAccount::InvokingUser
7897 );
7898
7899 let boot = default_root_sddl(&RootAdmission::LocalSystem);
7900 assert!(!grants_broad_write(&boot), "{boot}");
7901 assert!(
7902 !boot.contains("S-1-5-21"),
7903 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
7904 );
7905
7906 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
7911 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
7912 assert!(!grants_broad_write(&login), "{login}");
7913 }
7914
7915 #[test]
7916 fn an_install_reports_the_runner_root_it_prepared() {
7917 let host = Host::new();
7918 let installed = host
7919 .operations()
7920 .install(&host.request(StartMode::Boot))
7921 .expect("an install");
7922 let rendered = installed.runner_root.to_string();
7923 assert!(
7924 !rendered.contains("S-1-5-21"),
7925 "the report must add no identity to the output: {rendered}"
7926 );
7927 if cfg!(windows) {
7928 assert_eq!(
7929 installed.runner_root.path(),
7930 Some(host.runner_root.as_path())
7931 );
7932 assert!(
7933 host.runner_root.as_path().is_dir(),
7934 "the directory jobs would run in has to exist once the service is registered"
7935 );
7936 } else {
7937 assert_eq!(
7938 installed.runner_root,
7939 crate::runner_root_access::RootAccessSummary::NotApplicable,
7940 "macOS and Linux keep the runtime directory they have always used"
7941 );
7942 }
7943 }
7944
7945 #[test]
7946 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
7947 let host = Host::new();
7948 let operations = host.operations();
7949 operations
7950 .install(&host.request(StartMode::Boot))
7951 .expect("an install at boot");
7952
7953 let change = operations
7954 .set_start_mode(StartMode::Login)
7955 .expect("a switch to login");
7956
7957 assert!(change.changed);
7958 if cfg!(windows) {
7959 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
7960 assert!(
7961 host.runner_root.as_path().is_dir(),
7962 "the switch must not remove the directory it reconciled"
7963 );
7964 }
7965 assert!(
7968 change.to_string().contains("runner root"),
7969 "{}",
7970 change.to_string()
7971 );
7972 }
7973
7974 #[test]
7975 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
7976 let host = Host::new();
7977 let operations = host.operations();
7978 operations
7979 .install(&host.request(StartMode::Boot))
7980 .expect("an install at boot");
7981
7982 let change = operations
7983 .set_start_mode(StartMode::Boot)
7984 .expect("a switch to the mode already in force");
7985
7986 assert!(!change.changed);
7987 assert_eq!(
7988 change.runner_root,
7989 crate::runner_root_access::RootAccessSummary::NotApplicable,
7990 "nothing moves, so nothing about the root's access control has to; reconciling here \
7991 would turn a no-op command into one that can fail on a permission it does not need"
7992 );
7993 }
7994
7995 #[cfg(windows)]
7996 #[test]
7997 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
7998 let host = Host::new();
7999 host.controls
8000 .fail_next_install(StartMode::Boot, "injected registration failure");
8001
8002 let error = host
8003 .operations()
8004 .install(&host.request(StartMode::Boot))
8005 .expect_err("the manager refuses the registration");
8006
8007 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8008 assert!(
8009 !host.runner_root.as_path().exists(),
8010 "the directory was created for a registration that does not exist"
8011 );
8012 }
8013
8014 #[cfg(windows)]
8015 #[test]
8016 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
8017 let host = Host::new();
8018 crate::runner_root_access::create_with_descriptor_for_tests(
8022 host.runner_root.as_path(),
8023 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
8024 )
8025 .expect("a deliberately open runner root");
8026 let before = crate::runner_root_access::report(host.runner_root.as_path());
8027
8028 let error = host
8029 .operations()
8030 .install(&host.request(StartMode::Boot))
8031 .expect_err("an open runner root is refused");
8032
8033 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
8034 assert!(
8035 error.to_string().contains("nothing was registered"),
8036 "{error}"
8037 );
8038 assert!(
8039 host.controls.registrations().is_empty(),
8040 "the refusal has to come before the platform is asked to register anything: {:?}",
8041 host.controls.calls()
8042 );
8043 assert_eq!(
8044 crate::runner_root_access::report(host.runner_root.as_path()),
8045 before,
8046 "an open directory is refused rather than tightened: its contents cannot be trusted, \
8047 so adopting it would be worse than declining it"
8048 );
8049 }
8050
8051 #[cfg(windows)]
8052 #[test]
8053 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
8054 let host = Host::new();
8055 let operations = host.operations();
8056 operations
8057 .install(&host.request(StartMode::Boot))
8058 .expect("an install");
8059 assert!(host.runner_root.as_path().is_dir());
8060
8061 operations.uninstall().expect("an uninstall");
8062
8063 assert!(
8064 host.runner_root.as_path().is_dir(),
8065 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
8066 runner root may hold an operator's retained workspaces."
8067 );
8068 }
8069
8070 #[test]
8071 fn install_reviews_what_it_registered() {
8072 let host = Host::new();
8073 let installed = host
8074 .operations()
8075 .install(&host.request(StartMode::Boot))
8076 .expect("an install");
8077 assert!(
8078 installed.review.is_least_privilege(),
8079 "{}",
8080 installed.review
8081 );
8082 assert!(
8083 !installed.review.controls().is_empty(),
8084 "a review that confirms nothing proves nothing: {}",
8085 installed.review
8086 );
8087 assert_eq!(
8088 installed.review.kind(),
8089 host_definition_kind(StartMode::Boot),
8090 "the review must be of the definition this host's manager was given"
8091 );
8092 assert!(
8093 !installed.review.account().justification().is_empty(),
8094 "a privileged account with no stated reason is an unreviewed one"
8095 );
8096 }
8097
8098 #[test]
8103 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
8104 let host = Host::new();
8105 let operations = host.operations();
8106 operations
8107 .install(&host.request(StartMode::Boot))
8108 .expect("an install");
8109
8110 let config = host.paths.config_dir();
8114 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
8115 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
8116 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
8117 std::fs::write(
8118 host.paths
8119 .state_dir()
8120 .join("packages/2.330.0/runner.tar.gz"),
8121 b"cached package",
8122 )
8123 .expect("writable");
8124 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
8125 std::fs::write(
8126 host.paths.state_dir().join("secrets/user-access-token"),
8127 b"a stand-in for the stored credential",
8128 )
8129 .expect("writable");
8130 std::fs::write(
8131 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
8132 b"diagnostics",
8133 )
8134 .expect("writable");
8135
8136 let roots: Vec<PathBuf> = host
8137 .paths
8138 .all()
8139 .iter()
8140 .map(|(_, path)| (*path).to_path_buf())
8141 .collect();
8142 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
8143 let before = snapshot(&roots);
8144
8145 assert!(
8148 before.len() >= 6,
8149 "the fixture must actually contain the files this test is about, got {before:#?}"
8150 );
8151 let record_path = InstallRecord::path(&host.paths);
8152 assert!(
8153 before.contains_key(&record_path),
8154 "the install record must be present before uninstall"
8155 );
8156
8157 let uninstalled = operations.uninstall().expect("an uninstall");
8158 assert!(uninstalled.removed_registration);
8159 assert!(uninstalled.removed_record);
8160
8161 let after = snapshot(&roots);
8162
8163 let mut expected = before.clone();
8165 expected.remove(&record_path);
8166 assert_eq!(
8167 after, expected,
8168 "uninstall must remove its own record and nothing else"
8169 );
8170 assert!(
8171 !record_path.exists(),
8172 "the record itself must go, or `uninstall` did nothing at all"
8173 );
8174 assert!(
8175 uninstalled
8176 .preserved
8177 .iter()
8178 .all(|path| roots.contains(&path.as_path())),
8179 "the preserved list must name the four directories: {uninstalled}"
8180 );
8181 }
8182
8183 #[test]
8184 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
8185 let host = Host::new();
8186 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
8187 assert!(!uninstalled.removed_registration);
8188 assert!(!uninstalled.removed_record);
8189 }
8190
8191 #[test]
8192 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
8193 let host = Host::new();
8194 let operations = host.operations();
8195 operations
8196 .install(&host.request(StartMode::Boot))
8197 .expect("an install");
8198 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
8199
8200 let uninstalled = operations.uninstall().expect("an uninstall");
8201 assert!(
8202 uninstalled.removed_registration,
8203 "a lost record must not strand a registration"
8204 );
8205 assert!(host.controls.registrations().is_empty());
8206 }
8207
8208 #[test]
8213 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
8214 let host = Host::new();
8215 let operations = host.operations();
8216 operations
8217 .install(&host.request(StartMode::Boot))
8218 .expect("an install at boot");
8219
8220 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
8224
8225 let change = operations
8226 .set_start_mode(StartMode::Login)
8227 .expect("a switch that does not reinstall the product");
8228 assert!(change.changed);
8229 assert_eq!(change.from, StartMode::Boot);
8230 assert_eq!(change.to, StartMode::Login);
8231 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
8232
8233 let record = InstallRecord::read(&host.paths)
8234 .expect("readable")
8235 .expect("a record");
8236 assert_eq!(record.start_mode, StartMode::Login);
8237 assert_eq!(
8238 record.binary, host.binary,
8239 "the recorded path must survive the switch untouched"
8240 );
8241
8242 let registrations = host.controls.registrations();
8243 assert_eq!(registrations.len(), 1, "{registrations:?}");
8244 assert_eq!(registrations[0].0, StartMode::Login);
8245 assert!(
8246 registrations[0]
8247 .2
8248 .command_line
8249 .contains(&host.binary.to_string_lossy().into_owned()),
8250 "{:?}",
8251 registrations[0].2
8252 );
8253 }
8254
8255 #[test]
8256 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
8257 let host = Host::new();
8258 let operations = host.operations();
8259 operations
8260 .install(&host.request(StartMode::Boot))
8261 .expect("an install at boot");
8262 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8263 host.controls
8264 .fail_next_install(StartMode::Login, "injected target failure");
8265
8266 let error = operations
8267 .set_start_mode(StartMode::Login)
8268 .expect_err("the target manager refuses the install");
8269
8270 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8271 assert_eq!(
8272 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8273 record_before
8274 );
8275 let registrations = host.controls.registrations();
8276 assert_eq!(registrations.len(), 1, "{registrations:?}");
8277 assert_eq!(registrations[0].0, StartMode::Boot);
8278 }
8279
8280 #[test]
8281 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
8282 let host = Host::new();
8283 let operations = host.operations();
8284 operations
8285 .install(&host.request(StartMode::Boot))
8286 .expect("an install at boot");
8287 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8288 let config = host.paths.config_dir().to_path_buf();
8289 let hidden = config.with_file_name("config-hidden-by-fault");
8290 host.controls.hide_directory_after_install(
8291 StartMode::Login,
8292 config.clone(),
8293 hidden.clone(),
8294 );
8295
8296 let error = operations
8297 .set_start_mode(StartMode::Login)
8298 .expect_err("the injected filesystem fault prevents persistence");
8299
8300 std::fs::remove_file(&config).expect("remove the injected blocker");
8301 std::fs::rename(&hidden, &config).expect("restore the record directory");
8302 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8303 assert_eq!(
8304 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8305 record_before
8306 );
8307 let registrations = host.controls.registrations();
8308 assert_eq!(registrations.len(), 1, "{registrations:?}");
8309 assert_eq!(registrations[0].0, StartMode::Boot);
8310 assert!(
8311 host.controls
8312 .calls()
8313 .iter()
8314 .any(|call| call == "uninstall runner-manager (login)"),
8315 "the target must be rolled back: {:?}",
8316 host.controls.calls()
8317 );
8318 }
8319
8320 #[test]
8321 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
8322 let host = Host::new();
8323 let operations = host.operations();
8324 operations
8325 .install(&host.request(StartMode::Boot))
8326 .expect("an install");
8327 let before = host.controls.calls().len();
8328
8329 let change = operations
8330 .set_start_mode(StartMode::Boot)
8331 .expect("a no-op switch");
8332 assert!(!change.changed);
8333 assert_eq!(
8334 host.controls.calls().len(),
8335 before,
8336 "a no-op switch must not touch the service manager"
8337 );
8338 }
8339
8340 #[test]
8341 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
8342 let host = Host::new();
8343 let error = host
8344 .operations()
8345 .set_start_mode(StartMode::Login)
8346 .expect_err("there is nothing to switch");
8347 assert!(
8348 matches!(error, ServiceError::NotInstalled { .. }),
8349 "{error}"
8350 );
8351 }
8352
8353 #[test]
8358 fn status_reports_the_four_facts_journey_five_asks_for() {
8359 let host = Host::new();
8360 let operations = host.operations();
8361 operations
8362 .install(&host.request(StartMode::Boot))
8363 .expect("an install");
8364 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
8365 .expect("a valid timestamp")
8366 .with_timezone(&Utc);
8367 record_github_contact(&host.paths, at).expect("a heartbeat");
8368
8369 let status = operations.status().expect("a status");
8370 assert_eq!(status.start_mode(), Some(StartMode::Boot));
8371 assert_eq!(
8372 status.binary().map(BinaryPath::recorded),
8373 Some(host.binary.as_path())
8374 );
8375 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
8376 assert_eq!(status.last_github_contact(), Some(at));
8377 assert!(status.is_installed());
8378 assert!(status.is_healthy(), "{status}");
8379
8380 let printed = status.to_string();
8381 for fragment in [
8382 "start mode",
8383 "diagnostic log",
8384 "last GitHub contact",
8385 "binary",
8386 ] {
8387 assert!(printed.contains(fragment), "{printed}");
8388 }
8389 }
8390
8391 #[cfg(unix)]
8403 #[test]
8404 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
8405 use std::os::unix::fs::PermissionsExt as _;
8406
8407 if unsafe { libc::geteuid() } == 0 {
8411 return;
8412 }
8413
8414 let host = Host::new();
8415 let operations = host.operations();
8416 operations
8417 .install(&host.request(StartMode::Boot))
8418 .expect("an install");
8419 assert!(
8420 operations.status().expect("a status").is_healthy(),
8421 "the discriminator: healthy before the record is made unreadable"
8422 );
8423
8424 let record = InstallRecord::path(&host.paths);
8425 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
8426 .expect("the mode is applied");
8427
8428 let status = operations
8429 .status()
8430 .expect("a record this account may not read is reported, not thrown");
8431 assert!(status.is_installed(), "{status}");
8432 assert!(!status.is_healthy(), "{status}");
8433
8434 let printed = status.to_string();
8435 assert!(
8436 printed.contains("this account may not read it"),
8437 "the operator is told which of the two states this is: {printed}"
8438 );
8439 assert!(
8440 !printed.contains("there is no install record"),
8441 "a record that is there and unreadable is not a record that is missing, and the \
8442 missing one's remedy starts with `service uninstall`: {printed}"
8443 );
8444
8445 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
8448 .expect("the mode is restored");
8449 }
8450
8451 #[test]
8452 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
8453 let host = Host::new();
8454 let operations = host.operations();
8455 operations
8456 .install(&host.request(StartMode::Boot))
8457 .expect("an install");
8458
8459 assert!(
8462 operations.status().expect("a status").is_healthy(),
8463 "the freshly installed host must be healthy"
8464 );
8465
8466 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
8467
8468 let status = operations.status().expect("a status");
8469 assert!(!status.is_healthy(), "{status}");
8470 assert!(
8471 status
8472 .problems()
8473 .iter()
8474 .any(|problem| problem.subject == "binary"),
8475 "{status}"
8476 );
8477 assert!(status.to_string().contains("STALE"), "{status}");
8478 }
8479
8480 #[test]
8481 fn status_reports_a_registration_that_would_not_start_at_boot() {
8482 let host = Host::new();
8483 let operations = host.operations();
8484 operations
8485 .install(&host.request(StartMode::Boot))
8486 .expect("an install");
8487 assert!(operations.status().expect("a status").is_healthy());
8488
8489 host.controls.edit("runner-manager", |registration| {
8490 registration.starts_automatically = false;
8491 });
8492
8493 let status = operations.status().expect("a status");
8494 assert!(!status.is_healthy(), "{status}");
8495 assert!(
8496 status
8497 .problems()
8498 .iter()
8499 .any(|problem| problem.detail.contains("after a reboot")),
8500 "{status}"
8501 );
8502 }
8503
8504 #[test]
8505 fn status_reports_a_restart_policy_something_else_edited() {
8506 let host = Host::new();
8507 let operations = host.operations();
8508 operations
8509 .install(&host.request(StartMode::Boot))
8510 .expect("an install");
8511 assert!(operations.status().expect("a status").is_healthy());
8512
8513 host.controls.edit("runner-manager", |registration| {
8514 registration.restart_delay = Some(Duration::from_secs(1));
8515 });
8516
8517 let status = operations.status().expect("a status");
8518 assert!(!status.is_healthy(), "{status}");
8519 assert!(
8520 status
8521 .problems()
8522 .iter()
8523 .any(|problem| problem.subject == "restart policy"),
8524 "{status}"
8525 );
8526 }
8527
8528 #[test]
8529 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
8530 let host = Host::new();
8531 let operations = host.operations();
8532 operations
8533 .install(&host.request(StartMode::Boot))
8534 .expect("an install");
8535 let other = host.binary.with_file_name("someone-elses.exe");
8536 std::fs::write(&other, b"x").expect("writable");
8537
8538 host.controls.edit("runner-manager", |registration| {
8539 registration.command_line = quote_argument(&other.to_string_lossy());
8540 });
8541
8542 let status = operations.status().expect("a status");
8543 assert!(!status.is_healthy(), "{status}");
8544 assert!(
8545 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
8546 "{status}"
8547 );
8548 }
8549
8550 #[test]
8551 fn status_reports_a_record_no_service_manager_knows_about() {
8552 let host = Host::new();
8553 let operations = host.operations();
8554 operations
8555 .install(&host.request(StartMode::Boot))
8556 .expect("an install");
8557 for mode in [StartMode::Boot, StartMode::Login] {
8559 host.controls
8560 .control(mode)
8561 .expect("a control")
8562 .uninstall(&ServiceIdentity::product())
8563 .expect("removed");
8564 }
8565
8566 let status = operations.status().expect("a status");
8567 assert!(!status.is_healthy(), "{status}");
8568 assert!(
8569 status
8570 .problems()
8571 .iter()
8572 .any(|problem| problem.subject == "registration"),
8573 "{status}"
8574 );
8575 }
8576
8577 #[test]
8578 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
8579 let host = Host::new();
8580 let status = host.operations().status().expect("a status");
8581 assert!(!status.is_installed());
8582 assert!(
8583 status.is_healthy(),
8584 "a host that never installed the service has no fault to report: {status}"
8585 );
8586 assert!(status.to_string().contains("installed"), "{status}");
8587 }
8588
8589 #[test]
8590 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
8591 let host = Host::new();
8592 let operations = host.operations();
8593 operations
8594 .install(&host.request(StartMode::Login))
8595 .expect("an install at login");
8596 let status = operations.status().expect("a status");
8597 assert!(
8598 status
8599 .notes()
8600 .iter()
8601 .any(|note| note.contains("does not run until the operator signs in")),
8602 "05-infrastructure.md requires `service status` to say so: {status}"
8603 );
8604 }
8605
8606 #[test]
8611 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
8612 let host = Host::new();
8613 let operations = host.operations();
8614 operations
8615 .install(&host.request(StartMode::Login))
8616 .expect("an install at login");
8617 operations.start().expect("a start");
8618 assert!(operations.status().expect("a status").is_running());
8619 assert!(operations.stop().expect("a stop"));
8620 assert!(!operations.status().expect("a status").is_running());
8621 }
8622
8623 #[test]
8624 fn starting_a_host_with_no_registration_is_refused() {
8625 let host = Host::new();
8626 let error = host.operations().start().expect_err("nothing to start");
8627 assert!(
8628 matches!(error, ServiceError::NotInstalled { .. }),
8629 "{error}"
8630 );
8631 }
8632
8633 #[cfg(windows)]
8649 #[test]
8650 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
8651 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
8652
8653 let root = tempfile::tempdir().expect("a temporary directory");
8654 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
8655 .expect("a rooted machine-scoped store");
8656 store
8657 .store(&secrecy::SecretString::from("a stand-in for the token"))
8658 .expect("the store accepts a value");
8659 let protection = store.protection().expect("the DACL can be read back");
8660
8661 assert!(
8662 protection.description().contains(";;;SY)"),
8663 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
8664 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
8665 protection.description()
8666 );
8667 assert_eq!(
8668 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
8669 ServiceAccount::LocalSystem,
8670 "and that is the account this installer registers, which is why SY is what matters"
8671 );
8672 assert!(
8673 !protection.readable_by_other_local_users(),
8674 "the same DACL must still exclude ordinary local users: {}",
8675 protection.description()
8676 );
8677
8678 for rejected in [";;;LS)", ";;;NS)"] {
8682 assert!(
8683 !protection.description().contains(rejected),
8684 "if the store ever admitted {rejected}, the least-privilege analysis in \
8685 docs/service-account.md would need redoing: {}",
8686 protection.description()
8687 );
8688 }
8689 }
8690}