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("KillMode=process\n");
1541 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1542 out.push_str(&format!(
1543 "WorkingDirectory={}\n",
1544 directories.state.display()
1545 ));
1546 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1547 out.push_str("Restart=on-failure\n");
1548 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1549
1550 if plan.start_mode() == StartMode::Boot
1554 && let Some(guard) = plan.secret_guard()
1555 {
1556 out.push_str(&format!(
1557 "LoadCredential={}:{}\n",
1558 crate::secrets::SYSTEMD_CREDENTIAL,
1559 guard.display()
1560 ));
1561 }
1562
1563 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1564 for directive in SYSTEMD_HARDENING {
1565 out.push_str(directive);
1566 out.push('\n');
1567 }
1568 out.push_str(&format!(
1569 "ReadWritePaths={}\n",
1570 directories
1571 .all()
1572 .iter()
1573 .map(|path| quote_argument(&path.to_string_lossy()))
1574 .collect::<Vec<_>>()
1575 .join(" ")
1576 ));
1577
1578 out.push_str("\n[Install]\n");
1579 out.push_str(match plan.start_mode() {
1580 StartMode::Boot => "WantedBy=multi-user.target\n",
1581 StartMode::Login => "WantedBy=default.target\n",
1582 });
1583 out
1584}
1585
1586pub const SYSTEMD_HARDENING: [&str; 13] = [
1593 "NoNewPrivileges=yes",
1594 "CapabilityBoundingSet=",
1595 "AmbientCapabilities=",
1596 "PrivateTmp=yes",
1597 "PrivateDevices=yes",
1598 "ProtectSystem=strict",
1599 "ProtectKernelTunables=yes",
1600 "ProtectKernelModules=yes",
1601 "ProtectControlGroups=yes",
1602 "RestrictNamespaces=yes",
1603 "RestrictRealtime=yes",
1604 "RestrictSUIDSGID=yes",
1605 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1606];
1607
1608#[must_use]
1617pub fn launchd_plist(plan: &InstallPlan) -> String {
1618 let identity = plan.identity();
1619 let directories = plan.directories();
1620 let mut out = String::new();
1621 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1622 out.push_str(
1623 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1624 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1625 );
1626 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1627 out.push_str(&plist_string("Label", &identity.launchd_label()));
1628
1629 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1630 out.push_str(&format!(
1631 " <string>{}</string>\n",
1632 xml_escape(&plan.binary().to_string_lossy())
1633 ));
1634 for argument in plan.arguments() {
1635 out.push_str(&format!(
1636 " <string>{}</string>\n",
1637 xml_escape(&argument.to_string_lossy())
1638 ));
1639 }
1640 out.push_str(" </array>\n");
1641
1642 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1643 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1644 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1645 out.push_str(" </dict>\n");
1646 out.push_str(&format!(
1647 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1648 plan.restart().delay().as_secs()
1649 ));
1650 out.push_str(&plist_string("ProcessType", "Background"));
1653 out.push_str(&plist_string(
1654 "WorkingDirectory",
1655 &directories.state.to_string_lossy(),
1656 ));
1657 out.push_str(&plist_string(
1658 "StandardOutPath",
1659 &directories
1660 .logs
1661 .join("runner-manager.launchd.out.log")
1662 .to_string_lossy(),
1663 ));
1664 out.push_str(&plist_string(
1665 "StandardErrorPath",
1666 &directories
1667 .logs
1668 .join("runner-manager.launchd.err.log")
1669 .to_string_lossy(),
1670 ));
1671
1672 match plan.start_mode() {
1673 StartMode::Boot => {
1674 out.push_str(&plist_string(
1677 "UserName",
1678 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1679 .as_str(),
1680 ));
1681 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1683 }
1684 StartMode::Login => {
1685 }
1689 }
1690
1691 out.push_str("</dict>\n</plist>\n");
1692 out
1693}
1694
1695fn plist_string(key: &str, value: &str) -> String {
1697 format!(
1698 " <key>{}</key>\n <string>{}</string>\n",
1699 xml_escape(key),
1700 xml_escape(value)
1701 )
1702}
1703
1704#[derive(Debug, Clone, PartialEq, Eq)]
1712pub struct TaskPrincipal {
1713 user_id: String,
1714}
1715
1716impl TaskPrincipal {
1717 pub fn current() -> Result<Self, ServiceError> {
1725 let user = std::env::var("USERNAME")
1726 .ok()
1727 .filter(|value| !value.trim().is_empty());
1728 let Some(user) = user else {
1729 return Err(ServiceError::Control {
1730 operation: "identify the account for",
1731 name: SERVICE_NAME.to_string(),
1732 manager: "Windows Task Scheduler",
1733 detail: "this session reports no %USERNAME%, so there is no principal to \
1734 register a logon-triggered task for"
1735 .to_string(),
1736 });
1737 };
1738 let domain = std::env::var("USERDOMAIN")
1739 .ok()
1740 .filter(|value| !value.trim().is_empty());
1741 Ok(Self {
1742 user_id: match domain {
1743 Some(domain) => format!("{domain}\\{user}"),
1744 None => user,
1745 },
1746 })
1747 }
1748
1749 #[must_use]
1751 pub fn named(user_id: impl Into<String>) -> Self {
1752 Self {
1753 user_id: user_id.into(),
1754 }
1755 }
1756
1757 #[must_use]
1759 pub fn user_id(&self) -> &str {
1760 &self.user_id
1761 }
1762}
1763
1764#[must_use]
1771pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1772 let identity = plan.identity();
1773 let user = xml_escape(principal.user_id());
1774 let arguments = plan
1775 .arguments()
1776 .iter()
1777 .map(|argument| quote_argument(&argument.to_string_lossy()))
1778 .collect::<Vec<_>>()
1779 .join(" ");
1780 let mut out = String::new();
1781 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1782 out.push_str(
1783 "<Task version=\"1.4\" \
1784 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1785 );
1786 out.push_str(" <RegistrationInfo>\n");
1787 out.push_str(&format!(
1788 " <Description>{}</Description>\n",
1789 xml_escape(identity.description())
1790 ));
1791 out.push_str(&format!(
1792 " <URI>\\{}</URI>\n",
1793 xml_escape(identity.name())
1794 ));
1795 out.push_str(" </RegistrationInfo>\n");
1796
1797 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1798 out.push_str(" <Enabled>true</Enabled>\n");
1799 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1800 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1801
1802 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1803 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1804 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1805 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1806 out.push_str(" </Principal>\n </Principals>\n");
1807
1808 out.push_str(" <Settings>\n");
1809 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1813 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1814 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1815 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1816 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1817 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1818 out.push_str(" <IdleSettings>\n");
1819 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1820 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1821 out.push_str(" </IdleSettings>\n");
1822 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1823 out.push_str(" <Enabled>true</Enabled>\n");
1824 out.push_str(" <Hidden>false</Hidden>\n");
1825 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1826 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1827 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1829 out.push_str(" <Priority>7</Priority>\n");
1830 out.push_str(" <RestartOnFailure>\n");
1831 out.push_str(&format!(
1832 " <Interval>{}</Interval>\n",
1833 iso8601_minutes(
1834 plan.restart()
1835 .effective_delay(DefinitionKind::WindowsScheduledTask)
1836 )
1837 ));
1838 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1839 out.push_str(" </RestartOnFailure>\n");
1840 out.push_str(" </Settings>\n");
1841
1842 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1843 out.push_str(&format!(
1844 " <Command>{}</Command>\n",
1845 xml_escape(&plan.binary().to_string_lossy())
1846 ));
1847 if !arguments.is_empty() {
1848 out.push_str(&format!(
1849 " <Arguments>{}</Arguments>\n",
1850 xml_escape(&arguments)
1851 ));
1852 }
1853 out.push_str(&format!(
1854 " <WorkingDirectory>{}</WorkingDirectory>\n",
1855 xml_escape(&plan.directories().state.to_string_lossy())
1856 ));
1857 out.push_str(" </Exec>\n </Actions>\n");
1858 out.push_str("</Task>\n");
1859 out
1860}
1861
1862fn iso8601_minutes(duration: Duration) -> String {
1865 format!("PT{}M", duration.as_secs() / 60)
1866}
1867
1868pub(crate) fn xml_escape(value: &str) -> String {
1876 let mut out = String::with_capacity(value.len());
1877 for c in value.chars() {
1878 match c {
1879 '&' => out.push_str("&"),
1880 '<' => out.push_str("<"),
1881 '>' => out.push_str(">"),
1882 '"' => out.push_str("""),
1883 '\'' => out.push_str("'"),
1884 other => out.push(other),
1885 }
1886 }
1887 out
1888}
1889
1890fn xml_unescape(value: &str) -> String {
1896 value
1897 .replace("<", "<")
1898 .replace(">", ">")
1899 .replace(""", "\"")
1900 .replace("'", "'")
1901 .replace("&", "&")
1902}
1903
1904#[derive(Debug, Clone, PartialEq, Eq)]
1913pub struct WindowsServiceSpec {
1914 pub name: String,
1916 pub display_name: String,
1918 pub description: String,
1920 pub automatic_start: bool,
1922 pub account: Option<String>,
1925 pub command_line: String,
1927 pub restart: RestartPolicy,
1929}
1930
1931#[must_use]
1933pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1934 WindowsServiceSpec {
1935 name: plan.identity().name().to_string(),
1936 display_name: plan.identity().display_name().to_string(),
1937 description: plan.identity().description().to_string(),
1938 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1943 account: match ServiceAccount::for_definition(
1944 DefinitionKind::WindowsService,
1945 plan.start_mode(),
1946 ) {
1947 ServiceAccount::LocalSystem => None,
1950 other => Some(other.as_str().to_string()),
1951 },
1952 command_line: plan.command_line(),
1953 restart: plan.restart(),
1954 }
1955}
1956
1957#[must_use]
1960fn windows_service_descriptor(plan: &InstallPlan) -> String {
1961 let spec = windows_service_spec(plan);
1962 let mut out = String::new();
1963 out.push_str("[windows-service]\n");
1964 out.push_str(&format!("Name={}\n", spec.name));
1965 out.push_str(&format!("DisplayName={}\n", spec.display_name));
1966 out.push_str(&format!("Description={}\n", spec.description));
1967 out.push_str("ServiceType=OWN_PROCESS\n");
1971 out.push_str(&format!(
1972 "StartType={}\n",
1973 if spec.automatic_start {
1974 "AutoStart"
1975 } else {
1976 "OnDemand"
1977 }
1978 ));
1979 out.push_str("ErrorControl=Normal\n");
1980 out.push_str(&format!(
1981 "Account={}\n",
1982 spec.account
1983 .as_deref()
1984 .unwrap_or(ServiceAccount::LocalSystem.as_str())
1985 ));
1986 out.push_str(&format!("CommandLine={}\n", spec.command_line));
1987 out.push_str(&format!(
1988 "FailureActionRestartDelaySecs={}\n",
1989 spec.restart.delay().as_secs()
1990 ));
1991 out.push_str(&format!(
1992 "FailureActionsResetPeriodSecs={}\n",
1993 spec.restart.reset_after().as_secs()
1994 ));
1995 out.push_str("FailureActionsOnNonCrashFailures=true\n");
1999 out.push_str(&format!(
2000 "ReadWritePaths={}\n",
2001 plan.directories()
2002 .all()
2003 .iter()
2004 .map(|path| quote_argument(&path.to_string_lossy()))
2005 .collect::<Vec<_>>()
2006 .join(" ")
2007 ));
2008 out
2009}
2010
2011#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2017pub enum FindingKind {
2018 Excess,
2021 Shortfall,
2024}
2025
2026impl fmt::Display for FindingKind {
2027 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2028 f.write_str(match self {
2029 Self::Excess => "excess",
2030 Self::Shortfall => "shortfall",
2031 })
2032 }
2033}
2034
2035#[derive(Debug, Clone, PartialEq, Eq)]
2037pub struct PrivilegeFinding {
2038 pub kind: FindingKind,
2040 pub subject: String,
2042 pub detail: String,
2044}
2045
2046impl fmt::Display for PrivilegeFinding {
2047 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2048 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2049 }
2050}
2051
2052#[derive(Debug, Clone, PartialEq, Eq)]
2066pub struct PrivilegeReview {
2067 kind: DefinitionKind,
2068 account: ServiceAccount,
2069 controls: Vec<String>,
2070 findings: Vec<PrivilegeFinding>,
2071}
2072
2073impl PrivilegeReview {
2074 #[must_use]
2076 pub fn is_least_privilege(&self) -> bool {
2077 !self
2078 .findings
2079 .iter()
2080 .any(|finding| finding.kind == FindingKind::Excess)
2081 }
2082
2083 #[must_use]
2085 pub fn findings(&self) -> &[PrivilegeFinding] {
2086 &self.findings
2087 }
2088
2089 #[must_use]
2092 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2093 self.findings
2094 .iter()
2095 .filter(|finding| finding.kind == FindingKind::Excess)
2096 .collect()
2097 }
2098
2099 #[must_use]
2105 pub fn controls(&self) -> &[String] {
2106 &self.controls
2107 }
2108
2109 #[must_use]
2111 pub const fn account(&self) -> &ServiceAccount {
2112 &self.account
2113 }
2114
2115 #[must_use]
2117 pub const fn kind(&self) -> DefinitionKind {
2118 self.kind
2119 }
2120}
2121
2122impl fmt::Display for PrivilegeReview {
2123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2124 writeln!(
2125 f,
2126 "{} runs as {} ({})",
2127 self.kind,
2128 self.account,
2129 self.account.justification()
2130 )?;
2131 for control in &self.controls {
2132 writeln!(f, " confirmed {control}")?;
2133 }
2134 for finding in &self.findings {
2135 writeln!(f, " {finding}")?;
2136 }
2137 if self.is_least_privilege() {
2138 write!(f, " verdict least privilege")
2139 } else {
2140 write!(
2141 f,
2142 " verdict NOT least privilege: {} excess(es)",
2143 self.excesses().len()
2144 )
2145 }
2146 }
2147}
2148
2149#[must_use]
2155pub fn review_least_privilege(
2156 definition: &ServiceDefinition,
2157 plan: &InstallPlan,
2158) -> PrivilegeReview {
2159 let mut controls = Vec::new();
2160 let mut findings = Vec::new();
2161 match definition.kind() {
2162 DefinitionKind::SystemdUnit => {
2163 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2164 }
2165 DefinitionKind::LaunchdPlist => {
2166 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2167 }
2168 DefinitionKind::WindowsScheduledTask => {
2169 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2170 }
2171 DefinitionKind::WindowsService => {
2172 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2173 }
2174 }
2175 PrivilegeReview {
2176 kind: definition.kind(),
2177 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2182 controls,
2183 findings,
2184 }
2185}
2186
2187fn permitted_paths(plan: &InstallPlan) -> Vec<String> {
2189 plan.directories()
2190 .all()
2191 .iter()
2192 .map(|path| path.to_string_lossy().into_owned())
2193 .collect()
2194}
2195
2196fn same_path_text(left: &str, right: &str) -> bool {
2203 if cfg!(windows) {
2204 left.eq_ignore_ascii_case(right)
2205 } else {
2206 left == right
2207 }
2208}
2209
2210fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2213 match kind {
2214 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2215 left.eq_ignore_ascii_case(right)
2216 }
2217 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2218 }
2219}
2220
2221fn review_writable_paths(
2223 kind: DefinitionKind,
2224 subject: &str,
2225 listed: &[String],
2226 plan: &InstallPlan,
2227 controls: &mut Vec<String>,
2228 findings: &mut Vec<PrivilegeFinding>,
2229) {
2230 let permitted = permitted_paths(plan);
2231 for entry in listed {
2232 if !permitted
2233 .iter()
2234 .any(|allowed| same_path_for(kind, allowed, entry))
2235 {
2236 findings.push(PrivilegeFinding {
2237 kind: FindingKind::Excess,
2238 subject: subject.to_string(),
2239 detail: format!(
2240 "{entry} is writable but is not one of this registration's four \
2241 application-data directories"
2242 ),
2243 });
2244 }
2245 }
2246 for allowed in &permitted {
2247 if !listed
2248 .iter()
2249 .any(|entry| same_path_for(kind, allowed, entry))
2250 {
2251 findings.push(PrivilegeFinding {
2252 kind: FindingKind::Shortfall,
2253 subject: subject.to_string(),
2254 detail: format!(
2255 "{allowed} is one of this registration's directories but is not writable, \
2256 so the daemon cannot use it"
2257 ),
2258 });
2259 }
2260 }
2261 if listed.len() == permitted.len() && findings.iter().all(|f| f.subject != subject) {
2262 controls.push(format!(
2263 "{subject} names exactly the four application-data directories"
2264 ));
2265 }
2266}
2267
2268fn review_inbound_surface(
2276 text: &str,
2277 markers: &[(&str, &str)],
2278 controls: &mut Vec<String>,
2279 findings: &mut Vec<PrivilegeFinding>,
2280) {
2281 let mut clean = true;
2282 for (marker, detail) in markers {
2283 if text.contains(marker) {
2284 clean = false;
2285 findings.push(PrivilegeFinding {
2286 kind: FindingKind::Excess,
2287 subject: (*marker).to_string(),
2288 detail: (*detail).to_string(),
2289 });
2290 }
2291 }
2292 if clean {
2293 controls.push(
2294 "no socket, listener, or Mach service is published on the daemon's behalf".to_string(),
2295 );
2296 }
2297}
2298
2299fn review_systemd(
2300 text: &str,
2301 plan: &InstallPlan,
2302 controls: &mut Vec<String>,
2303 findings: &mut Vec<PrivilegeFinding>,
2304) {
2305 let directives = ini_directives(text, "Service");
2306 for expected in SYSTEMD_HARDENING {
2307 let (key, value) = expected
2308 .split_once('=')
2309 .expect("every hardening directive is written as key=value");
2310 match directives.get(key) {
2311 Some(actual) if actual == value => controls.push((*expected).to_string()),
2312 Some(actual) => findings.push(PrivilegeFinding {
2313 kind: FindingKind::Excess,
2314 subject: key.to_string(),
2315 detail: format!(
2316 "is `{actual}`, not `{value}`, so the unit keeps authority the \
2317 requirement does not ask for"
2318 ),
2319 }),
2320 None => findings.push(PrivilegeFinding {
2321 kind: FindingKind::Excess,
2322 subject: key.to_string(),
2323 detail: format!(
2324 "is absent, so the unit inherits systemd's default rather than `{value}`"
2325 ),
2326 }),
2327 }
2328 }
2329
2330 match directives.get("ReadWritePaths") {
2331 Some(value) => {
2332 let listed = split_quoted(value);
2333 review_writable_paths(
2334 DefinitionKind::SystemdUnit,
2335 "ReadWritePaths",
2336 &listed,
2337 plan,
2338 controls,
2339 findings,
2340 );
2341 }
2342 None => findings.push(PrivilegeFinding {
2343 kind: FindingKind::Shortfall,
2344 subject: "ReadWritePaths".to_string(),
2345 detail: "is absent, so `ProtectSystem=strict` leaves the daemon nowhere to write"
2346 .to_string(),
2347 }),
2348 }
2349
2350 if directives.contains_key("PrivateUsers")
2353 && directives.get("PrivateUsers") == Some(&"no".to_string())
2354 {
2355 findings.push(PrivilegeFinding {
2356 kind: FindingKind::Excess,
2357 subject: "PrivateUsers".to_string(),
2358 detail: "is explicitly disabled, which is broader than leaving it at systemd's default"
2359 .to_string(),
2360 });
2361 }
2362
2363 review_inbound_surface(
2364 text,
2365 &[
2366 (
2367 "ListenStream=",
2368 "asks systemd to open a listening socket for this service, which \
2369 07-security.md rule 2 forbids the product to have",
2370 ),
2371 (
2372 "ListenDatagram=",
2373 "asks systemd to open a listening socket for this service, which \
2374 07-security.md rule 2 forbids the product to have",
2375 ),
2376 ],
2377 controls,
2378 findings,
2379 );
2380}
2381
2382fn review_launchd(
2383 text: &str,
2384 plan: &InstallPlan,
2385 controls: &mut Vec<String>,
2386 findings: &mut Vec<PrivilegeFinding>,
2387) {
2388 match plist_string_value(text, "ProcessType").as_deref() {
2389 Some("Background") => controls.push("ProcessType=Background".to_string()),
2390 Some(other) => findings.push(PrivilegeFinding {
2391 kind: FindingKind::Excess,
2392 subject: "ProcessType".to_string(),
2393 detail: format!(
2394 "is `{other}`, which asks the scheduler for more CPU and I/O than a background \
2395 daemon needs"
2396 ),
2397 }),
2398 None => findings.push(PrivilegeFinding {
2399 kind: FindingKind::Excess,
2400 subject: "ProcessType".to_string(),
2401 detail: "is absent, so launchd applies its `Standard` default rather than \
2402 `Background`"
2403 .to_string(),
2404 }),
2405 }
2406
2407 match plan.start_mode() {
2408 StartMode::Boot => {
2409 if plist_bool_value(text, "SessionCreate") == Some(true) {
2410 findings.push(PrivilegeFinding {
2411 kind: FindingKind::Excess,
2412 subject: "SessionCreate".to_string(),
2413 detail: "asks launchd to create a security session for a job that runs \
2414 outside every login session and has no use for one"
2415 .to_string(),
2416 });
2417 } else {
2418 controls.push("SessionCreate is not requested".to_string());
2419 }
2420 match plist_string_value(text, "UserName").as_deref() {
2421 Some("root") => {
2422 controls.push("UserName=root, stated rather than inherited".to_string())
2423 }
2424 Some(other) => findings.push(PrivilegeFinding {
2425 kind: FindingKind::Shortfall,
2426 subject: "UserName".to_string(),
2427 detail: format!(
2428 "is `{other}`, which cannot unlock the System Keychain: \
2429 /var/db/SystemKey is root-only, so the daemon would start and then \
2430 find no credential"
2431 ),
2432 }),
2433 None => findings.push(PrivilegeFinding {
2434 kind: FindingKind::Shortfall,
2435 subject: "UserName".to_string(),
2436 detail: "is absent, so the account is launchd's implicit default and this \
2437 review cannot confirm it"
2438 .to_string(),
2439 }),
2440 }
2441 }
2442 StartMode::Login => {
2443 if let Some(named) = plist_string_value(text, "UserName") {
2444 findings.push(PrivilegeFinding {
2445 kind: FindingKind::Excess,
2446 subject: "UserName".to_string(),
2447 detail: format!(
2448 "names `{named}` in a LaunchAgent, which already runs as the operator; \
2449 naming an account here asks launchd for a switch a login-mode \
2450 registration has no reason to want"
2451 ),
2452 });
2453 } else {
2454 controls
2455 .push("no UserName: the agent runs as the operator and no other".to_string());
2456 }
2457 }
2458 }
2459
2460 review_inbound_surface(
2461 text,
2462 &[
2463 (
2464 "<key>Sockets</key>",
2465 "asks launchd to open a socket for this job, which 07-security.md rule 2 \
2466 forbids the product to have",
2467 ),
2468 (
2469 "<key>MachServices</key>",
2470 "publishes a Mach service, which is the RPC surface 07-security.md rule 2 \
2471 forbids the product to have",
2472 ),
2473 ],
2474 controls,
2475 findings,
2476 );
2477}
2478
2479fn review_scheduled_task(
2480 text: &str,
2481 controls: &mut Vec<String>,
2482 findings: &mut Vec<PrivilegeFinding>,
2483) {
2484 match xml_value(text, "RunLevel").as_deref() {
2485 Some("LeastPrivilege") => controls.push("RunLevel=LeastPrivilege".to_string()),
2486 Some(other) => findings.push(PrivilegeFinding {
2487 kind: FindingKind::Excess,
2488 subject: "RunLevel".to_string(),
2489 detail: format!(
2490 "is `{other}`, so the task runs with an elevated token whenever the operator is \
2491 an administrator"
2492 ),
2493 }),
2494 None => findings.push(PrivilegeFinding {
2495 kind: FindingKind::Excess,
2496 subject: "RunLevel".to_string(),
2497 detail: "is absent, so Task Scheduler decides the token rather than the definition"
2498 .to_string(),
2499 }),
2500 }
2501
2502 match xml_value(text, "LogonType").as_deref() {
2503 Some("InteractiveToken") => controls.push("LogonType=InteractiveToken".to_string()),
2504 Some(other) => findings.push(PrivilegeFinding {
2505 kind: FindingKind::Excess,
2506 subject: "LogonType".to_string(),
2507 detail: format!(
2508 "is `{other}`, which asks Windows to store or synthesise a credential for this \
2509 task; an interactive token needs neither"
2510 ),
2511 }),
2512 None => findings.push(PrivilegeFinding {
2513 kind: FindingKind::Shortfall,
2514 subject: "LogonType".to_string(),
2515 detail: "is absent, so this review cannot confirm that no credential is stored"
2516 .to_string(),
2517 }),
2518 }
2519}
2520
2521fn review_windows_service(
2522 text: &str,
2523 plan: &InstallPlan,
2524 controls: &mut Vec<String>,
2525 findings: &mut Vec<PrivilegeFinding>,
2526) {
2527 let directives = ini_directives(text, "windows-service");
2528
2529 match directives.get("ServiceType").map(String::as_str) {
2530 Some("OWN_PROCESS") => controls.push("ServiceType=OWN_PROCESS".to_string()),
2531 Some(other) => findings.push(PrivilegeFinding {
2532 kind: FindingKind::Excess,
2533 subject: "ServiceType".to_string(),
2534 detail: format!(
2535 "is `{other}`; an interactive or shared-process service reaches further than a \
2536 daemon that only talks to GitHub over HTTPS"
2537 ),
2538 }),
2539 None => findings.push(PrivilegeFinding {
2540 kind: FindingKind::Shortfall,
2541 subject: "ServiceType".to_string(),
2542 detail: "is absent, so this review cannot confirm the service is not interactive"
2543 .to_string(),
2544 }),
2545 }
2546
2547 match directives.get("Account").map(String::as_str) {
2551 Some(account) if account == ServiceAccount::LocalSystem.as_str() => {
2552 controls.push(format!(
2553 "Account={account}: the only stock account the machine-scoped store's DACL \
2554 (SY, BA, OW) admits"
2555 ));
2556 }
2557 Some(other) => findings.push(PrivilegeFinding {
2558 kind: FindingKind::Shortfall,
2559 subject: "Account".to_string(),
2560 detail: format!(
2561 "is `{other}`, which the machine-scoped store's DACL does not name, so the \
2562 daemon would start and then find no credential. Widening that DACL is not this \
2563 registration's to do: an ACE reaching `{other}` would also reach every other \
2564 service running under it"
2565 ),
2566 }),
2567 None => findings.push(PrivilegeFinding {
2568 kind: FindingKind::Shortfall,
2569 subject: "Account".to_string(),
2570 detail: "is absent, so this review cannot confirm which account was registered"
2571 .to_string(),
2572 }),
2573 }
2574
2575 match directives.get("ReadWritePaths") {
2576 Some(value) => {
2577 let listed = split_quoted(value);
2578 review_writable_paths(
2579 DefinitionKind::WindowsService,
2580 "ReadWritePaths",
2581 &listed,
2582 plan,
2583 controls,
2584 findings,
2585 );
2586 }
2587 None => findings.push(PrivilegeFinding {
2588 kind: FindingKind::Shortfall,
2589 subject: "ReadWritePaths".to_string(),
2590 detail: "is absent, so the directories the service was installed against are not \
2591 recorded"
2592 .to_string(),
2593 }),
2594 }
2595}
2596
2597fn ini_directives(text: &str, section: &str) -> BTreeMap<String, String> {
2605 let mut out = BTreeMap::new();
2606 let mut inside = false;
2607 for line in text.lines() {
2608 let line = line.trim();
2609 if line.starts_with('[') && line.ends_with(']') {
2610 inside = &line[1..line.len() - 1] == section;
2611 continue;
2612 }
2613 if !inside || line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2614 continue;
2615 }
2616 if let Some((key, value)) = line.split_once('=') {
2617 out.insert(key.trim().to_string(), value.trim().to_string());
2618 }
2619 }
2620 out
2621}
2622
2623fn split_quoted(value: &str) -> Vec<String> {
2626 let mut out = Vec::new();
2627 let mut rest = value.trim();
2628 while !rest.is_empty() {
2629 if rest.starts_with('"') {
2630 if let Some(parsed) = executable_from_command_line(rest) {
2633 out.push(parsed.to_string_lossy().into_owned());
2634 }
2635 let mut depth = 0usize;
2637 let mut end = rest.len();
2638 for (index, c) in rest.char_indices() {
2639 match c {
2640 '\\' => depth += 1,
2641 '"' => {
2642 if depth.is_multiple_of(2) && index > 0 {
2643 end = index + 1;
2644 break;
2645 }
2646 depth = 0;
2647 }
2648 _ => depth = 0,
2649 }
2650 }
2651 rest = rest[end..].trim_start();
2652 } else {
2653 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
2654 out.push(rest[..end].to_string());
2655 rest = rest[end..].trim_start();
2656 }
2657 }
2658 out
2659}
2660
2661pub(crate) fn xml_value(text: &str, tag: &str) -> Option<String> {
2673 let open = format!("<{tag}>");
2674 let close = format!("</{tag}>");
2675 let start = text.find(&open)? + open.len();
2676 let end = text[start..].find(&close)? + start;
2677 Some(xml_unescape(text[start..end].trim()))
2678}
2679
2680fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2682 let marker = format!("<key>{key}</key>");
2683 let start = text.find(&marker)? + marker.len();
2684 Some(text[start..].trim_start())
2685}
2686
2687fn plist_string_value(text: &str, key: &str) -> Option<String> {
2688 let rest = plist_value_after_key(text, key)?;
2689 if !rest.starts_with("<string>") {
2690 return None;
2691 }
2692 xml_value(rest, "string")
2693}
2694
2695fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2696 let rest = plist_value_after_key(text, key)?;
2697 if rest.starts_with("<true/>") {
2698 Some(true)
2699 } else if rest.starts_with("<false/>") {
2700 Some(false)
2701 } else {
2702 None
2703 }
2704}
2705
2706pub const RECORD_SCHEMA_VERSION: u32 = 1;
2717
2718#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2730pub struct InstallRecord {
2731 pub schema_version: u32,
2733 pub service_name: String,
2735 pub manager: String,
2737 pub start_mode: StartMode,
2739 pub account: ServiceAccount,
2741 pub binary: PathBuf,
2743 #[serde(default)]
2751 pub source_binary: Option<PathBuf>,
2752 pub arguments: Vec<String>,
2754 pub restart_delay_secs: u64,
2756 pub restart_reset_secs: u64,
2758 pub log_file: PathBuf,
2760 #[serde(default)]
2768 pub starts_on_demand: bool,
2769 pub definition_path: Option<PathBuf>,
2771 pub installed_at: DateTime<Utc>,
2773 pub installed_by_version: String,
2775 pub directories: ServiceDirectories,
2780}
2781
2782impl InstallRecord {
2783 #[must_use]
2785 pub fn path(paths: &AppPaths) -> PathBuf {
2786 paths.config_dir().join(RECORD_FILE)
2787 }
2788
2789 #[must_use]
2791 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2792 Self {
2793 schema_version: RECORD_SCHEMA_VERSION,
2794 service_name: plan.identity().name().to_string(),
2795 manager: definition.kind().manager().to_string(),
2796 start_mode: plan.start_mode(),
2797 account: plan.account().clone(),
2798 binary: plan.binary().to_path_buf(),
2799 arguments: plan
2800 .arguments()
2801 .iter()
2802 .map(|argument| argument.to_string_lossy().into_owned())
2803 .collect(),
2804 restart_delay_secs: plan.restart().delay().as_secs(),
2805 restart_reset_secs: plan.restart().reset_after().as_secs(),
2806 starts_on_demand: plan.is_on_demand(),
2807 source_binary: plan.source_binary().map(Path::to_path_buf),
2808 log_file: plan.directories().log_file(),
2809 definition_path: definition.install_path().map(Path::to_path_buf),
2810 installed_at: at,
2811 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2812 directories: plan.directories().clone(),
2813 }
2814 }
2815
2816 #[must_use]
2820 pub fn restart(&self) -> RestartPolicy {
2821 RestartPolicy::new(
2822 Duration::from_secs(self.restart_delay_secs),
2823 Duration::from_secs(self.restart_reset_secs),
2824 )
2825 .unwrap_or_default()
2826 }
2827
2828 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2839 let path = Self::path(paths);
2840 let text = match std::fs::read_to_string(&path) {
2841 Ok(text) => text,
2842 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2843 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2846 return Err(ServiceError::RecordNotPermitted {
2847 path,
2848 detail: error.to_string(),
2849 });
2850 }
2851 Err(error) => {
2852 return Err(ServiceError::Record {
2853 operation: "read",
2854 path,
2855 detail: error.to_string(),
2856 });
2857 }
2858 };
2859 let record: Self =
2860 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2861 path: path.clone(),
2862 detail: error.to_string(),
2863 })?;
2864 if record.schema_version != RECORD_SCHEMA_VERSION {
2865 return Err(ServiceError::RecordUnreadable {
2866 path,
2867 detail: format!(
2868 "it declares schema version {} and this build reads version {}",
2869 record.schema_version, RECORD_SCHEMA_VERSION
2870 ),
2871 });
2872 }
2873 Ok(Some(record))
2874 }
2875
2876 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2882 use std::io::Write as _;
2883
2884 let path = Self::path(paths);
2885 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2886 operation: "encode",
2887 path: path.clone(),
2888 detail: error.to_string(),
2889 })?;
2890 if let Some(parent) = path.parent() {
2891 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2892 operation: "write",
2893 path: path.clone(),
2894 detail: error.to_string(),
2895 })?;
2896 }
2897 let parent = path.parent().ok_or_else(|| ServiceError::Record {
2898 operation: "write",
2899 path: path.clone(),
2900 detail: "the record path has no parent directory".to_string(),
2901 })?;
2902 let mut temporary =
2903 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2904 operation: "write",
2905 path: path.clone(),
2906 detail: error.to_string(),
2907 })?;
2908 temporary
2909 .write_all(text.as_bytes())
2910 .and_then(|()| temporary.as_file().sync_all())
2911 .map_err(|error| ServiceError::Record {
2912 operation: "write",
2913 path: path.clone(),
2914 detail: error.to_string(),
2915 })?;
2916 #[cfg(unix)]
2932 {
2933 use std::os::unix::fs::PermissionsExt as _;
2934
2935 temporary
2936 .as_file()
2937 .set_permissions(std::fs::Permissions::from_mode(0o644))
2938 .map_err(|error| ServiceError::Record {
2939 operation: "write",
2940 path: path.clone(),
2941 detail: error.to_string(),
2942 })?;
2943 }
2944 temporary
2945 .persist(&path)
2946 .map(|_| ())
2947 .map_err(|error| ServiceError::Record {
2948 operation: "write",
2949 path,
2950 detail: error.error.to_string(),
2951 })
2952 }
2953
2954 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
2960 let path = Self::path(paths);
2961 match std::fs::remove_file(&path) {
2962 Ok(()) => Ok(true),
2963 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
2964 Err(error) => Err(ServiceError::Record {
2965 operation: "remove",
2966 path,
2967 detail: error.to_string(),
2968 }),
2969 }
2970 }
2971}
2972
2973const CONTACT_SCHEMA_VERSION: u32 = 1;
2979
2980#[derive(Debug, Clone, Serialize, Deserialize)]
2981struct ContactRecord {
2982 schema_version: u32,
2983 last_success: DateTime<Utc>,
2984}
2985
2986pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3007 let path = contact_path(paths);
3008 let record = ContactRecord {
3009 schema_version: CONTACT_SCHEMA_VERSION,
3010 last_success: at,
3011 };
3012 let failed = |detail: String| ServiceError::Record {
3013 operation: "write",
3014 path: path.clone(),
3015 detail,
3016 };
3017 let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3018 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3019 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3020 let temporary = path.with_extension("toml.new");
3021 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3022 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3023}
3024
3025pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3037 let path = contact_path(paths);
3038 let text = match std::fs::read_to_string(&path) {
3039 Ok(text) => text,
3040 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3041 Err(error) => {
3042 return Err(ServiceError::Record {
3043 operation: "read",
3044 path,
3045 detail: error.to_string(),
3046 });
3047 }
3048 };
3049 let record: ContactRecord = toml::from_str(&text).map_err(|error| ServiceError::Record {
3050 operation: "read",
3051 path,
3052 detail: error.to_string(),
3053 })?;
3054 Ok(Some(record.last_success))
3055}
3056
3057#[must_use]
3059pub fn contact_path(paths: &AppPaths) -> PathBuf {
3060 paths.state_dir().join(CONTACT_FILE)
3061}
3062
3063const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3068
3069#[derive(Debug, Clone, Serialize, Deserialize)]
3070struct RootRefusalFile {
3071 schema_version: u32,
3072 #[serde(default)]
3074 refusals: BTreeMap<String, RootRefusalEntry>,
3075}
3076
3077#[derive(Debug, Clone, Serialize, Deserialize)]
3078struct RootRefusalEntry {
3079 at: DateTime<Utc>,
3080 kind: String,
3081 root: String,
3082 detail: String,
3083}
3084
3085#[derive(Debug, Clone, PartialEq, Eq)]
3087pub struct RunnerRootRefusal {
3088 pub policy: String,
3090 pub at: DateTime<Utc>,
3092 pub kind: String,
3094 pub root: String,
3096 pub detail: String,
3099}
3100
3101pub fn record_runner_root_refusal(
3135 paths: &AppPaths,
3136 policy: &str,
3137 at: DateTime<Utc>,
3138 kind: &str,
3139 root: &str,
3140 detail: &str,
3141) -> Result<(), ServiceError> {
3142 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3143 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3144 refusals: BTreeMap::new(),
3145 });
3146 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3147 file.refusals.insert(
3148 policy.to_owned(),
3149 RootRefusalEntry {
3150 at,
3151 kind: kind.to_owned(),
3152 root: root.to_owned(),
3153 detail: detail.to_owned(),
3154 },
3155 );
3156 write_refusal_file(paths, &file)
3157}
3158
3159pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3170 let Some(mut file) = read_refusal_file(paths)? else {
3171 return Ok(());
3172 };
3173 if file.refusals.remove(policy).is_none() {
3174 return Ok(());
3175 }
3176 if file.refusals.is_empty() {
3177 let path = root_refusal_path(paths);
3178 return match std::fs::remove_file(&path) {
3179 Ok(()) => Ok(()),
3180 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3181 Err(error) => Err(ServiceError::Record {
3182 operation: "remove",
3183 path,
3184 detail: error.to_string(),
3185 }),
3186 };
3187 }
3188 write_refusal_file(paths, &file)
3189}
3190
3191pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3203 Ok(read_refusal_file(paths)?
3204 .map(|file| {
3205 file.refusals
3206 .into_iter()
3207 .map(|(policy, entry)| RunnerRootRefusal {
3208 policy,
3209 at: entry.at,
3210 kind: entry.kind,
3211 root: entry.root,
3212 detail: entry.detail,
3213 })
3214 .collect()
3215 })
3216 .unwrap_or_default())
3217}
3218
3219fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3220 let path = root_refusal_path(paths);
3221 let text = match std::fs::read_to_string(&path) {
3222 Ok(text) => text,
3223 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3224 Err(error) => {
3225 return Err(ServiceError::Record {
3226 operation: "read",
3227 path,
3228 detail: error.to_string(),
3229 });
3230 }
3231 };
3232 toml::from_str(&text)
3233 .map(Some)
3234 .map_err(|error| ServiceError::Record {
3235 operation: "read",
3236 path,
3237 detail: error.to_string(),
3238 })
3239}
3240
3241fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3242 let path = root_refusal_path(paths);
3243 let failed = |detail: String| ServiceError::Record {
3244 operation: "write",
3245 path: path.clone(),
3246 detail,
3247 };
3248 let text = toml::to_string_pretty(file).map_err(|error| failed(error.to_string()))?;
3249 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3250 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3251 let temporary = path.with_extension("toml.new");
3252 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3253 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3254}
3255
3256#[must_use]
3258pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3259 paths.state_dir().join(ROOT_REFUSAL_FILE)
3260}
3261
3262#[derive(Debug, Clone, PartialEq, Eq)]
3273pub enum BinaryPath {
3274 Current {
3276 path: PathBuf,
3278 },
3279 Missing {
3286 recorded: PathBuf,
3288 },
3289 NotExecutable {
3292 recorded: PathBuf,
3294 detail: String,
3296 },
3297 Diverged {
3299 recorded: PathBuf,
3301 registered: PathBuf,
3303 },
3304}
3305
3306impl BinaryPath {
3307 #[must_use]
3309 pub const fn is_error(&self) -> bool {
3310 !matches!(self, Self::Current { .. })
3311 }
3312
3313 #[must_use]
3315 pub fn recorded(&self) -> &Path {
3316 match self {
3317 Self::Current { path } => path,
3318 Self::Missing { recorded }
3319 | Self::NotExecutable { recorded, .. }
3320 | Self::Diverged { recorded, .. } => recorded,
3321 }
3322 }
3323}
3324
3325impl fmt::Display for BinaryPath {
3326 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3327 match self {
3328 Self::Current { path } => write!(f, "{}", path.display()),
3329 Self::Missing { recorded } => write!(
3330 f,
3331 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3332 package manager that moved the binary is the usual cause; an `npm i -g` \
3333 installation moves with the active Node version. Run `service install` again \
3334 from the binary that is now installed.",
3335 recorded.display()
3336 ),
3337 Self::NotExecutable { recorded, detail } => write!(
3338 f,
3339 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3340 from the installed binary.",
3341 recorded.display()
3342 ),
3343 Self::Diverged {
3344 recorded,
3345 registered,
3346 } => write!(
3347 f,
3348 "{} -- STALE: the service manager is registered to start {} instead. Something \
3349 has edited the registration since it was installed. Run `service uninstall` and \
3350 `service install`; neither touches configuration, secrets, or the cache.",
3351 recorded.display(),
3352 registered.display()
3353 ),
3354 }
3355 }
3356}
3357
3358#[must_use]
3366pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3367 match std::fs::metadata(recorded) {
3368 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3369 return BinaryPath::Missing {
3370 recorded: recorded.to_path_buf(),
3371 };
3372 }
3373 Err(error) => {
3374 return BinaryPath::NotExecutable {
3375 recorded: recorded.to_path_buf(),
3376 detail: format!("it cannot be inspected ({error})"),
3377 };
3378 }
3379 Ok(metadata) if !metadata.is_file() => {
3380 return BinaryPath::NotExecutable {
3381 recorded: recorded.to_path_buf(),
3382 detail: "what is there is not a file".to_string(),
3383 };
3384 }
3385 Ok(_) => {}
3386 }
3387 if let Some(registered) = registered
3388 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3389 {
3390 return BinaryPath::Diverged {
3391 recorded: recorded.to_path_buf(),
3392 registered: registered.to_path_buf(),
3393 };
3394 }
3395 BinaryPath::Current {
3396 path: recorded.to_path_buf(),
3397 }
3398}
3399
3400#[derive(Debug, Clone, PartialEq, Eq)]
3406pub struct Registration {
3407 pub manager: DefinitionKind,
3412 pub start_mode: StartMode,
3414 pub command_line: String,
3416 pub account: Option<String>,
3418 pub running: bool,
3420 pub starts_automatically: bool,
3427 pub restart_delay: Option<Duration>,
3436}
3437
3438impl Registration {
3439 #[must_use]
3441 pub fn binary(&self) -> Option<PathBuf> {
3442 executable_from_command_line(&self.command_line)
3443 }
3444}
3445
3446pub trait ServiceControl: fmt::Debug {
3452 fn manager(&self) -> DefinitionKind;
3454
3455 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3461
3462 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3473
3474 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3480
3481 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3488
3489 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3495}
3496
3497pub trait ControlFactory: fmt::Debug + Send + Sync {
3503 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3509}
3510
3511#[derive(Debug, Clone, Copy, Default)]
3513pub struct HostControls;
3514
3515#[derive(Debug, Clone)]
3521pub struct Installed {
3522 pub plan: InstallPlan,
3524 pub definition: ServiceDefinition,
3526 pub record: InstallRecord,
3528 pub review: PrivilegeReview,
3530 pub runner_root: RootAccessSummary,
3535 pub replaced_existing: bool,
3543}
3544
3545#[derive(Debug, Clone, PartialEq, Eq)]
3547pub struct Uninstalled {
3548 pub removed_registration: bool,
3550 pub removed_record: bool,
3552 pub removed_definition: Option<PathBuf>,
3554 pub preserved: Vec<PathBuf>,
3561}
3562
3563impl fmt::Display for Uninstalled {
3564 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3565 if self.removed_registration {
3566 writeln!(f, "The service registration was removed.")?;
3567 } else {
3568 writeln!(f, "There was no service registration to remove.")?;
3569 }
3570 writeln!(f, "Nothing else was deleted. These are untouched:")?;
3571 for path in &self.preserved {
3572 writeln!(f, " {}", path.display())?;
3573 }
3574 write!(
3575 f,
3576 "The stored GitHub token is untouched too; `auth logout` is what purges it."
3577 )
3578 }
3579}
3580
3581#[derive(Debug, Clone, PartialEq, Eq)]
3583pub struct StartModeChange {
3584 pub from: StartMode,
3586 pub to: StartMode,
3588 pub changed: bool,
3590 pub store_scope: crate::secrets::SecretScope,
3593 pub runner_root: RootAccessSummary,
3599}
3600
3601impl fmt::Display for StartModeChange {
3602 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3603 if !self.changed {
3604 return write!(f, "The service already starts at {}.", self.to);
3605 }
3606 write!(
3607 f,
3608 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3609 if the token was stored under the other scope, run `auth login` again. {}",
3610 self.to, self.from, self.store_scope, self.runner_root
3611 )
3612 }
3613}
3614
3615#[derive(Debug, Clone)]
3623pub struct ServiceOperations {
3624 paths: AppPaths,
3625 identity: ServiceIdentity,
3626 controls: std::sync::Arc<dyn ControlFactory>,
3627 runner_root: Option<LocalAbsolutePath>,
3628}
3629
3630impl ServiceOperations {
3631 #[must_use]
3633 pub fn on_this_host(paths: AppPaths) -> Self {
3634 Self::with_controls(
3635 paths,
3636 ServiceIdentity::product(),
3637 std::sync::Arc::new(HostControls),
3638 )
3639 }
3640
3641 #[must_use]
3649 pub fn with_controls(
3650 paths: AppPaths,
3651 identity: ServiceIdentity,
3652 controls: std::sync::Arc<dyn ControlFactory>,
3653 ) -> Self {
3654 Self {
3655 paths,
3656 identity,
3657 controls,
3658 runner_root: None,
3659 }
3660 }
3661
3662 #[must_use]
3681 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
3682 self.runner_root = Some(root);
3683 self
3684 }
3685
3686 #[must_use]
3688 pub const fn paths(&self) -> &AppPaths {
3689 &self.paths
3690 }
3691
3692 #[must_use]
3694 pub const fn identity(&self) -> &ServiceIdentity {
3695 &self.identity
3696 }
3697
3698 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
3718 self.paths
3719 .create_all()
3720 .map_err(|source| ServiceError::Paths {
3721 source: Box::new(source),
3722 })?;
3723
3724 let _guard = self.refuse_while_an_agent_runs()?;
3727
3728 let replacing = match self.find_registration()? {
3757 Some((existing, _)) if existing == request.start_mode() => true,
3758 Some((existing, _)) => {
3759 return Err(ServiceError::AlreadyInstalled {
3760 name: self.identity.name().to_string(),
3761 existing,
3762 requested: request.start_mode(),
3763 });
3764 }
3765 None => false,
3766 };
3767
3768 let plan = InstallPlan::resolve(
3769 self.identity.clone(),
3770 request,
3771 ServiceDirectories::of(&self.paths),
3772 )?;
3773
3774 let control = self.controls.control(plan.start_mode())?;
3779
3780 let root = self.prepare_runner_root(plan.start_mode())?;
3786
3787 let previous = if replacing {
3792 InstallRecord::read(&self.paths).ok().flatten()
3793 } else {
3794 None
3795 };
3796 if replacing && let Err(cause) = control.uninstall(&self.identity) {
3797 return Err(undo_runner_root(&root, "install", &self.identity, cause));
3798 }
3799
3800 let definition = match control.install(&plan) {
3801 Ok(definition) => definition,
3802 Err(cause) => {
3803 let restored = self.reinstate(control.as_ref(), previous.as_ref());
3806 return Err(rolled_back(
3807 retained_runner_root(&root),
3808 restored,
3809 "install",
3810 &self.identity,
3811 cause,
3812 ));
3813 }
3814 };
3815 let review = review_least_privilege(&definition, &plan);
3816 let record = InstallRecord::of(&plan, &definition, Utc::now());
3817 if let Err(cause) = record.write(&self.paths) {
3818 return Err(rolled_back(
3819 retained_runner_root(&root),
3820 control.uninstall(&self.identity),
3821 "install",
3822 &self.identity,
3823 cause,
3824 ));
3825 }
3826 Ok(Installed {
3827 plan,
3828 definition,
3829 record,
3830 review,
3831 runner_root: root.summary().clone(),
3832 replaced_existing: replacing,
3833 })
3834 }
3835
3836 fn reinstate(
3847 &self,
3848 control: &dyn ServiceControl,
3849 previous: Option<&InstallRecord>,
3850 ) -> Result<(), ServiceError> {
3851 let Some(record) = previous else {
3852 return Ok(());
3853 };
3854 let plan = InstallPlan::unchecked(
3855 self.identity.clone(),
3856 record.start_mode,
3857 record.binary.clone(),
3858 record.directories.clone(),
3859 )
3860 .with_arguments(record.arguments.clone())
3861 .with_restart(record.restart());
3862 let plan = if record.starts_on_demand {
3863 plan.started_on_demand()
3864 } else {
3865 plan
3866 };
3867 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode) {
3868 Ok(store) => plan.with_secret_guard(store.guard()),
3869 Err(_) => plan,
3870 };
3871 control.install(&plan).map(|_| ())
3872 }
3873
3874 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
3883 let record = InstallRecord::read(&self.paths).ok().flatten();
3884 let removed_definition = record
3885 .as_ref()
3886 .and_then(|record| record.definition_path.clone());
3887
3888 let mut removed_registration = false;
3889 for mode in [StartMode::Boot, StartMode::Login] {
3893 let control = self.controls.control(mode)?;
3894 if control.uninstall(&self.identity)? {
3895 removed_registration = true;
3896 }
3897 }
3898 let removed_record = InstallRecord::remove(&self.paths)?;
3899 Ok(Uninstalled {
3900 removed_registration,
3901 removed_record,
3902 removed_definition: removed_definition.filter(|_| removed_registration),
3903 preserved: self
3904 .paths
3905 .all()
3906 .iter()
3907 .map(|(_, path)| (*path).to_path_buf())
3908 .collect(),
3909 })
3910 }
3911
3912 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
3934 let Some(record) = InstallRecord::read(&self.paths)? else {
3935 return Err(ServiceError::NotInstalled {
3936 name: self.identity.name().to_string(),
3937 operation: "switch the start mode of",
3938 });
3939 };
3940 let from = record.start_mode;
3941 if from == to {
3942 return Ok(StartModeChange {
3946 from,
3947 to,
3948 changed: false,
3949 store_scope: crate::secrets::SecretScope::for_start_mode(to),
3950 runner_root: RootAccessSummary::NotApplicable,
3951 });
3952 }
3953
3954 #[cfg(windows)]
3955 let arguments = {
3956 let mut arguments = record.arguments.clone();
3957 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
3958 if to == StartMode::Boot {
3959 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
3960 }
3961 arguments
3962 };
3963 #[cfg(not(windows))]
3964 let arguments = record.arguments.clone();
3965 let plan = InstallPlan::unchecked(
3966 self.identity.clone(),
3967 to,
3968 record.binary.clone(),
3969 record.directories.clone(),
3970 )
3971 .with_arguments(arguments)
3972 .with_restart(record.restart());
3973 let plan = if record.starts_on_demand {
3974 plan.started_on_demand()
3975 } else {
3976 plan
3977 };
3978 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
3979 Ok(store) => plan.with_secret_guard(store.guard()),
3980 Err(_) => plan,
3981 };
3982
3983 let target = self.controls.control(to)?;
3990 let previous = self.controls.control(from)?;
3995
3996 let root = self.prepare_runner_root(to)?;
4004
4005 let definition = match target.install(&plan) {
4006 Ok(definition) => definition,
4007 Err(cause) => {
4008 return Err(undo_runner_root(
4009 &root,
4010 "switch start mode",
4011 &self.identity,
4012 cause,
4013 ));
4014 }
4015 };
4016 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4017 if let Err(cause) = next_record.write(&self.paths) {
4018 return Err(rolled_back(
4019 retained_runner_root(&root),
4020 target.uninstall(&self.identity),
4021 "switch start mode",
4022 &self.identity,
4023 cause,
4024 ));
4025 }
4026
4027 if let Err(cause) = previous.uninstall(&self.identity) {
4031 let target_rollback = target.uninstall(&self.identity);
4032 let record_rollback = record.write(&self.paths);
4033 return Err(rolled_back(
4034 retained_runner_root(&root),
4035 target_rollback.and(record_rollback),
4036 "switch start mode",
4037 &self.identity,
4038 cause,
4039 ));
4040 }
4041 Ok(StartModeChange {
4042 from,
4043 to,
4044 changed: true,
4045 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4046 runner_root: root.summary().clone(),
4047 })
4048 }
4049
4050 pub fn start(&self) -> Result<(), ServiceError> {
4056 let Some((mode, _)) = self.find_registration()? else {
4057 return Err(ServiceError::NotInstalled {
4058 name: self.identity.name().to_string(),
4059 operation: "start",
4060 });
4061 };
4062 self.controls.control(mode)?.start(&self.identity)
4063 }
4064
4065 pub fn stop(&self) -> Result<bool, ServiceError> {
4071 let Some((mode, _)) = self.find_registration()? else {
4072 return Err(ServiceError::NotInstalled {
4073 name: self.identity.name().to_string(),
4074 operation: "stop",
4075 });
4076 };
4077 self.controls.control(mode)?.stop(&self.identity)
4078 }
4079
4080 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4099 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4100 Ok(record) => (record, None),
4101 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4102 Err(error) => return Err(error),
4103 };
4104 let found = self.find_registration()?;
4105 let last_github_contact = last_github_contact(&self.paths)?;
4106 Ok(ServiceStatus::compose(
4107 self.identity.clone(),
4108 record,
4109 record_refused.as_ref(),
4110 found.map(|(_, registration)| registration),
4111 last_github_contact,
4112 &self.paths,
4113 ))
4114 }
4115
4116 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4126 #[cfg(not(windows))]
4127 {
4128 let _ = (mode, &self.runner_root);
4131 Ok(RootAccessChange::not_applicable())
4132 }
4133 #[cfg(windows)]
4134 {
4135 let wrap = |source| ServiceError::RunnerRoot {
4136 source: Box::new(source),
4137 };
4138 if let Some(root) = self
4156 .runner_root
4157 .as_ref()
4158 .filter(|_| self.identity.is_fixture() || cfg!(test))
4159 {
4160 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4161 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4162 .map_err(wrap);
4163 }
4164
4165 let admission = match ServiceAccount::for_start_mode(mode) {
4166 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4169 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4173 RootAdmission::of_this_account().map_err(wrap)?
4174 }
4175 };
4176 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4177 }
4178 }
4179
4180 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4182 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4183 |source| match source {
4184 held @ LockError::Held { .. } => ServiceError::LockHeld {
4185 source: Box::new(held),
4186 },
4187 other => ServiceError::LockUnreadable {
4188 source: Box::new(other),
4189 },
4190 },
4191 )
4192 }
4193
4194 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4196 for mode in [StartMode::Boot, StartMode::Login] {
4197 let control = self.controls.control(mode)?;
4198 if let Some(registration) = control.query(&self.identity)? {
4199 return Ok(Some((registration.start_mode, registration)));
4200 }
4201 }
4202 Ok(None)
4203 }
4204}
4205
4206#[derive(Debug, Clone, PartialEq, Eq)]
4212pub struct StatusProblem {
4213 pub subject: &'static str,
4215 pub detail: String,
4217}
4218
4219impl fmt::Display for StatusProblem {
4220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4221 write!(f, "{}: {}", self.subject, self.detail)
4222 }
4223}
4224
4225#[derive(Debug, Clone)]
4234pub struct ServiceStatus {
4235 identity: ServiceIdentity,
4236 record: Option<InstallRecord>,
4237 registration: Option<Registration>,
4238 binary: Option<BinaryPath>,
4239 log_file: PathBuf,
4240 store: Option<crate::secrets::ActiveStore>,
4241 last_github_contact: Option<DateTime<Utc>>,
4242 runner_root: Option<(PathBuf, RootAccessReport)>,
4243 problems: Vec<StatusProblem>,
4244 notes: Vec<String>,
4245}
4246
4247impl ServiceStatus {
4248 fn compose(
4253 identity: ServiceIdentity,
4254 record: Option<InstallRecord>,
4255 record_refused: Option<&ServiceError>,
4256 registration: Option<Registration>,
4257 last_github_contact: Option<DateTime<Utc>>,
4258 paths: &AppPaths,
4259 ) -> Self {
4260 let mut problems = Vec::new();
4261 let mut notes = Vec::new();
4262
4263 if let Some(refusal) = record_refused {
4264 problems.push(StatusProblem {
4269 subject: "install record",
4270 detail: refusal.to_string(),
4271 });
4272 }
4273
4274 let log_file = record.as_ref().map_or_else(
4275 || ServiceDirectories::of(paths).log_file(),
4276 |record| record.log_file.clone(),
4277 );
4278
4279 let binary = record.as_ref().map(|record| {
4280 inspect_binary(
4281 &record.binary,
4282 registration
4283 .as_ref()
4284 .and_then(Registration::binary)
4285 .as_deref(),
4286 )
4287 });
4288 if let Some(state) = &binary
4289 && state.is_error()
4290 {
4291 problems.push(StatusProblem {
4292 subject: "binary",
4293 detail: state.to_string(),
4294 });
4295 }
4296
4297 match (&record, ®istration) {
4298 (Some(_), None) => problems.push(StatusProblem {
4299 subject: "registration",
4300 detail: "this host has a service record but no service manager knows the \
4301 registration. Run `service install` again; it deletes nothing."
4302 .to_string(),
4303 }),
4304 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4309 subject: "record",
4310 detail: format!(
4311 "{} holds a registration for this service but there is no install record, so \
4312 the path it was installed from and the directories it was installed against \
4313 are unknown. Run `service uninstall` and `service install`.",
4314 found.manager
4315 ),
4316 }),
4317 (None, Some(_)) => {}
4318 (Some(record), Some(found)) => {
4319 if record.start_mode != found.start_mode {
4320 problems.push(StatusProblem {
4321 subject: "start mode",
4322 detail: format!(
4323 "the record says {} and {} holds a {} registration. Switch the start \
4324 mode again to make them agree.",
4325 record.start_mode, found.manager, found.start_mode
4326 ),
4327 });
4328 }
4329 if record.start_mode == StartMode::Boot && !found.starts_automatically {
4330 problems.push(StatusProblem {
4331 subject: "start mode",
4332 detail: format!(
4333 "{} holds the registration but will not start it by itself, so this \
4334 host does not resume work after a reboot.",
4335 found.manager
4336 ),
4337 });
4338 }
4339 if let Some(actual) = found.restart_delay {
4340 let expected = record.restart().effective_delay(found.manager);
4341 if actual != expected {
4342 problems.push(StatusProblem {
4343 subject: "restart policy",
4344 detail: format!(
4345 "the record says the service restarts after {}s and {} reports \
4346 {}s. Something has edited the registration since it was \
4347 installed.",
4348 expected.as_secs(),
4349 found.manager,
4350 actual.as_secs()
4351 ),
4352 });
4353 } else if expected != record.restart().delay() {
4354 notes.push(format!(
4359 "{} expresses the restart delay in whole minutes, so the {}s asked \
4360 for is enforced as {}s. The service therefore never restarts faster \
4361 than the configured bound.",
4362 found.manager,
4363 record.restart().delay().as_secs(),
4364 expected.as_secs()
4365 ));
4366 }
4367 }
4368 }
4369 (None, None) => {}
4370 }
4371
4372 let store = record.as_ref().and_then(|record| {
4377 let registered_mode = registration
4378 .as_ref()
4379 .map_or(record.start_mode, |found| found.start_mode);
4380 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4381 .ok()
4382 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4383 });
4384 if let Some(store) = &store
4385 && !store.agrees_with_start_mode()
4386 {
4387 problems.push(StatusProblem {
4388 subject: "secret store",
4389 detail: format!(
4390 "{store}. Run `auth login` again so the token is stored where the registered \
4391 start mode can read it."
4392 ),
4393 });
4394 }
4395
4396 if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4397 notes.push(
4398 "This registration starts at login, so the agent does not run until the operator \
4399 signs in; this host does not resume work after an unattended reboot."
4400 .to_string(),
4401 );
4402 }
4403 if last_github_contact.is_none() {
4404 notes.push(
4405 "GitHub has not been reached successfully since this host's state directory was \
4406 created."
4407 .to_string(),
4408 );
4409 }
4410
4411 let runner_root = crate::runner_root::default_runner_root(paths)
4418 .ok()
4419 .map(|root| {
4420 let path = root.as_path().to_path_buf();
4421 let report = crate::runner_root_access::report(&path);
4422 (path, report)
4423 });
4424 if let Some((
4434 path,
4435 RootAccessReport::Present {
4436 broad_write: true, ..
4437 },
4438 )) = &runner_root
4439 {
4440 notes.push(format!(
4441 "the platform default runner root {} can be written by ordinary local users, so \
4442 it is not a safe place to run jobs. `service install` refuses it rather than \
4443 tightening it, because the contents of a directory anybody could write cannot \
4444 be trusted: remove or empty it, or choose another root with `runner-manager \
4445 host set-runtime-root --path <PATH>`.",
4446 path.display()
4447 ));
4448 }
4449
4450 match runner_root_refusals(paths) {
4473 Ok(refusals) => {
4474 for refusal in refusals {
4475 notes.push(format!(
4476 "policy {} started no runner: its runner root {} refused the launch \
4477 ({}), last at {}. {} This clears when that policy next places a \
4478 runner.",
4479 refusal.policy,
4480 refusal.root,
4481 refusal.kind,
4482 refusal.at.to_rfc3339(),
4483 refusal.detail,
4484 ));
4485 }
4486 }
4487 Err(error) => notes.push(format!(
4488 "whether the agent could use its runner roots could not be read: {error}"
4489 )),
4490 }
4491
4492 Self {
4493 identity,
4494 record,
4495 registration,
4496 binary,
4497 log_file,
4498 store,
4499 last_github_contact,
4500 runner_root,
4501 problems,
4502 notes,
4503 }
4504 }
4505
4506 #[must_use]
4514 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
4515 self.runner_root
4516 .as_ref()
4517 .map(|(path, report)| (path.as_path(), report))
4518 }
4519
4520 #[must_use]
4522 pub const fn is_installed(&self) -> bool {
4523 self.registration.is_some() || self.record.is_some()
4524 }
4525
4526 #[must_use]
4528 pub fn is_running(&self) -> bool {
4529 self.registration
4530 .as_ref()
4531 .is_some_and(|registration| registration.running)
4532 }
4533
4534 #[must_use]
4536 pub fn is_healthy(&self) -> bool {
4537 self.problems.is_empty()
4538 }
4539
4540 #[must_use]
4542 pub fn problems(&self) -> &[StatusProblem] {
4543 &self.problems
4544 }
4545
4546 #[must_use]
4549 pub fn notes(&self) -> &[String] {
4550 &self.notes
4551 }
4552
4553 #[must_use]
4555 pub fn start_mode(&self) -> Option<StartMode> {
4556 self.record.as_ref().map(|record| record.start_mode)
4557 }
4558
4559 #[must_use]
4562 pub const fn binary(&self) -> Option<&BinaryPath> {
4563 self.binary.as_ref()
4564 }
4565
4566 #[must_use]
4568 pub fn log_file(&self) -> &Path {
4569 &self.log_file
4570 }
4571
4572 #[must_use]
4574 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
4575 self.last_github_contact
4576 }
4577
4578 #[must_use]
4581 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
4582 self.store.as_ref()
4583 }
4584
4585 #[must_use]
4587 pub const fn record(&self) -> Option<&InstallRecord> {
4588 self.record.as_ref()
4589 }
4590
4591 #[must_use]
4593 pub const fn registration(&self) -> Option<&Registration> {
4594 self.registration.as_ref()
4595 }
4596}
4597
4598impl fmt::Display for ServiceStatus {
4599 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4600 writeln!(f, "Service: {}", self.identity)?;
4601 match (&self.record, &self.registration) {
4602 (None, None) => {
4603 writeln!(
4604 f,
4605 " installed no. `service install` registers `{} {}`.",
4606 SERVICE_NAME,
4607 DAEMON_ARGUMENTS.join(" ")
4608 )?;
4609 }
4610 _ => {
4611 let manager = self
4612 .registration
4613 .as_ref()
4614 .map(|registration| registration.manager.manager());
4615 writeln!(
4616 f,
4617 " installed {}",
4618 manager.unwrap_or("yes, but no service manager knows it")
4619 )?;
4620 writeln!(
4621 f,
4622 " state {}",
4623 if self.is_running() {
4624 "running"
4625 } else {
4626 "not running"
4627 }
4628 )?;
4629 }
4630 }
4631 if let Some(record) = &self.record {
4632 writeln!(f, " start mode {}", record.start_mode)?;
4633 writeln!(f, " account {}", record.account)?;
4634 writeln!(f, " restart on failure {}", record.restart())?;
4635 writeln!(
4636 f,
4637 " arguments {}",
4638 record.arguments.join(" ")
4639 )?;
4640 }
4641 if let Some(binary) = &self.binary {
4642 writeln!(f, " binary {binary}")?;
4643 }
4644 writeln!(f, " diagnostic log {}", self.log_file.display())?;
4645 if let Some((path, report)) = &self.runner_root {
4646 writeln!(f, " default runner root {}", path.display())?;
4653 if *report != RootAccessReport::NotApplicable {
4654 writeln!(f, " default root access {report}")?;
4655 }
4656 }
4657 if let Some(store) = &self.store {
4658 writeln!(f, " secret store {store}")?;
4659 }
4660 writeln!(
4661 f,
4662 " last GitHub contact {}",
4663 match self.last_github_contact {
4664 Some(at) => at.to_rfc3339(),
4665 None => "never".to_string(),
4666 }
4667 )?;
4668 for note in &self.notes {
4669 writeln!(f, " note {note}")?;
4670 }
4671 for problem in &self.problems {
4672 writeln!(f, " ERROR {problem}")?;
4673 }
4674 write!(
4675 f,
4676 " verdict {}",
4677 if self.is_healthy() {
4678 "healthy"
4679 } else {
4680 "NOT healthy"
4681 }
4682 )
4683 }
4684}
4685
4686#[derive(Debug, Clone, Default)]
4703pub struct RecordingControls {
4704 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
4705}
4706
4707#[derive(Debug, Default)]
4708struct RecordingState {
4709 registrations: BTreeMap<(StartMode, String), Registration>,
4710 definitions: BTreeMap<String, ServiceDefinition>,
4711 calls: Vec<String>,
4712 #[cfg(test)]
4713 install_failures: BTreeMap<StartMode, String>,
4714 #[cfg(test)]
4715 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
4716}
4717
4718#[cfg(test)]
4719#[derive(Debug, Clone)]
4720enum TestInstallSideEffect {
4721 HideDirectory { directory: PathBuf, hidden: PathBuf },
4722}
4723
4724impl RecordingControls {
4725 #[must_use]
4727 pub fn new() -> Self {
4728 Self::default()
4729 }
4730
4731 #[must_use]
4734 pub fn calls(&self) -> Vec<String> {
4735 self.state.lock().expect("not poisoned").calls.clone()
4736 }
4737
4738 #[must_use]
4740 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
4741 self.state
4742 .lock()
4743 .expect("not poisoned")
4744 .registrations
4745 .iter()
4746 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
4747 .collect()
4748 }
4749
4750 #[must_use]
4752 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
4753 self.state
4754 .lock()
4755 .expect("not poisoned")
4756 .definitions
4757 .get(name)
4758 .cloned()
4759 }
4760
4761 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
4771 let mut state = self.state.lock().expect("not poisoned");
4772 if let Some((_, registration)) = state
4773 .registrations
4774 .iter_mut()
4775 .find(|((_, held), _)| held == name)
4776 {
4777 edit(registration);
4778 }
4779 }
4780
4781 #[cfg(test)]
4782 fn fail_next_install(&self, mode: StartMode, detail: &str) {
4783 self.state
4784 .lock()
4785 .expect("not poisoned")
4786 .install_failures
4787 .insert(mode, detail.to_string());
4788 }
4789
4790 #[cfg(test)]
4791 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
4792 self.state
4793 .lock()
4794 .expect("not poisoned")
4795 .after_install
4796 .insert(
4797 mode,
4798 TestInstallSideEffect::HideDirectory { directory, hidden },
4799 );
4800 }
4801}
4802
4803impl ControlFactory for RecordingControls {
4804 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
4805 Ok(Box::new(RecordingControl {
4806 mode,
4807 state: std::sync::Arc::clone(&self.state),
4808 }))
4809 }
4810}
4811
4812#[derive(Debug)]
4813struct RecordingControl {
4814 mode: StartMode,
4815 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
4816}
4817
4818impl RecordingControl {
4819 fn note(&self, operation: &str, name: &str) {
4820 self.state
4821 .lock()
4822 .expect("not poisoned")
4823 .calls
4824 .push(format!("{operation} {name} ({})", self.mode));
4825 }
4826}
4827
4828impl ServiceControl for RecordingControl {
4829 fn manager(&self) -> DefinitionKind {
4830 host_definition_kind(self.mode)
4834 }
4835
4836 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
4837 self.note("install", plan.identity().name());
4838 #[cfg(test)]
4839 if let Some(detail) = self
4840 .state
4841 .lock()
4842 .expect("not poisoned")
4843 .install_failures
4844 .remove(&self.mode)
4845 {
4846 return Err(ServiceError::Control {
4847 operation: "install",
4848 name: plan.identity().name().to_string(),
4849 manager: "recording control",
4850 detail,
4851 });
4852 }
4853 let definition = ServiceDefinition::for_host(plan)?;
4858 let mut state = self.state.lock().expect("not poisoned");
4859 state.registrations.insert(
4860 (self.mode, plan.identity().name().to_string()),
4861 Registration {
4862 manager: host_definition_kind(self.mode),
4863 start_mode: self.mode,
4864 command_line: plan.command_line(),
4865 account: Some(plan.account().as_str().to_string()),
4866 running: false,
4867 starts_automatically: true,
4868 restart_delay: Some(plan.restart().delay()),
4869 },
4870 );
4871 state
4872 .definitions
4873 .insert(plan.identity().name().to_string(), definition.clone());
4874 #[cfg(test)]
4875 let side_effect = state.after_install.remove(&self.mode);
4876 drop(state);
4877 #[cfg(test)]
4878 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
4879 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
4880 std::fs::write(&directory, b"blocks recreation")
4881 .expect("test fault can block record directory recreation");
4882 }
4883 Ok(definition)
4884 }
4885
4886 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
4887 self.note("uninstall", identity.name());
4888 let mut state = self.state.lock().expect("not poisoned");
4889 state.definitions.remove(identity.name());
4890 Ok(state
4891 .registrations
4892 .remove(&(self.mode, identity.name().to_string()))
4893 .is_some())
4894 }
4895
4896 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
4897 self.note("query", identity.name());
4898 Ok(self
4899 .state
4900 .lock()
4901 .expect("not poisoned")
4902 .registrations
4903 .get(&(self.mode, identity.name().to_string()))
4904 .cloned())
4905 }
4906
4907 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
4908 self.note("start", identity.name());
4909 let mut state = self.state.lock().expect("not poisoned");
4910 match state
4911 .registrations
4912 .get_mut(&(self.mode, identity.name().to_string()))
4913 {
4914 Some(registration) => {
4915 registration.running = true;
4916 Ok(())
4917 }
4918 None => Err(ServiceError::NotInstalled {
4919 name: identity.name().to_string(),
4920 operation: "start",
4921 }),
4922 }
4923 }
4924
4925 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
4926 self.note("stop", identity.name());
4927 let mut state = self.state.lock().expect("not poisoned");
4928 match state
4929 .registrations
4930 .get_mut(&(self.mode, identity.name().to_string()))
4931 {
4932 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
4933 None => Err(ServiceError::NotInstalled {
4934 name: identity.name().to_string(),
4935 operation: "stop",
4936 }),
4937 }
4938 }
4939}
4940
4941impl ControlFactory for HostControls {
4942 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
4943 sys::control(mode)
4944 }
4945}
4946
4947fn host_home() -> Option<PathBuf> {
4954 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
4955}
4956
4957#[cfg(unix)]
4959fn home_directory() -> Option<PathBuf> {
4960 host_home()
4961}
4962
4963fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
4972 let output = std::process::Command::new(program)
4973 .args(arguments)
4974 .output()?;
4975 Ok((
4976 output.status.success(),
4977 String::from_utf8_lossy(&output.stdout).into_owned(),
4978 String::from_utf8_lossy(&output.stderr).into_owned(),
4979 ))
4980}
4981
4982#[must_use]
4985pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
4986 if cfg!(windows) {
4987 match mode {
4988 StartMode::Boot => DefinitionKind::WindowsService,
4989 StartMode::Login => DefinitionKind::WindowsScheduledTask,
4990 }
4991 } else if cfg!(target_os = "macos") {
4992 DefinitionKind::LaunchdPlist
4993 } else {
4994 DefinitionKind::SystemdUnit
4995 }
4996}
4997
4998#[cfg(any(target_os = "macos", test))]
5005fn enable_launchd_registration(
5006 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5007 domain: &str,
5008 service_target: &str,
5009 plist: &Path,
5010 name: &str,
5011 elevation_remedy: &'static str,
5012) -> Result<(), ServiceError> {
5013 let (enabled, cause) = launchctl(&[
5014 std::ffi::OsStr::new("enable"),
5015 std::ffi::OsStr::new(service_target),
5016 ]);
5017 if enabled {
5018 return Ok(());
5019 }
5020
5021 let (booted_out, bootout_detail) = launchctl(&[
5022 std::ffi::OsStr::new("bootout"),
5023 std::ffi::OsStr::new(service_target),
5024 ]);
5025 let removed = std::fs::remove_file(plist);
5026 if !booted_out || removed.is_err() {
5027 return Err(ServiceError::Rollback {
5028 operation: "enable launchd registration",
5029 name: name.to_string(),
5030 cause,
5031 rollback: format!(
5032 "launchctl bootout {domain}: {}; remove {}: {}",
5033 if booted_out {
5034 "succeeded".to_string()
5035 } else {
5036 bootout_detail
5037 },
5038 plist.display(),
5039 removed
5040 .err()
5041 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5042 ),
5043 });
5044 }
5045
5046 if cause.to_ascii_lowercase().contains("permission denied") {
5047 Err(ServiceError::NeedsElevation {
5048 operation: "enable",
5049 name: name.to_string(),
5050 detail: cause,
5051 remedy: elevation_remedy,
5052 })
5053 } else {
5054 Err(ServiceError::Control {
5055 operation: "enable",
5056 name: name.to_string(),
5057 manager: "launchd",
5058 detail: cause,
5059 })
5060 }
5061}
5062
5063#[derive(Debug)]
5072pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5073
5074impl ServiceShutdown {
5075 pub async fn wait(mut self) {
5077 if !*self.0.borrow() {
5078 let _ = self.0.changed().await;
5079 }
5080 }
5081}
5082
5083#[cfg(windows)]
5090pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5091where
5092 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5093{
5094 windows_host::run(Box::new(run))
5095}
5096
5097#[cfg(windows)]
5098mod windows_host {
5099 use std::ffi::OsString;
5100 use std::sync::{Arc, Mutex, OnceLock, mpsc};
5101 use std::time::Duration;
5102
5103 use windows_service::service::{
5104 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5105 ServiceType,
5106 };
5107 use windows_service::service_control_handler::{
5108 self, ServiceControlHandlerResult, ServiceStatusHandle,
5109 };
5110
5111 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5112
5113 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5114
5115 struct Invocation {
5116 run: Runner,
5117 result: mpsc::SyncSender<Result<u8, String>>,
5118 }
5119
5120 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5121
5122 windows_service::define_windows_service!(ffi_service_main, service_main);
5123
5124 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5125 let (result_tx, result_rx) = mpsc::sync_channel(1);
5126 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5127 let mut invocation = slot
5128 .lock()
5129 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5130 if invocation.is_some() {
5131 return Err(host_error(
5132 "prepare",
5133 "the service-host slot was already used",
5134 ));
5135 }
5136 *invocation = Some(Invocation {
5137 run,
5138 result: result_tx,
5139 });
5140 drop(invocation);
5141
5142 windows_service::service_dispatcher::start("", ffi_service_main)
5143 .map_err(|error| host_error("connect", &error.to_string()))?;
5144 result_rx
5145 .recv()
5146 .map_err(|error| host_error("finish", &error.to_string()))?
5147 .map_err(|detail| host_error("run", &detail))
5148 }
5149
5150 fn service_main(_arguments: Vec<OsString>) {
5151 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5152 return;
5153 };
5154 let result = run_service(invocation.run);
5155 let _ = invocation.result.send(result);
5156 }
5157
5158 fn run_service(run: Runner) -> Result<u8, String> {
5159 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5160 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5161 let handler_status = Arc::clone(&status);
5162 let handler = move |control| match control {
5163 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5164 ServiceControl::Stop | ServiceControl::Shutdown => {
5165 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5166 let _ = handle.set_service_status(service_status(
5167 ServiceState::StopPending,
5168 ServiceControlAccept::empty(),
5169 1,
5170 Duration::from_secs(300),
5171 0,
5172 ));
5173 }
5174 let _ = stop_tx.send(true);
5175 ServiceControlHandlerResult::NoError
5176 }
5177 _ => ServiceControlHandlerResult::NotImplemented,
5178 };
5179 let handle = service_control_handler::register("", handler)
5180 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5181 *status
5182 .lock()
5183 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5184 handle
5185 .set_service_status(service_status(
5186 ServiceState::Running,
5187 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5188 0,
5189 Duration::default(),
5190 0,
5191 ))
5192 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5193
5194 let exit = run(ServiceShutdown(stop_rx));
5195 handle
5196 .set_service_status(service_status(
5197 ServiceState::Stopped,
5198 ServiceControlAccept::empty(),
5199 0,
5200 Duration::default(),
5201 u32::from(exit),
5202 ))
5203 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5204 Ok(exit)
5205 }
5206
5207 fn service_status(
5208 state: ServiceState,
5209 accepted: ServiceControlAccept,
5210 checkpoint: u32,
5211 wait_hint: Duration,
5212 exit: u32,
5213 ) -> ServiceStatus {
5214 ServiceStatus {
5215 service_type: ServiceType::OWN_PROCESS,
5216 current_state: state,
5217 controls_accepted: accepted,
5218 exit_code: if exit == 0 {
5219 ServiceExitCode::Win32(0)
5220 } else {
5221 ServiceExitCode::ServiceSpecific(exit)
5222 },
5223 checkpoint,
5224 wait_hint,
5225 process_id: None,
5226 }
5227 }
5228
5229 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5230 ServiceError::Control {
5231 operation,
5232 name: SERVICE_NAME.to_string(),
5233 manager: "the Windows Service Control Manager",
5234 detail: detail.to_string(),
5235 }
5236 }
5237}
5238
5239#[cfg(windows)]
5240mod sys {
5241 use std::ffi::{OsStr, OsString};
5251 use std::time::{Duration, Instant};
5252
5253 use runner_manager_domain::model::StartMode;
5254 use windows_service::service::{
5255 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5256 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5257 ServiceState, ServiceType,
5258 };
5259 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5260
5261 use super::{
5262 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5263 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5264 };
5265
5266 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5269 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5273 const ACCESS_DENIED: i32 = 5;
5275 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5276 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5277
5278 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5279 choose \"Run as administrator\".";
5280
5281 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5282 Ok(match mode {
5283 StartMode::Boot => Box::new(ScmControl),
5284 StartMode::Login => Box::new(TaskControl),
5285 })
5286 }
5287
5288 #[derive(Debug)]
5291 struct ScmControl;
5292
5293 fn scm_error(
5297 operation: &'static str,
5298 name: &str,
5299 error: &windows_service::Error,
5300 ) -> ServiceError {
5301 let raw = match error {
5302 windows_service::Error::Winapi(io) => io.raw_os_error(),
5303 _ => None,
5304 };
5305 let detail = match error {
5306 windows_service::Error::Winapi(io) => io.to_string(),
5307 other => other.to_string(),
5308 };
5309 if raw == Some(ACCESS_DENIED) {
5310 return ServiceError::NeedsElevation {
5311 operation,
5312 name: name.to_string(),
5313 detail,
5314 remedy: ELEVATION_REMEDY,
5315 };
5316 }
5317 ServiceError::Control {
5318 operation,
5319 name: name.to_string(),
5320 manager: "the Windows Service Control Manager",
5321 detail,
5322 }
5323 }
5324
5325 fn open_manager(
5326 access: ServiceManagerAccess,
5327 operation: &'static str,
5328 name: &str,
5329 ) -> Result<ServiceManager, ServiceError> {
5330 ServiceManager::local_computer(None::<&OsStr>, access)
5331 .map_err(|error| scm_error(operation, name, &error))
5332 }
5333
5334 impl ServiceControl for ScmControl {
5335 fn manager(&self) -> DefinitionKind {
5336 DefinitionKind::WindowsService
5337 }
5338
5339 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5340 let spec = windows_service_spec(plan);
5341 let manager = open_manager(
5342 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5343 "install",
5344 &spec.name,
5345 )?;
5346 let info = ServiceInfo {
5347 name: OsString::from(&spec.name),
5348 display_name: OsString::from(&spec.display_name),
5349 service_type: ServiceType::OWN_PROCESS,
5351 start_type: if spec.automatic_start {
5352 ServiceStartType::AutoStart
5353 } else {
5354 ServiceStartType::OnDemand
5355 },
5356 error_control: ServiceErrorControl::Normal,
5357 executable_path: plan.binary().to_path_buf(),
5358 launch_arguments: plan.arguments().to_vec(),
5359 dependencies: Vec::new(),
5360 account_name: spec.account.as_ref().map(OsString::from),
5362 account_password: None,
5365 };
5366 let service = manager
5367 .create_service(
5368 &info,
5369 ServiceAccess::CHANGE_CONFIG
5370 | ServiceAccess::QUERY_CONFIG
5371 | ServiceAccess::QUERY_STATUS
5372 | ServiceAccess::START
5373 | ServiceAccess::STOP
5374 | ServiceAccess::DELETE,
5375 )
5376 .map_err(|error| scm_error("install", &spec.name, &error))?;
5377 service
5378 .set_description(&spec.description)
5379 .map_err(|error| scm_error("describe", &spec.name, &error))?;
5380 service
5381 .update_failure_actions(ServiceFailureActions {
5382 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5383 reboot_msg: None,
5384 command: None,
5385 actions: Some(vec![
5392 ServiceAction {
5393 action_type: ServiceActionType::Restart,
5394 delay: spec.restart.delay(),
5395 };
5396 3
5397 ]),
5398 })
5399 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5400 service
5403 .set_failure_actions_on_non_crash_failures(true)
5404 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5405 Ok(ServiceDefinition::windows_service(plan))
5406 }
5407
5408 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5409 let manager =
5410 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
5411 let service = match manager.open_service(
5412 identity.name(),
5413 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
5414 ) {
5415 Ok(service) => service,
5416 Err(error) if is_missing(&error) => return Ok(false),
5417 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
5418 };
5419 if let Ok(status) = service.query_status()
5423 && status.current_state != ServiceState::Stopped
5424 {
5425 let _ = service.stop();
5426 }
5427 service
5428 .delete()
5429 .map_err(|error| scm_error("uninstall", identity.name(), &error))?;
5430
5431 drop(service);
5438 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
5439 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
5440 Ok(service) => {
5441 drop(service);
5442 Ok(false)
5443 }
5444 Err(error) if is_missing(&error) => Ok(true),
5445 Err(error) if is_marked_for_delete(&error) => Ok(false),
5446 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
5447 }
5448 })?;
5449 if !absent {
5450 return Err(ServiceError::Control {
5451 operation: "verify uninstall of",
5452 name: identity.name().to_string(),
5453 manager: "the Windows Service Control Manager",
5454 detail: format!(
5455 "the registration was still visible {} seconds after DeleteService; \
5456 retry `service uninstall` from an elevated prompt",
5457 DELETE_TIMEOUT.as_secs()
5458 ),
5459 });
5460 }
5461 Ok(true)
5462 }
5463
5464 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5465 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
5466 let service = match manager.open_service(
5467 identity.name(),
5468 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
5469 ) {
5470 Ok(service) => service,
5471 Err(error) if is_missing(&error) => return Ok(None),
5472 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
5473 };
5474 let config = service
5475 .query_config()
5476 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5477 let status = service
5478 .query_status()
5479 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5480 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
5481 actions
5482 .actions
5483 .and_then(|actions| actions.into_iter().next())
5484 .filter(|action| action.action_type == ServiceActionType::Restart)
5485 .map(|action| action.delay)
5486 });
5487 Ok(Some(Registration {
5488 manager: DefinitionKind::WindowsService,
5489 start_mode: StartMode::Boot,
5490 command_line: config.executable_path.to_string_lossy().into_owned(),
5494 account: config
5495 .account_name
5496 .map(|account| account.to_string_lossy().into_owned()),
5497 running: status.current_state == ServiceState::Running,
5498 starts_automatically: config.start_type == ServiceStartType::AutoStart,
5499 restart_delay,
5500 }))
5501 }
5502
5503 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5504 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
5505 let service = manager
5506 .open_service(identity.name(), ServiceAccess::START)
5507 .map_err(|error| scm_error("start", identity.name(), &error))?;
5508 service
5509 .start::<&OsStr>(&[])
5510 .map_err(|error| scm_error("start", identity.name(), &error))
5511 }
5512
5513 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5514 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
5515 let service = manager
5516 .open_service(
5517 identity.name(),
5518 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
5519 )
5520 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5521 let status = service
5522 .query_status()
5523 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5524 if status.current_state == ServiceState::Stopped {
5525 return Ok(false);
5526 }
5527 service
5528 .stop()
5529 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5530 Ok(true)
5531 }
5532 }
5533
5534 fn is_missing(error: &windows_service::Error) -> bool {
5535 matches!(error, windows_service::Error::Winapi(io)
5536 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
5537 }
5538
5539 fn is_marked_for_delete(error: &windows_service::Error) -> bool {
5540 matches!(error, windows_service::Error::Winapi(io)
5541 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
5542 }
5543
5544 pub(super) fn wait_until_scm_absent(
5545 timeout: Duration,
5546 poll_interval: Duration,
5547 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
5548 ) -> Result<bool, ServiceError> {
5549 let deadline = Instant::now() + timeout;
5550 loop {
5551 if probe_absent()? {
5552 return Ok(true);
5553 }
5554 if Instant::now() >= deadline {
5555 return Ok(false);
5556 }
5557 std::thread::sleep(poll_interval);
5558 }
5559 }
5560
5561 #[derive(Debug)]
5564 struct TaskControl;
5565
5566 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
5567 if detail.to_ascii_lowercase().contains("access is denied") {
5568 return ServiceError::NeedsElevation {
5569 operation,
5570 name: name.to_string(),
5571 detail,
5572 remedy: ELEVATION_REMEDY,
5573 };
5574 }
5575 ServiceError::Control {
5576 operation,
5577 name: name.to_string(),
5578 manager: "Windows Task Scheduler",
5579 detail,
5580 }
5581 }
5582
5583 fn schtasks(
5584 operation: &'static str,
5585 name: &str,
5586 arguments: &[&OsStr],
5587 ) -> Result<(bool, String), ServiceError> {
5588 match run("schtasks.exe", arguments) {
5589 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
5590 Err(error) => Err(task_error(operation, name, error.to_string())),
5591 }
5592 }
5593
5594 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
5597 let mut bytes = vec![0xFF, 0xFE];
5598 for unit in text.encode_utf16() {
5599 bytes.extend_from_slice(&unit.to_le_bytes());
5600 }
5601 std::fs::write(path, bytes)
5602 }
5603
5604 impl ServiceControl for TaskControl {
5605 fn manager(&self) -> DefinitionKind {
5606 DefinitionKind::WindowsScheduledTask
5607 }
5608
5609 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5610 let name = plan.identity().name().to_string();
5611 let principal = TaskPrincipal::current()?;
5612 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
5613 let directory = tempfile::tempdir()
5614 .map_err(|error| task_error("install", &name, error.to_string()))?;
5615 let document = directory.path().join("task.xml");
5616 write_utf16(&document, definition.text())
5617 .map_err(|error| task_error("install", &name, error.to_string()))?;
5618 let (ok, message) = schtasks(
5619 "install",
5620 &name,
5621 &[
5622 OsStr::new("/Create"),
5623 OsStr::new("/TN"),
5624 OsStr::new(&name),
5625 OsStr::new("/XML"),
5626 document.as_os_str(),
5627 ],
5628 )?;
5629 if !ok {
5630 return Err(task_error("install", &name, message));
5631 }
5632 Ok(definition)
5633 }
5634
5635 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5636 if self.query(identity)?.is_none() {
5637 return Ok(false);
5638 }
5639 let name = identity.name().to_string();
5640 let (ok, message) = schtasks(
5641 "uninstall",
5642 &name,
5643 &[
5644 OsStr::new("/Delete"),
5645 OsStr::new("/TN"),
5646 OsStr::new(&name),
5647 OsStr::new("/F"),
5648 ],
5649 )?;
5650 if !ok {
5651 return Err(task_error("uninstall", &name, message));
5652 }
5653 Ok(true)
5654 }
5655
5656 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5657 let name = identity.name().to_string();
5658 let (ok, document) = schtasks(
5659 "inspect",
5660 &name,
5661 &[
5662 OsStr::new("/Query"),
5663 OsStr::new("/TN"),
5664 OsStr::new(&name),
5665 OsStr::new("/XML"),
5666 OsStr::new("ONE"),
5667 ],
5668 )?;
5669 if !ok {
5670 return Ok(None);
5676 }
5677 let command = xml_value(&document, "Command").unwrap_or_default();
5678 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
5679 let command_line = if arguments.is_empty() {
5680 super::quote_argument(&command)
5681 } else {
5682 format!("{} {arguments}", super::quote_argument(&command))
5683 };
5684 Ok(Some(Registration {
5685 manager: DefinitionKind::WindowsScheduledTask,
5686 start_mode: StartMode::Login,
5687 command_line,
5688 account: xml_value(&document, "UserId"),
5689 running: task_is_running(&name),
5690 starts_automatically: document.contains("<LogonTrigger>")
5691 && xml_value(&document, "Enabled").as_deref() == Some("true"),
5692 restart_delay: xml_value(&document, "Interval")
5693 .as_deref()
5694 .and_then(parse_iso8601),
5695 }))
5696 }
5697
5698 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5699 let name = identity.name().to_string();
5700 let (ok, message) = schtasks(
5701 "start",
5702 &name,
5703 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
5704 )?;
5705 if ok {
5706 Ok(())
5707 } else {
5708 Err(task_error("start", &name, message))
5709 }
5710 }
5711
5712 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5713 let running = self
5714 .query(identity)?
5715 .is_some_and(|registration| registration.running);
5716 if !running {
5717 return Ok(false);
5718 }
5719 let name = identity.name().to_string();
5720 let (ok, message) = schtasks(
5721 "stop",
5722 &name,
5723 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
5724 )?;
5725 if ok {
5726 Ok(true)
5727 } else {
5728 Err(task_error("stop", &name, message))
5729 }
5730 }
5731 }
5732
5733 fn task_is_running(name: &str) -> bool {
5747 let Ok((true, stdout, _)) = run(
5748 "schtasks.exe",
5749 &[
5750 OsStr::new("/Query"),
5751 OsStr::new("/TN"),
5752 OsStr::new(name),
5753 OsStr::new("/FO"),
5754 OsStr::new("CSV"),
5755 OsStr::new("/NH"),
5756 ],
5757 ) else {
5758 return false;
5759 };
5760 stdout
5761 .lines()
5762 .filter_map(|line| line.rsplit(',').next())
5763 .any(|status| {
5764 status
5765 .trim()
5766 .trim_matches('"')
5767 .eq_ignore_ascii_case("running")
5768 })
5769 }
5770
5771 fn parse_iso8601(value: &str) -> Option<Duration> {
5775 let rest = value.strip_prefix("PT")?;
5776 if let Some(minutes) = rest.strip_suffix('M') {
5777 return minutes
5778 .parse::<u64>()
5779 .ok()
5780 .map(|minutes| Duration::from_secs(minutes * 60));
5781 }
5782 rest.strip_suffix('S')?
5783 .parse::<u64>()
5784 .ok()
5785 .map(Duration::from_secs)
5786 }
5787}
5788
5789#[cfg(unix)]
5801fn write_definition(
5802 operation: &'static str,
5803 name: &str,
5804 path: &Path,
5805 text: &str,
5806 remedy: &'static str,
5807) -> Result<(), ServiceError> {
5808 if let Some(parent) = path.parent()
5809 && let Err(error) = std::fs::create_dir_all(parent)
5810 && error.kind() != std::io::ErrorKind::AlreadyExists
5811 {
5812 return Err(definition_error(operation, name, error, remedy, parent));
5813 }
5814 std::fs::write(path, text)
5815 .map_err(|error| definition_error(operation, name, error, remedy, path))
5816}
5817
5818#[cfg(unix)]
5819fn definition_error(
5820 operation: &'static str,
5821 name: &str,
5822 error: std::io::Error,
5823 remedy: &'static str,
5824 path: &Path,
5825) -> ServiceError {
5826 let detail = format!("{}: {error}", path.display());
5827 if error.kind() == std::io::ErrorKind::PermissionDenied {
5828 ServiceError::NeedsElevation {
5829 operation,
5830 name: name.to_string(),
5831 detail,
5832 remedy,
5833 }
5834 } else {
5835 ServiceError::Control {
5836 operation,
5837 name: name.to_string(),
5838 manager: "the local service manager",
5839 detail,
5840 }
5841 }
5842}
5843
5844#[cfg(unix)]
5846const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
5847 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
5848 agent not running until you sign in.";
5849
5850#[cfg(target_os = "macos")]
5855mod sys {
5856 use std::ffi::OsStr;
5869 use std::path::PathBuf;
5870 use std::time::Duration;
5871
5872 use runner_manager_domain::model::StartMode;
5873
5874 use super::{
5875 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
5876 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
5877 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
5878 write_definition, xml_value,
5879 };
5880
5881 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5882 Ok(Box::new(LaunchdControl { mode }))
5883 }
5884
5885 #[derive(Debug)]
5886 struct LaunchdControl {
5887 mode: StartMode,
5888 }
5889
5890 impl LaunchdControl {
5891 fn domain(&self) -> String {
5893 match self.mode {
5894 StartMode::Boot => "system".to_string(),
5895 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
5898 }
5899 }
5900
5901 fn service_target(&self, identity: &ServiceIdentity) -> String {
5902 format!("{}/{}", self.domain(), identity.launchd_label())
5903 }
5904
5905 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
5908 let file = format!("{}.plist", identity.launchd_label());
5909 match self.mode {
5910 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
5911 StartMode::Login => {
5912 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
5913 }
5914 }
5915 }
5916
5917 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
5918 ServiceError::Control {
5919 operation,
5920 name: name.to_string(),
5921 manager: "launchd",
5922 detail,
5923 }
5924 }
5925
5926 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
5927 match run("launchctl", arguments) {
5928 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
5929 Err(error) => (false, error.to_string()),
5930 }
5931 }
5932 }
5933
5934 impl ServiceControl for LaunchdControl {
5935 fn manager(&self) -> DefinitionKind {
5936 DefinitionKind::LaunchdPlist
5937 }
5938
5939 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5940 let name = plan.identity().name().to_string();
5941 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
5942 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
5943 return Err(self.failed(
5944 "install",
5945 &name,
5946 "this account has no home directory, so there is nowhere to put a \
5947 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
5948 /Library/LaunchDaemons."
5949 .to_string(),
5950 ));
5951 };
5952 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
5953 let target = self.domain();
5954 let (ok, message) = self.launchctl(&[
5955 OsStr::new("bootstrap"),
5956 OsStr::new(&target),
5957 path.as_os_str(),
5958 ]);
5959 if !ok {
5960 let _ = std::fs::remove_file(&path);
5964 if message.to_ascii_lowercase().contains("permission denied") {
5965 return Err(ServiceError::NeedsElevation {
5966 operation: "install",
5967 name,
5968 detail: message,
5969 remedy: SUDO_REMEDY,
5970 });
5971 }
5972 return Err(self.failed("install", &name, message));
5973 }
5974 let service_target = self.service_target(plan.identity());
5977 enable_launchd_registration(
5978 |arguments| self.launchctl(arguments),
5979 &target,
5980 &service_target,
5981 &path,
5982 &name,
5983 SUDO_REMEDY,
5984 )?;
5985 Ok(definition)
5986 }
5987
5988 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5989 let Some(path) = self.plist_path(identity) else {
5990 return Ok(false);
5991 };
5992 if !path.exists() {
5993 return Ok(false);
5994 }
5995 let target = self.service_target(identity);
5996 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
5997 if !ok
5998 && !message.to_ascii_lowercase().contains("no such process")
5999 && !message.contains("113")
6000 {
6001 if message.to_ascii_lowercase().contains("permission denied") {
6002 return Err(ServiceError::NeedsElevation {
6003 operation: "uninstall",
6004 name: identity.name().to_string(),
6005 detail: message,
6006 remedy: SUDO_REMEDY,
6007 });
6008 }
6009 return Err(self.failed("uninstall", identity.name(), message));
6010 }
6011 std::fs::remove_file(&path).map_err(|error| {
6014 super::definition_error(
6015 "uninstall",
6016 identity.name(),
6017 error,
6018 SUDO_REMEDY,
6019 path.as_path(),
6020 )
6021 })?;
6022 Ok(true)
6023 }
6024
6025 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6026 let Some(path) = self.plist_path(identity) else {
6027 return Ok(None);
6028 };
6029 let Ok(document) = std::fs::read_to_string(&path) else {
6030 return Ok(None);
6031 };
6032 let target = self.service_target(identity);
6033 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6034 Ok(Some(Registration {
6035 manager: DefinitionKind::LaunchdPlist,
6036 start_mode: self.mode,
6037 command_line: program_arguments(&document),
6038 account: plist_string_value(&document, "UserName")
6039 .or_else(|| Some("the invoking user".to_string())),
6040 running: loaded && printed.contains("state = running"),
6041 starts_automatically: document.contains("<key>RunAtLoad</key>")
6042 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6043 restart_delay: xml_value(
6044 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6045 "integer",
6046 )
6047 .and_then(|value| value.parse::<u64>().ok())
6048 .map(Duration::from_secs),
6049 }))
6050 }
6051
6052 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6053 let target = self.service_target(identity);
6054 let (ok, message) = self.launchctl(&[
6055 OsStr::new("kickstart"),
6056 OsStr::new("-k"),
6057 OsStr::new(&target),
6058 ]);
6059 if ok {
6060 Ok(())
6061 } else {
6062 Err(self.failed("start", identity.name(), message))
6063 }
6064 }
6065
6066 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6067 let running = self
6068 .query(identity)?
6069 .is_some_and(|registration| registration.running);
6070 if !running {
6071 return Ok(false);
6072 }
6073 let target = self.service_target(identity);
6074 let (ok, message) = self.launchctl(&[
6075 OsStr::new("kill"),
6076 OsStr::new("SIGTERM"),
6077 OsStr::new(&target),
6078 ]);
6079 if ok {
6080 Ok(true)
6081 } else {
6082 Err(self.failed("stop", identity.name(), message))
6083 }
6084 }
6085 }
6086
6087 fn program_arguments(document: &str) -> String {
6089 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6090 return String::new();
6091 };
6092 let Some(end) = rest.find("</array>") else {
6093 return String::new();
6094 };
6095 let mut out = Vec::new();
6096 let mut cursor = &rest[..end];
6097 while let Some(open) = cursor.find("<string>") {
6098 let after = &cursor[open + "<string>".len()..];
6099 let Some(close) = after.find("</string>") else {
6100 break;
6101 };
6102 out.push(quote_argument(&super::xml_unescape(&after[..close])));
6103 cursor = &after[close..];
6104 }
6105 out.join(" ")
6106 }
6107}
6108
6109#[cfg(all(unix, not(target_os = "macos")))]
6114mod sys {
6115 use std::ffi::OsStr;
6125 use std::path::PathBuf;
6126 use std::time::Duration;
6127
6128 use runner_manager_domain::model::StartMode;
6129
6130 use super::{
6131 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6132 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6133 home_directory, ini_directives, run, write_definition,
6134 };
6135
6136 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6137 Ok(Box::new(SystemdControl { mode }))
6138 }
6139
6140 #[derive(Debug)]
6141 struct SystemdControl {
6142 mode: StartMode,
6143 }
6144
6145 impl SystemdControl {
6146 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6147 let file = identity.systemd_unit();
6148 match self.mode {
6149 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6150 StartMode::Login => {
6151 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6152 }
6153 }
6154 }
6155
6156 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6158 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6159 if self.mode == StartMode::Login {
6160 all.push(OsStr::new("--user"));
6161 }
6162 all.extend(arguments.iter().map(OsStr::new));
6163 match run("systemctl", &all) {
6164 Ok((ok, stdout, stderr)) => (
6165 ok,
6166 if stdout.trim().is_empty() {
6167 stderr
6168 } else {
6169 stdout
6170 },
6171 ),
6172 Err(error) => (false, error.to_string()),
6173 }
6174 }
6175
6176 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6177 ServiceError::Control {
6178 operation,
6179 name: name.to_string(),
6180 manager: "systemd",
6181 detail,
6182 }
6183 }
6184 }
6185
6186 impl ServiceControl for SystemdControl {
6187 fn manager(&self) -> DefinitionKind {
6188 DefinitionKind::SystemdUnit
6189 }
6190
6191 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6192 let name = plan.identity().name().to_string();
6193 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6194 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6195 return Err(self.failed(
6196 "install",
6197 &name,
6198 "this account has no home directory, so there is nowhere to put a systemd \
6199 user unit. Use --start-at boot, which installs a system unit under \
6200 /etc/systemd/system."
6201 .to_string(),
6202 ));
6203 };
6204 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6205 let unit = plan.identity().systemd_unit();
6206 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6207 if !reloaded {
6208 let _ = std::fs::remove_file(&path);
6209 return Err(self.failed("install", &name, message));
6210 }
6211 let (enabled, message) = self.systemctl(&["enable", &unit]);
6212 if !enabled {
6213 let _ = std::fs::remove_file(&path);
6214 let _ = self.systemctl(&["daemon-reload"]);
6215 return Err(self.failed("install", &name, message));
6216 }
6217 Ok(definition)
6218 }
6219
6220 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6221 let Some(path) = self.unit_path(identity) else {
6222 return Ok(false);
6223 };
6224 if !path.exists() {
6225 return Ok(false);
6226 }
6227 let unit = identity.systemd_unit();
6228 let _ = self.systemctl(&["disable", "--now", &unit]);
6233 std::fs::remove_file(&path).map_err(|error| {
6234 super::definition_error(
6235 "uninstall",
6236 identity.name(),
6237 error,
6238 SUDO_REMEDY,
6239 path.as_path(),
6240 )
6241 })?;
6242 let _ = self.systemctl(&["daemon-reload"]);
6243 Ok(true)
6244 }
6245
6246 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6247 let Some(path) = self.unit_path(identity) else {
6248 return Ok(None);
6249 };
6250 let Ok(document) = std::fs::read_to_string(&path) else {
6251 return Ok(None);
6252 };
6253 let unit = identity.systemd_unit();
6254 let directives = ini_directives(&document, "Service");
6255 let (_, active) = self.systemctl(&["is-active", &unit]);
6256 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6257 Ok(Some(Registration {
6258 manager: DefinitionKind::SystemdUnit,
6259 start_mode: self.mode,
6260 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6261 account: directives.get("User").cloned().or_else(|| {
6262 Some(match self.mode {
6263 StartMode::Boot => "root".to_string(),
6264 StartMode::Login => "the invoking user".to_string(),
6265 })
6266 }),
6267 running: active.trim() == "active",
6268 starts_automatically: enabled.trim() == "enabled",
6269 restart_delay: directives
6270 .get("RestartSec")
6271 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6272 .map(Duration::from_secs),
6273 }))
6274 }
6275
6276 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6277 let unit = identity.systemd_unit();
6278 let (ok, message) = self.systemctl(&["start", &unit]);
6279 if ok {
6280 Ok(())
6281 } else {
6282 Err(self.failed("start", identity.name(), message))
6283 }
6284 }
6285
6286 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6287 let running = self
6288 .query(identity)?
6289 .is_some_and(|registration| registration.running);
6290 if !running {
6291 return Ok(false);
6292 }
6293 let unit = identity.systemd_unit();
6294 let (ok, message) = self.systemctl(&["stop", &unit]);
6295 if ok {
6296 Ok(true)
6297 } else {
6298 Err(self.failed("stop", identity.name(), message))
6299 }
6300 }
6301 }
6302}
6303
6304#[cfg(test)]
6305mod tests {
6306 use super::*;
6307
6308 use std::collections::BTreeMap;
6309
6310 fn linux_plan(mode: StartMode) -> InstallPlan {
6317 InstallPlan::unchecked(
6318 ServiceIdentity::product(),
6319 mode,
6320 "/opt/runner-manager/bin/runner-manager",
6321 ServiceDirectories {
6322 config: PathBuf::from("/var/lib/runner-manager/config"),
6323 state: PathBuf::from("/var/lib/runner-manager/state"),
6324 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
6325 logs: PathBuf::from("/var/log/runner-manager"),
6326 },
6327 )
6328 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
6329 }
6330
6331 fn windows_plan(mode: StartMode) -> InstallPlan {
6332 InstallPlan::unchecked(
6333 ServiceIdentity::product(),
6334 mode,
6335 "C:\\Program Files\\runner-manager\\runner-manager.exe",
6336 ServiceDirectories {
6337 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
6338 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
6339 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
6340 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
6341 },
6342 )
6343 }
6344
6345 fn edited(text: &str, from: &str, to: &str) -> String {
6353 assert!(
6354 text.contains(from),
6355 "the rendered definition does not contain `{from}`, so this test would assert \
6356 nothing about a widened one"
6357 );
6358 text.replace(from, to)
6359 }
6360
6361 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
6363 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
6364 let Ok(entries) = std::fs::read_dir(directory) else {
6365 return;
6366 };
6367 for entry in entries.flatten() {
6368 let path = entry.path();
6369 if path.is_dir() {
6370 walk(&path, out);
6371 } else if let Ok(bytes) = std::fs::read(&path) {
6372 out.insert(path, bytes);
6373 }
6374 }
6375 }
6376 let mut out = BTreeMap::new();
6377 for root in roots {
6378 walk(root, &mut out);
6379 }
6380 out
6381 }
6382
6383 struct Host {
6384 _root: tempfile::TempDir,
6385 paths: AppPaths,
6386 binary: PathBuf,
6387 runner_root: LocalAbsolutePath,
6396 controls: RecordingControls,
6397 }
6398
6399 impl Host {
6400 fn new() -> Self {
6401 let root = tempfile::tempdir().expect("a temporary directory");
6402 let paths = AppPaths::rooted_at(root.path());
6403 paths.create_all().expect("the four directories");
6404 let binary = root.path().join(if cfg!(windows) {
6405 "runner-manager.exe"
6406 } else {
6407 "runner-manager"
6408 });
6409 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
6410 let runner_root = LocalAbsolutePath::new(
6411 root.path()
6412 .join("runner-root")
6413 .to_str()
6414 .expect("a unicode temporary path"),
6415 )
6416 .expect("a local absolute path");
6417 Self {
6418 _root: root,
6419 paths,
6420 binary,
6421 runner_root,
6422 controls: RecordingControls::new(),
6423 }
6424 }
6425
6426 fn operations(&self) -> ServiceOperations {
6427 ServiceOperations::with_controls(
6428 self.paths.clone(),
6429 ServiceIdentity::product(),
6430 std::sync::Arc::new(self.controls.clone()),
6431 )
6432 .with_runner_root(self.runner_root.clone())
6433 }
6434
6435 fn request(&self, mode: StartMode) -> InstallRequest {
6436 InstallRequest::new(mode).for_binary(&self.binary)
6437 }
6438 }
6439
6440 #[cfg(windows)]
6441 #[test]
6442 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
6443 let probes = std::cell::Cell::new(0);
6444 let absent =
6445 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
6446 let next = probes.get() + 1;
6447 probes.set(next);
6448 Ok(next == 3)
6449 })
6450 .expect("the simulated SCM probe succeeds");
6451
6452 assert!(absent);
6453 assert_eq!(
6454 probes.get(),
6455 3,
6456 "uninstall must recheck after transient presence instead of treating it as a leak"
6457 );
6458 }
6459
6460 #[test]
6461 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
6462 let root = tempfile::tempdir().expect("a temporary directory");
6463 let plist = root.path().join("fixture.plist");
6464 std::fs::write(&plist, b"fixture").expect("a plist fixture");
6465 let calls = std::cell::RefCell::new(Vec::new());
6466
6467 let error = enable_launchd_registration(
6468 |arguments| {
6469 let call = arguments
6470 .iter()
6471 .map(|argument| argument.to_string_lossy().into_owned())
6472 .collect::<Vec<_>>();
6473 let operation = call[0].clone();
6474 calls.borrow_mut().push(call);
6475 if operation == "enable" {
6476 (false, "label remains disabled".to_string())
6477 } else {
6478 (true, String::new())
6479 }
6480 },
6481 "system",
6482 "system/com.openai.runner-manager-selftest",
6483 &plist,
6484 "runner-manager-selftest",
6485 "rerun with administrative rights",
6486 )
6487 .expect_err("enable failure must fail the install");
6488
6489 assert!(
6490 matches!(
6491 error,
6492 ServiceError::Control {
6493 operation: "enable",
6494 ..
6495 }
6496 ),
6497 "{error}"
6498 );
6499 assert_eq!(calls.borrow().len(), 2);
6500 assert_eq!(calls.borrow()[0][0], "enable");
6501 assert_eq!(calls.borrow()[1][0], "bootout");
6502 assert!(!plist.exists(), "rollback must remove the plist");
6503 }
6504
6505 #[test]
6510 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
6511 let plan = windows_plan(StartMode::Boot);
6512 let command_line = plan.command_line();
6513 assert!(
6514 command_line.starts_with('"'),
6515 "a path with a space must be quoted, got {command_line}"
6516 );
6517 assert_eq!(
6518 executable_from_command_line(&command_line).as_deref(),
6519 Some(plan.binary())
6520 );
6521 }
6522
6523 #[test]
6524 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
6525 let plan = linux_plan(StartMode::Boot);
6526 let command_line = plan.command_line();
6527 assert!(!command_line.starts_with('"'), "got {command_line}");
6528 assert_eq!(
6529 executable_from_command_line(&command_line).as_deref(),
6530 Some(plan.binary())
6531 );
6532 }
6533
6534 #[test]
6535 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
6536 let awkward = r#"C:\odd "name"\rm.exe"#;
6539 let quoted = quote_argument(awkward);
6540 assert_eq!(
6541 executable_from_command_line(&format!("{quoted} daemon run"))
6542 .as_deref()
6543 .map(Path::to_string_lossy)
6544 .as_deref(),
6545 Some(awkward)
6546 );
6547 }
6548
6549 #[test]
6550 fn an_empty_command_line_has_no_executable() {
6551 assert_eq!(executable_from_command_line(" "), None);
6552 assert_eq!(executable_from_command_line(""), None);
6553 }
6554
6555 #[test]
6556 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
6557 let awkward = r#"DOMAIN\R&D <team> "ops""#;
6558 assert_eq!(
6559 xml_escape(awkward),
6560 "DOMAIN\\R&D <team> "ops""
6561 );
6562 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
6563 }
6564
6565 #[test]
6570 fn a_restart_delay_under_the_floor_is_refused() {
6571 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
6572 .expect_err("half a second is under the one-second floor");
6573 assert!(
6574 matches!(error, ServiceError::RestartDelay { .. }),
6575 "{error}"
6576 );
6577 }
6578
6579 #[test]
6580 fn a_restart_delay_over_the_ceiling_is_refused() {
6581 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
6582 .expect_err("an hour is over the five-minute ceiling");
6583 assert!(
6584 matches!(error, ServiceError::RestartDelay { .. }),
6585 "{error}"
6586 );
6587 }
6588
6589 #[test]
6590 fn a_reset_window_no_longer_than_the_delay_is_refused() {
6591 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
6592 .expect_err("a window equal to the delay can never elapse between restarts");
6593 assert!(
6594 matches!(error, ServiceError::RestartResetWindow { .. }),
6595 "{error}"
6596 );
6597 }
6598
6599 #[test]
6600 fn a_delay_inside_the_bound_is_accepted() {
6601 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
6602 .expect("twenty seconds is inside the bound");
6603 assert_eq!(policy.delay(), Duration::from_secs(20));
6604 assert_eq!(policy.reset_after(), Duration::from_secs(300));
6605 }
6606
6607 #[test]
6612 fn a_fixture_identity_can_never_be_the_product_identity() {
6613 let fixture = ServiceIdentity::fixture("abc123");
6614 assert!(fixture.is_fixture());
6615 assert!(!ServiceIdentity::product().is_fixture());
6616 assert_ne!(fixture.name(), ServiceIdentity::product().name());
6617 assert_ne!(
6618 fixture.launchd_label(),
6619 ServiceIdentity::product().launchd_label()
6620 );
6621 assert_ne!(
6622 fixture.systemd_unit(),
6623 ServiceIdentity::product().systemd_unit()
6624 );
6625 }
6626
6627 #[test]
6628 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
6629 let fixture = ServiceIdentity::fixture("A b/c:\\d");
6630 assert!(
6631 fixture
6632 .name()
6633 .chars()
6634 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
6635 "got {}",
6636 fixture.name()
6637 );
6638 }
6639
6640 #[test]
6645 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
6646 let unit = systemd_unit(&linux_plan(StartMode::Boot));
6647 assert!(unit.contains("KillMode=process\n"), "{unit}");
6648 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
6649 assert!(unit.contains("RestartSec=15\n"), "{unit}");
6650 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
6651 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
6652 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
6653 }
6654
6655 #[test]
6656 fn the_boot_unit_reads_the_token_through_the_credential_d2_publishes() {
6657 let unit = systemd_unit(&linux_plan(StartMode::Boot));
6658 assert!(
6659 unit.contains(&format!(
6660 "LoadCredential={}:/var/lib/runner-manager/secrets/user-access-token\n",
6661 crate::secrets::SYSTEMD_CREDENTIAL
6662 )),
6663 "the unit must name the credential `d2` reads, got:\n{unit}"
6664 );
6665 }
6666
6667 #[test]
6668 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
6669 let unit = systemd_unit(&linux_plan(StartMode::Login));
6670 assert!(
6671 !unit.contains("LoadCredential="),
6672 "a user unit must not name a root-owned credential file, got:\n{unit}"
6673 );
6674 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
6675 }
6676
6677 #[test]
6678 fn the_unit_makes_exactly_the_four_directories_writable() {
6679 let plan = linux_plan(StartMode::Boot);
6680 let unit = systemd_unit(&plan);
6681 let directives = ini_directives(&unit, "Service");
6682 let listed = split_quoted(
6683 directives
6684 .get("ReadWritePaths")
6685 .expect("the unit names its writable paths"),
6686 );
6687 assert_eq!(listed.len(), 4, "{listed:?}");
6688 for path in plan.directories().all() {
6689 assert!(
6690 listed.iter().any(|entry| entry == &path.to_string_lossy()),
6691 "{} is missing from {listed:?}",
6692 path.display()
6693 );
6694 }
6695 }
6696
6697 #[test]
6698 fn the_unit_records_the_absolute_binary_path() {
6699 let plan = linux_plan(StartMode::Boot);
6700 let unit = systemd_unit(&plan);
6701 assert!(
6702 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
6703 "{unit}"
6704 );
6705 }
6706
6707 #[test]
6712 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
6713 let plist = launchd_plist(&linux_plan(StartMode::Boot));
6714 assert!(
6717 plist.contains(
6718 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
6719 ),
6720 "{plist}"
6721 );
6722 assert!(
6723 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
6724 "{plist}"
6725 );
6726 }
6727
6728 #[test]
6729 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
6730 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
6731 assert_eq!(
6732 plist_string_value(&daemon, "UserName").as_deref(),
6733 Some("root")
6734 );
6735 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
6736
6737 let agent = launchd_plist(&linux_plan(StartMode::Login));
6738 assert_eq!(
6739 plist_string_value(&agent, "UserName"),
6740 None,
6741 "a LaunchAgent already runs as the operator:\n{agent}"
6742 );
6743 }
6744
6745 #[test]
6746 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
6747 let plist = launchd_plist(&linux_plan(StartMode::Boot));
6748 assert!(
6749 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
6750 "{plist}"
6751 );
6752 assert!(plist.contains("<string>daemon</string>"), "{plist}");
6753 assert!(plist.contains("<string>run</string>"), "{plist}");
6754 }
6755
6756 #[test]
6757 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
6758 assert_eq!(
6759 ServiceIdentity::product().launchd_label(),
6760 "io.github.IvanMurzak.runner-manager"
6761 );
6762 }
6763
6764 #[test]
6769 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
6770 let xml = windows_scheduled_task_xml(
6771 &windows_plan(StartMode::Login),
6772 &TaskPrincipal::named("HOST\\operator"),
6773 );
6774 assert!(xml.contains("<LogonTrigger>"), "{xml}");
6775 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
6776 assert!(
6777 xml.contains("<LogonType>InteractiveToken</LogonType>"),
6778 "{xml}"
6779 );
6780 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
6781 assert!(
6782 xml.contains("<Interval>PT1M</Interval>"),
6783 "Task Scheduler takes whole minutes only, and rejects the registration outright \
6784 for anything finer:\n{xml}"
6785 );
6786 }
6787
6788 #[test]
6789 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
6790 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
6795 let policy =
6796 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
6797 .expect("inside the supported range");
6798 assert_eq!(
6799 policy
6800 .effective_delay(DefinitionKind::WindowsScheduledTask)
6801 .as_secs(),
6802 enforced,
6803 "a {asked}s delay must be enforced as {enforced}s"
6804 );
6805 assert!(
6806 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
6807 "rounding must never shorten the bound"
6808 );
6809 }
6810 }
6811
6812 #[test]
6813 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
6814 let policy = RestartPolicy::default();
6815 for kind in [
6816 DefinitionKind::WindowsService,
6817 DefinitionKind::LaunchdPlist,
6818 DefinitionKind::SystemdUnit,
6819 ] {
6820 assert_eq!(
6821 policy.effective_delay(kind),
6822 policy.delay(),
6823 "{kind:?} takes seconds and enforces exactly what it is given"
6824 );
6825 }
6826 }
6827
6828 #[test]
6829 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
6830 let host = Host::new();
6831 let operations = host.operations();
6832 operations
6833 .install(&host.request(StartMode::Login))
6834 .expect("an install at login");
6835 host.controls.edit("runner-manager", |registration| {
6837 registration.manager = DefinitionKind::WindowsScheduledTask;
6838 registration.restart_delay = Some(Duration::from_secs(60));
6839 });
6840
6841 let status = operations.status().expect("a status");
6842 assert!(
6843 status.is_healthy(),
6844 "minute granularity is the manager's, not a mis-registration: {status}"
6845 );
6846 assert!(
6847 status
6848 .notes()
6849 .iter()
6850 .any(|note| note.contains("whole minutes")),
6851 "but the operator must be told why 15 became 60: {status}"
6852 );
6853
6854 host.controls.edit("runner-manager", |registration| {
6857 registration.restart_delay = Some(Duration::from_secs(1));
6858 });
6859 assert!(
6860 !operations.status().expect("a status").is_healthy(),
6861 "a one-second delay is not what any manager was asked for"
6862 );
6863 }
6864
6865 #[test]
6866 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
6867 let plan = windows_plan(StartMode::Login);
6868 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
6869 assert_eq!(
6870 xml_value(&xml, "Command").as_deref(),
6871 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
6872 "{xml}"
6873 );
6874 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
6875 }
6876
6877 #[test]
6878 fn an_account_name_holding_xml_punctuation_is_escaped() {
6879 let xml = windows_scheduled_task_xml(
6880 &windows_plan(StartMode::Login),
6881 &TaskPrincipal::named("R&D\\ops"),
6882 );
6883 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
6884 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
6885 }
6886
6887 #[test]
6892 fn the_service_starts_automatically_under_the_account_the_store_admits() {
6893 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
6894 let directives = ini_directives(&text, "windows-service");
6895 assert_eq!(
6896 directives.get("StartType").map(String::as_str),
6897 Some("AutoStart")
6898 );
6899 assert_eq!(
6900 directives.get("Account").map(String::as_str),
6901 Some("NT AUTHORITY\\SYSTEM")
6902 );
6903 assert_eq!(
6904 directives.get("ServiceType").map(String::as_str),
6905 Some("OWN_PROCESS")
6906 );
6907 assert_eq!(
6908 directives
6909 .get("FailureActionRestartDelaySecs")
6910 .map(String::as_str),
6911 Some("15")
6912 );
6913 assert_eq!(
6914 directives
6915 .get("FailureActionsOnNonCrashFailures")
6916 .map(String::as_str),
6917 Some("true"),
6918 "without this flag a non-zero exit is not a failure the manager restarts"
6919 );
6920 }
6921
6922 #[test]
6923 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
6924 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
6925 assert_eq!(spec.account, None);
6926 assert!(spec.automatic_start);
6927 assert!(
6928 spec.command_line.contains("daemon run"),
6929 "{}",
6930 spec.command_line
6931 );
6932 }
6933
6934 #[test]
6939 fn each_definition_goes_where_its_platform_expects_it() {
6940 let home = PathBuf::from("/home/op");
6941 assert_eq!(
6942 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
6943 Some(Path::new(
6944 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
6945 ))
6946 );
6947 assert_eq!(
6948 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
6949 Some(Path::new(
6950 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
6951 ))
6952 );
6953 assert_eq!(
6954 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
6955 Some(Path::new("/etc/systemd/system/runner-manager.service"))
6956 );
6957 assert_eq!(
6958 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
6959 Some(Path::new(
6960 "/home/op/.config/systemd/user/runner-manager.service"
6961 ))
6962 );
6963 assert_eq!(
6964 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
6965 None,
6966 "the Service Control Manager has no file"
6967 );
6968 }
6969
6970 #[test]
6971 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
6972 assert_eq!(
6973 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
6974 None
6975 );
6976 assert_eq!(
6977 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
6978 None
6979 );
6980 }
6981
6982 #[test]
6987 fn the_rendered_definitions_are_all_least_privilege() {
6988 let linux = linux_plan(StartMode::Boot);
6989 let windows = windows_plan(StartMode::Boot);
6990 for (definition, plan) in [
6991 (ServiceDefinition::systemd(&linux, None), &linux),
6992 (ServiceDefinition::launchd(&linux, None), &linux),
6993 (ServiceDefinition::windows_service(&windows), &windows),
6994 ] {
6995 let review = review_least_privilege(&definition, plan);
6996 assert!(
6997 review.is_least_privilege(),
6998 "{:?} should be least privilege, got:\n{review}",
6999 definition.kind()
7000 );
7001 assert!(
7002 !review.controls().is_empty(),
7003 "a review that confirms nothing proves nothing: {:?}",
7004 definition.kind()
7005 );
7006 }
7007 }
7008
7009 #[test]
7010 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
7011 let plan = windows_plan(StartMode::Login);
7012 let definition =
7013 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
7014 let review = review_least_privilege(&definition, &plan);
7015 assert!(review.is_least_privilege(), "{review}");
7016 assert!(
7017 review
7018 .controls()
7019 .iter()
7020 .any(|control| control.contains("LeastPrivilege")),
7021 "{review}"
7022 );
7023 }
7024
7025 #[test]
7026 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
7027 let plan = linux_plan(StartMode::Boot);
7028 let rendered = systemd_unit(&plan);
7029 let widened = edited(
7030 &rendered,
7031 "ReadWritePaths=/var/lib/runner-manager/config",
7032 "ReadWritePaths=/etc /var/lib/runner-manager/config",
7033 );
7034 let review = review_least_privilege(
7035 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7036 &plan,
7037 );
7038 assert!(!review.is_least_privilege(), "{review}");
7039 assert!(
7040 review
7041 .excesses()
7042 .iter()
7043 .any(|finding| finding.detail.contains("/etc")),
7044 "the review must name the directory it objects to: {review}"
7045 );
7046 }
7047
7048 #[test]
7049 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
7050 let plan = linux_plan(StartMode::Boot);
7051 let rendered = systemd_unit(&plan);
7052 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
7053 let review = review_least_privilege(
7054 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
7055 &plan,
7056 );
7057 assert!(!review.is_least_privilege(), "{review}");
7058 assert!(
7059 review
7060 .excesses()
7061 .iter()
7062 .any(|finding| finding.subject == "NoNewPrivileges"),
7063 "{review}"
7064 );
7065 }
7066
7067 #[test]
7068 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
7069 let plan = linux_plan(StartMode::Boot);
7070 let rendered = systemd_unit(&plan);
7071 let widened = edited(
7072 &rendered,
7073 "CapabilityBoundingSet=\n",
7074 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
7075 );
7076 let review = review_least_privilege(
7077 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7078 &plan,
7079 );
7080 assert!(!review.is_least_privilege(), "{review}");
7081 assert!(
7082 review
7083 .excesses()
7084 .iter()
7085 .any(|finding| finding.subject == "CapabilityBoundingSet"),
7086 "{review}"
7087 );
7088 }
7089
7090 #[test]
7091 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
7092 let plan = linux_plan(StartMode::Boot);
7093 let rendered = systemd_unit(&plan);
7094 let widened = edited(
7095 &rendered,
7096 "[Install]",
7097 "ListenStream=127.0.0.1:9000\n\n[Install]",
7098 );
7099 let review = review_least_privilege(
7100 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7101 &plan,
7102 );
7103 assert!(
7104 !review.is_least_privilege(),
7105 "07-security.md rule 2 forbids any inbound surface: {review}"
7106 );
7107 }
7108
7109 #[test]
7110 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
7111 let plan = linux_plan(StartMode::Boot);
7112 let rendered = systemd_unit(&plan);
7113 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
7114 let review = review_least_privilege(
7115 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
7116 &plan,
7117 );
7118 assert!(
7119 review.is_least_privilege(),
7120 "too little authority is not an excess: {review}"
7121 );
7122 assert!(
7123 review
7124 .findings()
7125 .iter()
7126 .any(|finding| finding.kind == FindingKind::Shortfall
7127 && finding.detail.contains("runtime")),
7128 "{review}"
7129 );
7130 }
7131
7132 #[test]
7133 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
7134 let plan = linux_plan(StartMode::Login);
7135 let rendered = launchd_plist(&plan);
7136 let widened = edited(
7137 &rendered,
7138 "<key>ProcessType</key>",
7139 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
7140 );
7141 let review = review_least_privilege(
7142 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7143 &plan,
7144 );
7145 assert!(!review.is_least_privilege(), "{review}");
7146 assert!(
7147 review
7148 .excesses()
7149 .iter()
7150 .any(|finding| finding.subject == "UserName"),
7151 "{review}"
7152 );
7153 }
7154
7155 #[test]
7156 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
7157 let plan = linux_plan(StartMode::Boot);
7158 let rendered = launchd_plist(&plan);
7159 let widened = edited(
7160 &rendered,
7161 "<key>SessionCreate</key>\n <false/>",
7162 "<key>SessionCreate</key>\n <true/>",
7163 );
7164 let review = review_least_privilege(
7165 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7166 &plan,
7167 );
7168 assert!(!review.is_least_privilege(), "{review}");
7169 assert!(
7170 review
7171 .excesses()
7172 .iter()
7173 .any(|finding| finding.subject == "SessionCreate"),
7174 "{review}"
7175 );
7176 }
7177
7178 #[test]
7179 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
7180 let plan = linux_plan(StartMode::Boot);
7181 let rendered = launchd_plist(&plan);
7182 let widened = edited(
7183 &rendered,
7184 "<key>ProcessType</key>",
7185 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
7186 );
7187 let review = review_least_privilege(
7188 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7189 &plan,
7190 );
7191 assert!(!review.is_least_privilege(), "{review}");
7192 }
7193
7194 #[test]
7195 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
7196 let plan = windows_plan(StartMode::Login);
7197 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7198 let widened = edited(
7199 &rendered,
7200 "<RunLevel>LeastPrivilege</RunLevel>",
7201 "<RunLevel>HighestAvailable</RunLevel>",
7202 );
7203 let review = review_least_privilege(
7204 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7205 &plan,
7206 );
7207 assert!(!review.is_least_privilege(), "{review}");
7208 assert!(
7209 review
7210 .excesses()
7211 .iter()
7212 .any(|finding| finding.subject == "RunLevel"),
7213 "{review}"
7214 );
7215 }
7216
7217 #[test]
7218 fn a_task_that_would_store_a_password_is_not_least_privilege() {
7219 let plan = windows_plan(StartMode::Login);
7220 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7221 let widened = edited(
7222 &rendered,
7223 "<LogonType>InteractiveToken</LogonType>",
7224 "<LogonType>Password</LogonType>",
7225 );
7226 let review = review_least_privilege(
7227 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7228 &plan,
7229 );
7230 assert!(!review.is_least_privilege(), "{review}");
7231 }
7232
7233 #[test]
7234 fn an_interactive_windows_service_is_not_least_privilege() {
7235 let plan = windows_plan(StartMode::Boot);
7236 let rendered = windows_service_descriptor(&plan);
7237 let widened = edited(
7238 &rendered,
7239 "ServiceType=OWN_PROCESS",
7240 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
7241 );
7242 let review = review_least_privilege(
7243 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
7244 &plan,
7245 );
7246 assert!(!review.is_least_privilege(), "{review}");
7247 assert!(
7248 review
7249 .excesses()
7250 .iter()
7251 .any(|finding| finding.subject == "ServiceType"),
7252 "{review}"
7253 );
7254 }
7255
7256 #[test]
7257 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
7258 let plan = windows_plan(StartMode::Boot);
7259 let rendered = windows_service_descriptor(&plan);
7260 let changed = edited(
7261 &rendered,
7262 "Account=NT AUTHORITY\\SYSTEM",
7263 "Account=NT AUTHORITY\\LocalService",
7264 );
7265 let review = review_least_privilege(
7266 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
7267 &plan,
7268 );
7269 assert!(review.is_least_privilege(), "{review}");
7275 assert!(
7276 review.findings().iter().any(|finding| {
7277 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
7278 }),
7279 "{review}"
7280 );
7281 }
7282
7283 #[test]
7288 fn a_recorded_path_that_is_still_there_is_current() {
7289 let host = Host::new();
7290 let state = inspect_binary(&host.binary, Some(&host.binary));
7291 assert!(!state.is_error(), "{state}");
7292 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
7293 }
7294
7295 #[test]
7296 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
7297 let host = Host::new();
7298 let recorded = host.binary.clone();
7302 let healthy = inspect_binary(&recorded, Some(&recorded));
7303 assert!(
7304 !healthy.is_error(),
7305 "the discriminator: before the binary moves, this must be healthy"
7306 );
7307
7308 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
7309
7310 let state = inspect_binary(&recorded, Some(&recorded));
7311 assert!(state.is_error(), "{state}");
7312 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
7313 assert!(
7314 state.to_string().contains("npm"),
7315 "the message must name the cause an operator will not otherwise connect: {state}"
7316 );
7317 }
7318
7319 #[test]
7320 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
7321 let root = tempfile::tempdir().expect("a temporary directory");
7322 let state = inspect_binary(root.path(), None);
7323 assert!(state.is_error(), "{state}");
7324 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
7325 }
7326
7327 #[test]
7328 fn a_registration_naming_a_different_binary_is_a_divergence() {
7329 let host = Host::new();
7330 let other = host.binary.with_file_name("something-else");
7331 let state = inspect_binary(&host.binary, Some(&other));
7332 assert!(state.is_error(), "{state}");
7333 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
7334 }
7335
7336 #[test]
7337 fn absence_is_reported_before_divergence() {
7338 let host = Host::new();
7339 let recorded = host.binary.clone();
7340 std::fs::remove_file(&recorded).expect("removable");
7341 let other = recorded.with_file_name("something-else");
7342 assert!(matches!(
7346 inspect_binary(&recorded, Some(&other)),
7347 BinaryPath::Missing { .. }
7348 ));
7349 }
7350
7351 #[test]
7356 fn the_record_round_trips_through_toml() {
7357 let host = Host::new();
7358 let plan = InstallPlan::resolve(
7359 ServiceIdentity::product(),
7360 &host.request(StartMode::Boot),
7361 ServiceDirectories::of(&host.paths),
7362 )
7363 .expect("a resolvable plan");
7364 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7365 let record = InstallRecord::of(&plan, &definition, Utc::now());
7366 record.write(&host.paths).expect("a writable record");
7367 let read = InstallRecord::read(&host.paths)
7368 .expect("a readable record")
7369 .expect("a record is there");
7370 assert_eq!(read, record);
7371 assert_eq!(read.binary, host.binary);
7372 assert!(read.binary.is_absolute());
7373 }
7374
7375 #[cfg(unix)]
7384 #[test]
7385 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
7386 use std::os::unix::fs::PermissionsExt as _;
7387
7388 let host = Host::new();
7389 let plan = InstallPlan::resolve(
7390 ServiceIdentity::product(),
7391 &host.request(StartMode::Boot),
7392 ServiceDirectories::of(&host.paths),
7393 )
7394 .expect("a resolvable plan");
7395 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7396 InstallRecord::of(&plan, &definition, Utc::now())
7397 .write(&host.paths)
7398 .expect("a writable record");
7399
7400 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
7401 .expect("the record is there")
7402 .permissions()
7403 .mode()
7404 & 0o777;
7405 assert_eq!(
7406 mode, 0o644,
7407 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
7408 service install` wrote, and `service status` fails on their own host. It holds no \
7409 credential and sits in a 0700 directory, so 0644 discloses nothing"
7410 );
7411 }
7412
7413 #[test]
7417 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
7418 let host = Host::new();
7419 let path = InstallRecord::path(&host.paths);
7420 std::fs::write(
7421 &path,
7422 format!(
7423 "schema_version = {RECORD_SCHEMA_VERSION}
7424service_name = \"runner-manager\"
7425 manager = \"systemd\"
7426start_mode = \"boot\"
7427account = \"root\"
7428 binary = \"/x\"
7429arguments = []
7430restart_delay_secs = 15
7431 restart_reset_secs = 600
7432log_file = \"/x\"
7433 installed_at = \"2026-01-01T00:00:00Z\"
7434installed_by_version = \"0.1.0\"
7435 [directories]
7436config = \"/a\"
7437state = \"/b\"
7438runtime = \"/c\"
7439logs = \"/d\"
7440"
7441 ),
7442 )
7443 .expect("a writable record");
7444 let read = InstallRecord::read(&host.paths)
7445 .expect("a record missing an optional field is still readable")
7446 .expect("a record is there");
7447 assert_eq!(
7448 read.source_binary, None,
7449 "the legacy layout has no source, and must not invent one"
7450 );
7451 }
7452
7453 #[test]
7455 fn a_registration_remembers_the_file_it_was_copied_from() {
7456 let host = Host::new();
7457 let source = host.binary.with_file_name("npm-installed-runner-manager");
7458 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
7459 let plan = InstallPlan::resolve(
7460 ServiceIdentity::product(),
7461 &host.request(StartMode::Boot).copied_from(&source),
7462 ServiceDirectories::of(&host.paths),
7463 )
7464 .expect("a resolvable plan");
7465 let definition = ServiceDefinition::from_text(
7466 DefinitionKind::SystemdUnit,
7467 "[Service]
7468",
7469 );
7470 let record = InstallRecord::of(&plan, &definition, Utc::now());
7471 record.write(&host.paths).expect("a writable record");
7472
7473 let read = InstallRecord::read(&host.paths)
7474 .expect("a readable record")
7475 .expect("a record is there");
7476 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
7477 assert_ne!(
7478 read.source_binary.as_deref(),
7479 Some(read.binary.as_path()),
7480 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
7481 );
7482 }
7483
7484 #[test]
7485 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
7486 let host = Host::new();
7487 let path = InstallRecord::path(&host.paths);
7488 std::fs::write(
7489 &path,
7490 format!(
7491 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
7492 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
7493 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
7494 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
7495 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
7496 RECORD_SCHEMA_VERSION + 1
7497 ),
7498 )
7499 .expect("a writable record");
7500 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
7501 assert!(
7502 matches!(error, ServiceError::RecordUnreadable { .. }),
7503 "{error}"
7504 );
7505 assert!(
7506 error.to_string().contains("service uninstall"),
7507 "the message must say how to recover: {error}"
7508 );
7509 }
7510
7511 #[test]
7512 fn no_record_is_not_an_error() {
7513 let host = Host::new();
7514 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
7515 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
7516 }
7517
7518 #[test]
7523 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
7524 let host = Host::new();
7525 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
7526 }
7527
7528 #[test]
7529 fn the_heartbeat_round_trips_to_the_second() {
7530 let host = Host::new();
7531 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
7532 .expect("a valid timestamp")
7533 .with_timezone(&Utc);
7534 record_github_contact(&host.paths, at).expect("a writable heartbeat");
7535 assert_eq!(
7536 last_github_contact(&host.paths).expect("readable"),
7537 Some(at)
7538 );
7539 }
7540
7541 #[test]
7542 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
7543 let host = Host::new();
7544 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
7545 let error = last_github_contact(&host.paths)
7546 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
7547 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7548 }
7549
7550 #[test]
7556 fn a_runner_root_refusal_round_trips_for_service_status() {
7557 let host = Host::new();
7558 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
7559
7560 assert!(
7561 runner_root_refusals(&host.paths)
7562 .expect("readable")
7563 .is_empty(),
7564 "no record means every policy is placing runners"
7565 );
7566
7567 record_runner_root_refusal(
7568 &host.paths,
7569 "policy-a",
7570 at,
7571 "denied_by_privacy_policy",
7572 "/Volumes/NVME/runners",
7573 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
7574 )
7575 .expect("a writable record");
7576
7577 let refusals = runner_root_refusals(&host.paths).expect("readable");
7578 assert_eq!(refusals.len(), 1);
7579 assert_eq!(refusals[0].policy, "policy-a");
7580 assert_eq!(refusals[0].at, at);
7581 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
7582 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
7583 assert!(
7584 refusals[0].detail.contains("/Volumes/NVME/runners")
7585 && refusals[0].detail.contains("Full Disk Access"),
7586 "the path and the remediation are the whole point of this file: {refusals:?}"
7587 );
7588 }
7589
7590 #[test]
7598 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
7599 let host = Host::new();
7600 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
7601 record_runner_root_refusal(
7602 &host.paths,
7603 "broken",
7604 at,
7605 "denied_by_privacy_policy",
7606 "/Volumes/NVME/runners",
7607 "detail",
7608 )
7609 .expect("a writable record");
7610 record_runner_root_refusal(
7611 &host.paths,
7612 "also-broken",
7613 at,
7614 "not_writable",
7615 "/srv/other",
7616 "detail",
7617 )
7618 .expect("a writable record");
7619
7620 clear_runner_root_refusal(&host.paths, "healthy")
7622 .expect("clearing an absent policy is fine");
7623 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
7624
7625 let refusals = runner_root_refusals(&host.paths).expect("readable");
7626 assert_eq!(
7627 refusals
7628 .iter()
7629 .map(|r| r.policy.as_str())
7630 .collect::<Vec<_>>(),
7631 vec!["broken"],
7632 "the policy that is still refused must keep its record"
7633 );
7634 }
7635
7636 #[test]
7639 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
7640 let host = Host::new();
7641 record_runner_root_refusal(
7642 &host.paths,
7643 "p",
7644 Utc::now(),
7645 "not_writable",
7646 "/srv/x",
7647 "detail",
7648 )
7649 .expect("a writable record");
7650
7651 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
7652 assert!(
7653 !root_refusal_path(&host.paths).exists(),
7654 "a host with nothing refused leaves nothing behind"
7655 );
7656 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
7657 }
7658
7659 #[test]
7662 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
7663 let host = Host::new();
7664 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
7665 let error = runner_root_refusals(&host.paths)
7666 .expect_err("an unparseable record is not the same as no record");
7667 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7668 }
7669
7670 #[test]
7680 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
7681 let host = Host::new();
7682 record_runner_root_refusal(
7683 &host.paths,
7684 "policy-a",
7685 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
7686 "denied_by_privacy_policy",
7687 "/Volumes/NVME/runners",
7688 "Grant Full Disk Access to the program that runs the service",
7689 )
7690 .expect("a writable record");
7691
7692 let status = host.operations().status().expect("a readable status");
7693 let notes = status.notes().join("\n");
7694
7695 assert!(
7696 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
7697 "the directory and the remediation the log had to scrub must appear here: {notes}"
7698 );
7699 assert!(
7700 notes.contains("policy-a"),
7701 "the operator has to know which target placed no runner: {notes}"
7702 );
7703 assert!(
7704 !status
7705 .problems()
7706 .iter()
7707 .any(|problem| problem.subject == "runner root"),
7708 "a record nothing an operator types can clear must not drive the exit code"
7709 );
7710 }
7711
7712 #[test]
7718 fn install_records_the_absolute_binary_path_and_the_four_directories() {
7719 let host = Host::new();
7720 let installed = host
7721 .operations()
7722 .install(&host.request(StartMode::Boot))
7723 .expect("an install against the recording controls");
7724
7725 assert_eq!(installed.record.binary, host.binary);
7726 assert!(installed.record.binary.is_absolute());
7727 assert_eq!(installed.record.start_mode, StartMode::Boot);
7728 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
7729 assert_eq!(
7730 installed.record.directories,
7731 ServiceDirectories::of(&host.paths)
7732 );
7733 assert_eq!(
7734 installed.record.log_file,
7735 host.paths.logs_dir().join(LOG_FILE_STEM)
7736 );
7737 assert_eq!(installed.record.restart_delay_secs, 15);
7738
7739 let registrations = host.controls.registrations();
7740 assert_eq!(registrations.len(), 1);
7741 assert_eq!(registrations[0].0, StartMode::Boot);
7742 assert_eq!(registrations[0].1, "runner-manager");
7743 }
7744
7745 #[test]
7746 fn install_is_refused_while_the_single_instance_lock_is_held() {
7747 let host = Host::new();
7748 {
7750 let operations = host.operations();
7751 operations
7752 .install(&host.request(StartMode::Boot))
7753 .expect("an install with the lock free");
7754 operations.uninstall().expect("a clean slate");
7755 }
7756
7757 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
7758 .expect("this process takes the lock first");
7759
7760 let error = host
7761 .operations()
7762 .install(&host.request(StartMode::Boot))
7763 .expect_err("a second agent must not be registered while one is running");
7764 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
7765 assert!(
7766 error.to_string().contains("already running"),
7767 "the message must be actionable: {error}"
7768 );
7769 assert!(
7770 host.controls.registrations().is_empty(),
7771 "a refused install must register nothing"
7772 );
7773 assert_eq!(
7774 InstallRecord::read(&host.paths).expect("readable"),
7775 None,
7776 "a refused install must write no record"
7777 );
7778 }
7779
7780 #[test]
7788 fn installing_over_the_same_start_mode_replaces_the_registration() {
7789 let host = Host::new();
7790 let operations = host.operations();
7791 operations
7792 .install(&host.request(StartMode::Boot))
7793 .expect("the first install");
7794
7795 let again = operations
7796 .install(&host.request(StartMode::Boot))
7797 .expect("an install over the same mode replaces rather than refusing");
7798
7799 assert!(
7800 again.replaced_existing,
7801 "the operator is told this replaced something rather than made it"
7802 );
7803 assert_eq!(
7804 host.controls.registrations().len(),
7805 1,
7806 "replacing must not leave two registrations behind"
7807 );
7808 assert_eq!(
7809 InstallRecord::read(&host.paths)
7810 .expect("readable")
7811 .expect("a record")
7812 .start_mode,
7813 StartMode::Boot
7814 );
7815 }
7816
7817 #[test]
7821 fn installing_over_the_other_start_mode_is_refused() {
7822 let host = Host::new();
7823 let operations = host.operations();
7824 operations
7825 .install(&host.request(StartMode::Boot))
7826 .expect("the first install");
7827
7828 let error = operations
7829 .install(&host.request(StartMode::Login))
7830 .expect_err("a mode change is not an install");
7831 assert!(
7832 matches!(
7833 error,
7834 ServiceError::AlreadyInstalled {
7835 existing: StartMode::Boot,
7836 requested: StartMode::Login,
7837 ..
7838 }
7839 ),
7840 "{error}"
7841 );
7842 assert!(
7843 !error
7844 .to_string()
7845 .contains("switch the start mode in place,"),
7846 "the old remedy named a capability no command offers; the terminal UI is where \
7847 the start mode moves: {error}"
7848 );
7849 assert_eq!(host.controls.registrations().len(), 1);
7850 }
7851
7852 #[test]
7853 fn install_rolls_back_the_registration_when_record_persistence_fails() {
7854 let host = Host::new();
7855 let record_path = InstallRecord::path(&host.paths);
7856 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
7857
7858 let error = host
7859 .operations()
7860 .install(&host.request(StartMode::Boot))
7861 .expect_err("record persistence must fail");
7862
7863 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7864 assert!(
7865 host.controls.registrations().is_empty(),
7866 "a failed install must not leave a live unrecorded registration"
7867 );
7868 assert!(
7869 host.controls
7870 .calls()
7871 .iter()
7872 .any(|call| call == "uninstall runner-manager (boot)"),
7873 "the registration must be explicitly rolled back: {:?}",
7874 host.controls.calls()
7875 );
7876 assert!(
7877 !host.runner_root.as_path().exists(),
7878 "the rollback must take the runner root this install created with it; a directory \
7879 prepared for a registration that does not exist is litter, and on Windows it is \
7880 litter with a security descriptor"
7881 );
7882 }
7883
7884 #[test]
7889 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
7890 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
7891
7892 assert_eq!(
7896 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
7897 ServiceAccount::LocalSystem
7898 );
7899 assert_eq!(
7900 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
7901 ServiceAccount::InvokingUser
7902 );
7903
7904 let boot = default_root_sddl(&RootAdmission::LocalSystem);
7905 assert!(!grants_broad_write(&boot), "{boot}");
7906 assert!(
7907 !boot.contains("S-1-5-21"),
7908 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
7909 );
7910
7911 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
7916 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
7917 assert!(!grants_broad_write(&login), "{login}");
7918 }
7919
7920 #[test]
7921 fn an_install_reports_the_runner_root_it_prepared() {
7922 let host = Host::new();
7923 let installed = host
7924 .operations()
7925 .install(&host.request(StartMode::Boot))
7926 .expect("an install");
7927 let rendered = installed.runner_root.to_string();
7928 assert!(
7929 !rendered.contains("S-1-5-21"),
7930 "the report must add no identity to the output: {rendered}"
7931 );
7932 if cfg!(windows) {
7933 assert_eq!(
7934 installed.runner_root.path(),
7935 Some(host.runner_root.as_path())
7936 );
7937 assert!(
7938 host.runner_root.as_path().is_dir(),
7939 "the directory jobs would run in has to exist once the service is registered"
7940 );
7941 } else {
7942 assert_eq!(
7943 installed.runner_root,
7944 crate::runner_root_access::RootAccessSummary::NotApplicable,
7945 "macOS and Linux keep the runtime directory they have always used"
7946 );
7947 }
7948 }
7949
7950 #[test]
7951 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
7952 let host = Host::new();
7953 let operations = host.operations();
7954 operations
7955 .install(&host.request(StartMode::Boot))
7956 .expect("an install at boot");
7957
7958 let change = operations
7959 .set_start_mode(StartMode::Login)
7960 .expect("a switch to login");
7961
7962 assert!(change.changed);
7963 if cfg!(windows) {
7964 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
7965 assert!(
7966 host.runner_root.as_path().is_dir(),
7967 "the switch must not remove the directory it reconciled"
7968 );
7969 }
7970 assert!(
7973 change.to_string().contains("runner root"),
7974 "{}",
7975 change.to_string()
7976 );
7977 }
7978
7979 #[test]
7980 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
7981 let host = Host::new();
7982 let operations = host.operations();
7983 operations
7984 .install(&host.request(StartMode::Boot))
7985 .expect("an install at boot");
7986
7987 let change = operations
7988 .set_start_mode(StartMode::Boot)
7989 .expect("a switch to the mode already in force");
7990
7991 assert!(!change.changed);
7992 assert_eq!(
7993 change.runner_root,
7994 crate::runner_root_access::RootAccessSummary::NotApplicable,
7995 "nothing moves, so nothing about the root's access control has to; reconciling here \
7996 would turn a no-op command into one that can fail on a permission it does not need"
7997 );
7998 }
7999
8000 #[cfg(windows)]
8001 #[test]
8002 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
8003 let host = Host::new();
8004 host.controls
8005 .fail_next_install(StartMode::Boot, "injected registration failure");
8006
8007 let error = host
8008 .operations()
8009 .install(&host.request(StartMode::Boot))
8010 .expect_err("the manager refuses the registration");
8011
8012 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8013 assert!(
8014 !host.runner_root.as_path().exists(),
8015 "the directory was created for a registration that does not exist"
8016 );
8017 }
8018
8019 #[cfg(windows)]
8020 #[test]
8021 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
8022 let host = Host::new();
8023 crate::runner_root_access::create_with_descriptor_for_tests(
8027 host.runner_root.as_path(),
8028 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
8029 )
8030 .expect("a deliberately open runner root");
8031 let before = crate::runner_root_access::report(host.runner_root.as_path());
8032
8033 let error = host
8034 .operations()
8035 .install(&host.request(StartMode::Boot))
8036 .expect_err("an open runner root is refused");
8037
8038 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
8039 assert!(
8040 error.to_string().contains("nothing was registered"),
8041 "{error}"
8042 );
8043 assert!(
8044 host.controls.registrations().is_empty(),
8045 "the refusal has to come before the platform is asked to register anything: {:?}",
8046 host.controls.calls()
8047 );
8048 assert_eq!(
8049 crate::runner_root_access::report(host.runner_root.as_path()),
8050 before,
8051 "an open directory is refused rather than tightened: its contents cannot be trusted, \
8052 so adopting it would be worse than declining it"
8053 );
8054 }
8055
8056 #[cfg(windows)]
8057 #[test]
8058 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
8059 let host = Host::new();
8060 let operations = host.operations();
8061 operations
8062 .install(&host.request(StartMode::Boot))
8063 .expect("an install");
8064 assert!(host.runner_root.as_path().is_dir());
8065
8066 operations.uninstall().expect("an uninstall");
8067
8068 assert!(
8069 host.runner_root.as_path().is_dir(),
8070 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
8071 runner root may hold an operator's retained workspaces."
8072 );
8073 }
8074
8075 #[test]
8076 fn install_reviews_what_it_registered() {
8077 let host = Host::new();
8078 let installed = host
8079 .operations()
8080 .install(&host.request(StartMode::Boot))
8081 .expect("an install");
8082 assert!(
8083 installed.review.is_least_privilege(),
8084 "{}",
8085 installed.review
8086 );
8087 assert!(
8088 !installed.review.controls().is_empty(),
8089 "a review that confirms nothing proves nothing: {}",
8090 installed.review
8091 );
8092 assert_eq!(
8093 installed.review.kind(),
8094 host_definition_kind(StartMode::Boot),
8095 "the review must be of the definition this host's manager was given"
8096 );
8097 assert!(
8098 !installed.review.account().justification().is_empty(),
8099 "a privileged account with no stated reason is an unreviewed one"
8100 );
8101 }
8102
8103 #[test]
8108 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
8109 let host = Host::new();
8110 let operations = host.operations();
8111 operations
8112 .install(&host.request(StartMode::Boot))
8113 .expect("an install");
8114
8115 let config = host.paths.config_dir();
8119 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
8120 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
8121 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
8122 std::fs::write(
8123 host.paths
8124 .state_dir()
8125 .join("packages/2.330.0/runner.tar.gz"),
8126 b"cached package",
8127 )
8128 .expect("writable");
8129 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
8130 std::fs::write(
8131 host.paths.state_dir().join("secrets/user-access-token"),
8132 b"a stand-in for the stored credential",
8133 )
8134 .expect("writable");
8135 std::fs::write(
8136 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
8137 b"diagnostics",
8138 )
8139 .expect("writable");
8140
8141 let roots: Vec<PathBuf> = host
8142 .paths
8143 .all()
8144 .iter()
8145 .map(|(_, path)| (*path).to_path_buf())
8146 .collect();
8147 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
8148 let before = snapshot(&roots);
8149
8150 assert!(
8153 before.len() >= 6,
8154 "the fixture must actually contain the files this test is about, got {before:#?}"
8155 );
8156 let record_path = InstallRecord::path(&host.paths);
8157 assert!(
8158 before.contains_key(&record_path),
8159 "the install record must be present before uninstall"
8160 );
8161
8162 let uninstalled = operations.uninstall().expect("an uninstall");
8163 assert!(uninstalled.removed_registration);
8164 assert!(uninstalled.removed_record);
8165
8166 let after = snapshot(&roots);
8167
8168 let mut expected = before.clone();
8170 expected.remove(&record_path);
8171 assert_eq!(
8172 after, expected,
8173 "uninstall must remove its own record and nothing else"
8174 );
8175 assert!(
8176 !record_path.exists(),
8177 "the record itself must go, or `uninstall` did nothing at all"
8178 );
8179 assert!(
8180 uninstalled
8181 .preserved
8182 .iter()
8183 .all(|path| roots.contains(&path.as_path())),
8184 "the preserved list must name the four directories: {uninstalled}"
8185 );
8186 }
8187
8188 #[test]
8189 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
8190 let host = Host::new();
8191 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
8192 assert!(!uninstalled.removed_registration);
8193 assert!(!uninstalled.removed_record);
8194 }
8195
8196 #[test]
8197 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
8198 let host = Host::new();
8199 let operations = host.operations();
8200 operations
8201 .install(&host.request(StartMode::Boot))
8202 .expect("an install");
8203 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
8204
8205 let uninstalled = operations.uninstall().expect("an uninstall");
8206 assert!(
8207 uninstalled.removed_registration,
8208 "a lost record must not strand a registration"
8209 );
8210 assert!(host.controls.registrations().is_empty());
8211 }
8212
8213 #[test]
8218 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
8219 let host = Host::new();
8220 let operations = host.operations();
8221 operations
8222 .install(&host.request(StartMode::Boot))
8223 .expect("an install at boot");
8224
8225 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
8229
8230 let change = operations
8231 .set_start_mode(StartMode::Login)
8232 .expect("a switch that does not reinstall the product");
8233 assert!(change.changed);
8234 assert_eq!(change.from, StartMode::Boot);
8235 assert_eq!(change.to, StartMode::Login);
8236 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
8237
8238 let record = InstallRecord::read(&host.paths)
8239 .expect("readable")
8240 .expect("a record");
8241 assert_eq!(record.start_mode, StartMode::Login);
8242 assert_eq!(
8243 record.binary, host.binary,
8244 "the recorded path must survive the switch untouched"
8245 );
8246
8247 let registrations = host.controls.registrations();
8248 assert_eq!(registrations.len(), 1, "{registrations:?}");
8249 assert_eq!(registrations[0].0, StartMode::Login);
8250 assert!(
8251 registrations[0]
8252 .2
8253 .command_line
8254 .contains(&host.binary.to_string_lossy().into_owned()),
8255 "{:?}",
8256 registrations[0].2
8257 );
8258 }
8259
8260 #[test]
8261 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
8262 let host = Host::new();
8263 let operations = host.operations();
8264 operations
8265 .install(&host.request(StartMode::Boot))
8266 .expect("an install at boot");
8267 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8268 host.controls
8269 .fail_next_install(StartMode::Login, "injected target failure");
8270
8271 let error = operations
8272 .set_start_mode(StartMode::Login)
8273 .expect_err("the target manager refuses the install");
8274
8275 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8276 assert_eq!(
8277 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8278 record_before
8279 );
8280 let registrations = host.controls.registrations();
8281 assert_eq!(registrations.len(), 1, "{registrations:?}");
8282 assert_eq!(registrations[0].0, StartMode::Boot);
8283 }
8284
8285 #[test]
8286 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
8287 let host = Host::new();
8288 let operations = host.operations();
8289 operations
8290 .install(&host.request(StartMode::Boot))
8291 .expect("an install at boot");
8292 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8293 let config = host.paths.config_dir().to_path_buf();
8294 let hidden = config.with_file_name("config-hidden-by-fault");
8295 host.controls.hide_directory_after_install(
8296 StartMode::Login,
8297 config.clone(),
8298 hidden.clone(),
8299 );
8300
8301 let error = operations
8302 .set_start_mode(StartMode::Login)
8303 .expect_err("the injected filesystem fault prevents persistence");
8304
8305 std::fs::remove_file(&config).expect("remove the injected blocker");
8306 std::fs::rename(&hidden, &config).expect("restore the record directory");
8307 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8308 assert_eq!(
8309 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8310 record_before
8311 );
8312 let registrations = host.controls.registrations();
8313 assert_eq!(registrations.len(), 1, "{registrations:?}");
8314 assert_eq!(registrations[0].0, StartMode::Boot);
8315 assert!(
8316 host.controls
8317 .calls()
8318 .iter()
8319 .any(|call| call == "uninstall runner-manager (login)"),
8320 "the target must be rolled back: {:?}",
8321 host.controls.calls()
8322 );
8323 }
8324
8325 #[test]
8326 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
8327 let host = Host::new();
8328 let operations = host.operations();
8329 operations
8330 .install(&host.request(StartMode::Boot))
8331 .expect("an install");
8332 let before = host.controls.calls().len();
8333
8334 let change = operations
8335 .set_start_mode(StartMode::Boot)
8336 .expect("a no-op switch");
8337 assert!(!change.changed);
8338 assert_eq!(
8339 host.controls.calls().len(),
8340 before,
8341 "a no-op switch must not touch the service manager"
8342 );
8343 }
8344
8345 #[test]
8346 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
8347 let host = Host::new();
8348 let error = host
8349 .operations()
8350 .set_start_mode(StartMode::Login)
8351 .expect_err("there is nothing to switch");
8352 assert!(
8353 matches!(error, ServiceError::NotInstalled { .. }),
8354 "{error}"
8355 );
8356 }
8357
8358 #[test]
8363 fn status_reports_the_four_facts_journey_five_asks_for() {
8364 let host = Host::new();
8365 let operations = host.operations();
8366 operations
8367 .install(&host.request(StartMode::Boot))
8368 .expect("an install");
8369 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
8370 .expect("a valid timestamp")
8371 .with_timezone(&Utc);
8372 record_github_contact(&host.paths, at).expect("a heartbeat");
8373
8374 let status = operations.status().expect("a status");
8375 assert_eq!(status.start_mode(), Some(StartMode::Boot));
8376 assert_eq!(
8377 status.binary().map(BinaryPath::recorded),
8378 Some(host.binary.as_path())
8379 );
8380 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
8381 assert_eq!(status.last_github_contact(), Some(at));
8382 assert!(status.is_installed());
8383 assert!(status.is_healthy(), "{status}");
8384
8385 let printed = status.to_string();
8386 for fragment in [
8387 "start mode",
8388 "diagnostic log",
8389 "last GitHub contact",
8390 "binary",
8391 ] {
8392 assert!(printed.contains(fragment), "{printed}");
8393 }
8394 }
8395
8396 #[cfg(unix)]
8408 #[test]
8409 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
8410 use std::os::unix::fs::PermissionsExt as _;
8411
8412 if unsafe { libc::geteuid() } == 0 {
8416 return;
8417 }
8418
8419 let host = Host::new();
8420 let operations = host.operations();
8421 operations
8422 .install(&host.request(StartMode::Boot))
8423 .expect("an install");
8424 assert!(
8425 operations.status().expect("a status").is_healthy(),
8426 "the discriminator: healthy before the record is made unreadable"
8427 );
8428
8429 let record = InstallRecord::path(&host.paths);
8430 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
8431 .expect("the mode is applied");
8432
8433 let status = operations
8434 .status()
8435 .expect("a record this account may not read is reported, not thrown");
8436 assert!(status.is_installed(), "{status}");
8437 assert!(!status.is_healthy(), "{status}");
8438
8439 let printed = status.to_string();
8440 assert!(
8441 printed.contains("this account may not read it"),
8442 "the operator is told which of the two states this is: {printed}"
8443 );
8444 assert!(
8445 !printed.contains("there is no install record"),
8446 "a record that is there and unreadable is not a record that is missing, and the \
8447 missing one's remedy starts with `service uninstall`: {printed}"
8448 );
8449
8450 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
8453 .expect("the mode is restored");
8454 }
8455
8456 #[test]
8457 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
8458 let host = Host::new();
8459 let operations = host.operations();
8460 operations
8461 .install(&host.request(StartMode::Boot))
8462 .expect("an install");
8463
8464 assert!(
8467 operations.status().expect("a status").is_healthy(),
8468 "the freshly installed host must be healthy"
8469 );
8470
8471 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
8472
8473 let status = operations.status().expect("a status");
8474 assert!(!status.is_healthy(), "{status}");
8475 assert!(
8476 status
8477 .problems()
8478 .iter()
8479 .any(|problem| problem.subject == "binary"),
8480 "{status}"
8481 );
8482 assert!(status.to_string().contains("STALE"), "{status}");
8483 }
8484
8485 #[test]
8486 fn status_reports_a_registration_that_would_not_start_at_boot() {
8487 let host = Host::new();
8488 let operations = host.operations();
8489 operations
8490 .install(&host.request(StartMode::Boot))
8491 .expect("an install");
8492 assert!(operations.status().expect("a status").is_healthy());
8493
8494 host.controls.edit("runner-manager", |registration| {
8495 registration.starts_automatically = false;
8496 });
8497
8498 let status = operations.status().expect("a status");
8499 assert!(!status.is_healthy(), "{status}");
8500 assert!(
8501 status
8502 .problems()
8503 .iter()
8504 .any(|problem| problem.detail.contains("after a reboot")),
8505 "{status}"
8506 );
8507 }
8508
8509 #[test]
8510 fn status_reports_a_restart_policy_something_else_edited() {
8511 let host = Host::new();
8512 let operations = host.operations();
8513 operations
8514 .install(&host.request(StartMode::Boot))
8515 .expect("an install");
8516 assert!(operations.status().expect("a status").is_healthy());
8517
8518 host.controls.edit("runner-manager", |registration| {
8519 registration.restart_delay = Some(Duration::from_secs(1));
8520 });
8521
8522 let status = operations.status().expect("a status");
8523 assert!(!status.is_healthy(), "{status}");
8524 assert!(
8525 status
8526 .problems()
8527 .iter()
8528 .any(|problem| problem.subject == "restart policy"),
8529 "{status}"
8530 );
8531 }
8532
8533 #[test]
8534 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
8535 let host = Host::new();
8536 let operations = host.operations();
8537 operations
8538 .install(&host.request(StartMode::Boot))
8539 .expect("an install");
8540 let other = host.binary.with_file_name("someone-elses.exe");
8541 std::fs::write(&other, b"x").expect("writable");
8542
8543 host.controls.edit("runner-manager", |registration| {
8544 registration.command_line = quote_argument(&other.to_string_lossy());
8545 });
8546
8547 let status = operations.status().expect("a status");
8548 assert!(!status.is_healthy(), "{status}");
8549 assert!(
8550 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
8551 "{status}"
8552 );
8553 }
8554
8555 #[test]
8556 fn status_reports_a_record_no_service_manager_knows_about() {
8557 let host = Host::new();
8558 let operations = host.operations();
8559 operations
8560 .install(&host.request(StartMode::Boot))
8561 .expect("an install");
8562 for mode in [StartMode::Boot, StartMode::Login] {
8564 host.controls
8565 .control(mode)
8566 .expect("a control")
8567 .uninstall(&ServiceIdentity::product())
8568 .expect("removed");
8569 }
8570
8571 let status = operations.status().expect("a status");
8572 assert!(!status.is_healthy(), "{status}");
8573 assert!(
8574 status
8575 .problems()
8576 .iter()
8577 .any(|problem| problem.subject == "registration"),
8578 "{status}"
8579 );
8580 }
8581
8582 #[test]
8583 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
8584 let host = Host::new();
8585 let status = host.operations().status().expect("a status");
8586 assert!(!status.is_installed());
8587 assert!(
8588 status.is_healthy(),
8589 "a host that never installed the service has no fault to report: {status}"
8590 );
8591 assert!(status.to_string().contains("installed"), "{status}");
8592 }
8593
8594 #[test]
8595 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
8596 let host = Host::new();
8597 let operations = host.operations();
8598 operations
8599 .install(&host.request(StartMode::Login))
8600 .expect("an install at login");
8601 let status = operations.status().expect("a status");
8602 assert!(
8603 status
8604 .notes()
8605 .iter()
8606 .any(|note| note.contains("does not run until the operator signs in")),
8607 "05-infrastructure.md requires `service status` to say so: {status}"
8608 );
8609 }
8610
8611 #[test]
8616 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
8617 let host = Host::new();
8618 let operations = host.operations();
8619 operations
8620 .install(&host.request(StartMode::Login))
8621 .expect("an install at login");
8622 operations.start().expect("a start");
8623 assert!(operations.status().expect("a status").is_running());
8624 assert!(operations.stop().expect("a stop"));
8625 assert!(!operations.status().expect("a status").is_running());
8626 }
8627
8628 #[test]
8629 fn starting_a_host_with_no_registration_is_refused() {
8630 let host = Host::new();
8631 let error = host.operations().start().expect_err("nothing to start");
8632 assert!(
8633 matches!(error, ServiceError::NotInstalled { .. }),
8634 "{error}"
8635 );
8636 }
8637
8638 #[cfg(windows)]
8654 #[test]
8655 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
8656 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
8657
8658 let root = tempfile::tempdir().expect("a temporary directory");
8659 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
8660 .expect("a rooted machine-scoped store");
8661 store
8662 .store(&secrecy::SecretString::from("a stand-in for the token"))
8663 .expect("the store accepts a value");
8664 let protection = store.protection().expect("the DACL can be read back");
8665
8666 assert!(
8667 protection.description().contains(";;;SY)"),
8668 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
8669 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
8670 protection.description()
8671 );
8672 assert_eq!(
8673 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
8674 ServiceAccount::LocalSystem,
8675 "and that is the account this installer registers, which is why SY is what matters"
8676 );
8677 assert!(
8678 !protection.readable_by_other_local_users(),
8679 "the same DACL must still exclude ordinary local users: {}",
8680 protection.description()
8681 );
8682
8683 for rejected in [";;;LS)", ";;;NS)"] {
8687 assert!(
8688 !protection.description().contains(rejected),
8689 "if the store ever admitted {rejected}, the least-privilege analysis in \
8690 docs/service-account.md would need redoing: {}",
8691 protection.description()
8692 );
8693 }
8694 }
8695}