1use std::collections::BTreeMap;
91use std::ffi::OsString;
92use std::fmt;
93use std::path::{Path, PathBuf};
94use std::time::Duration;
95
96use chrono::{DateTime, Utc};
97use runner_manager_domain::model::StartMode;
98use runner_manager_domain::path::LocalAbsolutePath;
99use serde::{Deserialize, Serialize};
100
101use crate::lock::{HostLock, LockError, LockKind};
102use crate::paths::AppPaths;
103#[cfg(windows)]
104use crate::runner_root_access::RootAdmission;
105use crate::runner_root_access::{
106 Reversal, RootAccessChange, RootAccessError, RootAccessReport, RootAccessSummary,
107};
108
109pub const SERVICE_NAME: &str = "runner-manager";
115pub const WINDOWS_SCM_HOST_ARGUMENT: &str = "--windows-service-host";
121
122pub const DISPLAY_NAME: &str = "GitHub Actions Runner Manager";
125
126pub const DESCRIPTION: &str =
128 "Starts ephemeral GitHub Actions self-hosted runners on this machine on demand.";
129
130pub const DAEMON_ARGUMENTS: [&str; 2] = ["daemon", "run"];
136
137pub const RECORD_FILE: &str = "service.toml";
145
146pub const CONTACT_FILE: &str = "github-contact.toml";
152
153pub const CREDENTIAL_REJECTION_FILE: &str = "github-credential-rejected.toml";
157
158pub const ROOT_REFUSAL_FILE: &str = "runner-root-refusal.toml";
162
163pub const LOG_FILE_STEM: &str = crate::logging::SERVICE_LOG_STEM;
174
175#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ServiceIdentity {
189 name: String,
190 display_name: String,
191 description: String,
192}
193
194impl ServiceIdentity {
195 #[must_use]
197 pub fn product() -> Self {
198 Self {
199 name: SERVICE_NAME.to_string(),
200 display_name: DISPLAY_NAME.to_string(),
201 description: DESCRIPTION.to_string(),
202 }
203 }
204
205 #[must_use]
212 pub fn fixture(tag: &str) -> Self {
213 let tag: String = tag
214 .chars()
215 .map(|c| {
216 if c.is_ascii_alphanumeric() {
217 c.to_ascii_lowercase()
218 } else {
219 '-'
220 }
221 })
222 .collect();
223 let name = format!("{SERVICE_NAME}-selftest-{tag}");
224 Self {
225 display_name: format!("{DISPLAY_NAME} (self-test fixture {tag})"),
226 description: "Disposable fixture created by runner-manager's own installer tests. \
227 Safe to remove."
228 .to_string(),
229 name,
230 }
231 }
232
233 #[must_use]
238 pub fn is_fixture(&self) -> bool {
239 self.name.starts_with(&format!("{SERVICE_NAME}-selftest-"))
240 }
241
242 #[must_use]
245 pub fn name(&self) -> &str {
246 &self.name
247 }
248
249 #[must_use]
251 pub fn display_name(&self) -> &str {
252 &self.display_name
253 }
254
255 #[must_use]
257 pub fn description(&self) -> &str {
258 &self.description
259 }
260
261 #[must_use]
267 pub fn launchd_label(&self) -> String {
268 format!(
269 "{}.{}.{}",
270 crate::paths::QUALIFIER,
271 crate::paths::ORGANIZATION,
272 self.name
273 )
274 }
275
276 #[must_use]
278 pub fn systemd_unit(&self) -> String {
279 format!("{}.service", self.name)
280 }
281}
282
283impl fmt::Display for ServiceIdentity {
284 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285 f.write_str(&self.name)
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub struct RestartPolicy {
304 delay: Duration,
305 reset_after: Duration,
306}
307
308impl RestartPolicy {
309 pub const MIN_DELAY: Duration = Duration::from_secs(1);
316
317 pub const MAX_DELAY: Duration = Duration::from_secs(300);
320
321 pub const DEFAULT_DELAY: Duration = Duration::from_secs(15);
323
324 pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(600);
326
327 pub fn new(delay: Duration, reset_after: Duration) -> Result<Self, ServiceError> {
335 if delay < Self::MIN_DELAY || delay > Self::MAX_DELAY {
336 return Err(ServiceError::RestartDelay {
337 requested_secs: delay.as_secs(),
338 min_secs: Self::MIN_DELAY.as_secs(),
339 max_secs: Self::MAX_DELAY.as_secs(),
340 });
341 }
342 if reset_after <= delay {
343 return Err(ServiceError::RestartResetWindow {
344 reset_secs: reset_after.as_secs(),
345 delay_secs: delay.as_secs(),
346 });
347 }
348 Ok(Self { delay, reset_after })
349 }
350
351 #[must_use]
353 pub const fn delay(&self) -> Duration {
354 self.delay
355 }
356
357 #[must_use]
359 pub const fn reset_after(&self) -> Duration {
360 self.reset_after
361 }
362
363 #[must_use]
380 pub const fn effective_delay(&self, kind: DefinitionKind) -> Duration {
381 match kind {
382 DefinitionKind::WindowsScheduledTask => {
383 let seconds = self.delay.as_secs();
384 let minutes = seconds.div_ceil(60);
385 Duration::from_secs(if minutes == 0 { 60 } else { minutes * 60 })
386 }
387 _ => self.delay,
388 }
389 }
390}
391
392impl Default for RestartPolicy {
393 fn default() -> Self {
394 Self {
395 delay: Self::DEFAULT_DELAY,
396 reset_after: Self::DEFAULT_RESET_AFTER,
397 }
398 }
399}
400
401impl fmt::Display for RestartPolicy {
402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403 write!(
404 f,
405 "restart after {}s, failure count resets after {}s",
406 self.delay.as_secs(),
407 self.reset_after.as_secs()
408 )
409 }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "snake_case")]
424pub enum ServiceAccount {
425 LocalSystem,
428 Root,
431 InvokingUser,
433}
434
435impl ServiceAccount {
436 #[must_use]
445 pub const fn for_definition(kind: DefinitionKind, mode: StartMode) -> Self {
446 match (kind, mode) {
447 (DefinitionKind::WindowsService, _) => Self::LocalSystem,
448 (DefinitionKind::WindowsScheduledTask, _) | (_, StartMode::Login) => Self::InvokingUser,
449 (_, StartMode::Boot) => Self::Root,
450 }
451 }
452
453 #[must_use]
456 pub const fn for_start_mode(mode: StartMode) -> Self {
457 Self::for_definition(host_definition_kind(mode), mode)
458 }
459
460 #[must_use]
462 pub const fn as_str(&self) -> &'static str {
463 match self {
464 Self::LocalSystem => "NT AUTHORITY\\SYSTEM",
465 Self::Root => "root",
466 Self::InvokingUser => "the invoking user",
467 }
468 }
469
470 #[must_use]
477 pub const fn justification(&self) -> &'static str {
478 match self {
479 Self::LocalSystem => {
480 "the machine-scoped store's DACL names SY, BA and OW only; LocalService and \
481 NetworkService cannot read it, and widening the DACL to reach them would grant \
482 every service on this host read access to the one credential this product holds"
483 }
484 Self::Root => {
485 "a boot-time registration runs outside every login session: on macOS the System \
486 Keychain is unlocked by /var/db/SystemKey, which is root-only, and on Linux the \
487 machine-scoped store is a 0600 file under /var/lib that only root can open \
488 before a session exists"
489 }
490 Self::InvokingUser => {
491 "a login-mode registration reads the user-scoped store, which is deliberately \
492 readable by exactly one account and needs no elevation at all"
493 }
494 }
495 }
496
497 #[must_use]
499 pub const fn needs_elevation(&self) -> bool {
500 matches!(self, Self::LocalSystem | Self::Root)
501 }
502}
503
504impl fmt::Display for ServiceAccount {
505 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506 f.write_str(self.as_str())
507 }
508}
509
510#[derive(Debug, thiserror::Error)]
521pub enum ServiceError {
522 #[error("cannot install the service while an agent is already running on this host: {source}")]
528 LockHeld {
529 #[source]
531 source: Box<LockError>,
532 },
533
534 #[error(
537 "cannot tell whether an agent is already running on this host, so the service was not \
538 installed: {source}. Fix the reported problem with the state directory and try again."
539 )]
540 LockUnreadable {
541 #[source]
543 source: Box<LockError>,
544 },
545
546 #[error(
554 "this registration would run jobs under the default runner root, and that root could \
555 not be prepared, so nothing was registered: {source}"
556 )]
557 RunnerRoot {
558 #[source]
560 source: Box<RootAccessError>,
561 },
562
563 #[error(
565 "cannot resolve the absolute path of this executable, so there is nothing to register: \
566 {detail}. Run the installer from the installed binary rather than through a shell \
567 function or a wrapper that replaces argv[0]."
568 )]
569 BinaryPath {
570 detail: String,
572 },
573
574 #[error(
576 "{} is not a file, so registering it would create a service that cannot start. Install \
577 the product first, then run `service install` from the installed binary.",
578 path.display()
579 )]
580 BinaryMissing {
581 path: PathBuf,
583 },
584
585 #[error(
587 "a restart delay of {requested_secs}s is outside the supported range \
588 {min_secs}s-{max_secs}s. Below the floor the platform's own throttle overrides the \
589 value, so `service status` would report a delay that is not the one in force."
590 )]
591 RestartDelay {
592 requested_secs: u64,
594 min_secs: u64,
596 max_secs: u64,
598 },
599
600 #[error(
602 "a failure-count reset window of {reset_secs}s is not longer than the {delay_secs}s \
603 restart delay, so the count would reset between every pair of restarts and no \
604 start limit could ever apply."
605 )]
606 RestartResetWindow {
607 reset_secs: u64,
609 delay_secs: u64,
611 },
612
613 #[error(
619 "{name} is already registered to start at {existing}, and this asks for {requested}. \
620 Changing the start mode moves the registration between two different service \
621 managers, so it is not something an install does by itself: run `service uninstall` \
622 first, or change the start mode in the terminal UI (`runner-manager tui`), which \
623 switches it in place and keeps the registration running throughout."
624 )]
625 AlreadyInstalled {
626 name: String,
628 existing: StartMode,
630 requested: StartMode,
632 },
633
634 #[error("{name} is not registered on this host, so there is nothing to {operation}.")]
636 NotInstalled {
637 name: String,
639 operation: &'static str,
641 },
642
643 #[error("cannot {operation} the service record {}: {detail}", path.display())]
645 Record {
646 operation: &'static str,
648 path: PathBuf,
650 detail: String,
652 },
653
654 #[error(
656 "the service record {} was not written by this product, or was written by a version \
657 this one cannot read: {detail}. Run `service uninstall` and `service install` again; \
658 neither touches configuration, secrets, or the cache.",
659 path.display()
660 )]
661 RecordUnreadable {
662 path: PathBuf,
664 detail: String,
666 },
667
668 #[error(
682 "the service record {} is there and this account may not read it: {detail}. It was \
683 written by whichever account installed the service -- on a boot-mode host, `sudo \
684 service install`. Running `service install` again rewrites it readable; until then \
685 only what the service manager itself reports is available.",
686 path.display()
687 )]
688 RecordNotPermitted {
689 path: PathBuf,
691 detail: String,
693 },
694
695 #[error("cannot prepare this host's application-data directories: {source}")]
697 Paths {
698 #[source]
700 source: Box<crate::paths::PathsError>,
701 },
702
703 #[error("cannot {operation} {name} through {manager}: {detail}")]
705 Control {
706 operation: &'static str,
708 name: String,
710 manager: &'static str,
712 detail: String,
714 },
715
716 #[error(
718 "cannot {operation} {name}: {cause}. The attempted rollback also failed: {rollback}. \
719 Inspect `service status` before retrying."
720 )]
721 Rollback {
722 operation: &'static str,
724 name: String,
726 cause: String,
728 rollback: String,
730 },
731
732 #[error("{operation} {name} needs administrative rights: {detail}. {remedy}")]
734 NeedsElevation {
735 operation: &'static str,
737 name: String,
739 detail: String,
741 remedy: &'static str,
743 },
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
758pub struct ServiceDirectories {
759 pub config: PathBuf,
761 pub state: PathBuf,
763 pub runtime: PathBuf,
765 pub logs: PathBuf,
767}
768
769impl ServiceDirectories {
770 #[must_use]
772 pub fn of(paths: &AppPaths) -> Self {
773 Self {
774 config: paths.config_dir().to_path_buf(),
775 state: paths.state_dir().to_path_buf(),
776 runtime: paths.runtime_dir().to_path_buf(),
777 logs: paths.logs_dir().to_path_buf(),
778 }
779 }
780
781 #[must_use]
783 pub fn all(&self) -> [&Path; 4] {
784 [
785 self.config.as_path(),
786 self.state.as_path(),
787 self.runtime.as_path(),
788 self.logs.as_path(),
789 ]
790 }
791
792 #[must_use]
795 pub fn log_file(&self) -> PathBuf {
796 self.logs.join(LOG_FILE_STEM)
797 }
798}
799
800#[derive(Debug, Clone, PartialEq, Eq)]
812pub struct InstallRequest {
813 start_mode: StartMode,
814 binary: Option<PathBuf>,
815 source_binary: Option<PathBuf>,
816 arguments: Vec<OsString>,
817 restart: RestartPolicy,
818 on_demand: bool,
819}
820
821impl InstallRequest {
822 #[must_use]
824 pub fn new(start_mode: StartMode) -> Self {
825 Self {
826 start_mode,
827 binary: None,
828 source_binary: None,
829 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
830 restart: RestartPolicy::default(),
831 on_demand: false,
832 }
833 }
834
835 #[must_use]
845 pub const fn started_on_demand(mut self) -> Self {
846 self.on_demand = true;
847 self
848 }
849
850 #[must_use]
857 pub fn for_binary(mut self, binary: impl Into<PathBuf>) -> Self {
858 self.binary = Some(binary.into());
859 self
860 }
861
862 #[must_use]
879 pub fn copied_from(mut self, source: impl Into<PathBuf>) -> Self {
880 self.source_binary = Some(source.into());
881 self
882 }
883
884 #[must_use]
886 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
887 where
888 I: IntoIterator<Item = S>,
889 S: Into<OsString>,
890 {
891 self.arguments = arguments.into_iter().map(Into::into).collect();
892 self
893 }
894
895 #[must_use]
898 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
899 self.restart = restart;
900 self
901 }
902
903 #[must_use]
905 pub const fn start_mode(&self) -> StartMode {
906 self.start_mode
907 }
908}
909
910#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct InstallPlan {
918 identity: ServiceIdentity,
919 start_mode: StartMode,
920 binary: PathBuf,
921 source_binary: Option<PathBuf>,
922 arguments: Vec<OsString>,
923 account: ServiceAccount,
924 restart: RestartPolicy,
925 directories: ServiceDirectories,
926 secret_guard: Option<PathBuf>,
927 on_demand: bool,
928}
929
930impl InstallPlan {
931 pub fn resolve(
939 identity: ServiceIdentity,
940 request: &InstallRequest,
941 directories: ServiceDirectories,
942 ) -> Result<Self, ServiceError> {
943 let binary = match &request.binary {
944 Some(named) => absolute(named)?,
945 None => running_executable()?,
946 };
947 if !binary.is_file() {
948 return Err(ServiceError::BinaryMissing { path: binary });
949 }
950 let secret_guard = crate::secrets::PlatformSecretStore::for_start_mode(request.start_mode)
955 .ok()
956 .map(|store| store.guard());
957 Ok(Self {
958 identity,
959 start_mode: request.start_mode,
960 binary,
961 source_binary: request.source_binary.clone(),
962 arguments: request.arguments.clone(),
963 account: ServiceAccount::for_start_mode(request.start_mode),
964 restart: request.restart,
965 directories,
966 secret_guard,
967 on_demand: request.on_demand,
968 })
969 }
970
971 #[must_use]
978 pub fn unchecked(
979 identity: ServiceIdentity,
980 start_mode: StartMode,
981 binary: impl Into<PathBuf>,
982 directories: ServiceDirectories,
983 ) -> Self {
984 Self {
985 identity,
986 start_mode,
987 binary: binary.into(),
988 source_binary: None,
989 arguments: DAEMON_ARGUMENTS.iter().map(OsString::from).collect(),
990 account: ServiceAccount::for_start_mode(start_mode),
991 restart: RestartPolicy::default(),
992 directories,
993 secret_guard: None,
994 on_demand: false,
995 }
996 }
997
998 #[must_use]
1001 pub const fn started_on_demand(mut self) -> Self {
1002 self.on_demand = true;
1003 self
1004 }
1005
1006 #[must_use]
1008 pub const fn is_on_demand(&self) -> bool {
1009 self.on_demand
1010 }
1011
1012 #[must_use]
1019 pub fn with_secret_guard(mut self, guard: impl Into<PathBuf>) -> Self {
1020 self.secret_guard = Some(guard.into());
1021 self
1022 }
1023
1024 #[must_use]
1026 pub fn secret_guard(&self) -> Option<&Path> {
1027 self.secret_guard.as_deref()
1028 }
1029
1030 #[must_use]
1032 pub const fn with_restart(mut self, restart: RestartPolicy) -> Self {
1033 self.restart = restart;
1034 self
1035 }
1036
1037 #[must_use]
1039 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
1040 where
1041 I: IntoIterator<Item = S>,
1042 S: Into<OsString>,
1043 {
1044 self.arguments = arguments.into_iter().map(Into::into).collect();
1045 self
1046 }
1047
1048 #[must_use]
1050 pub const fn identity(&self) -> &ServiceIdentity {
1051 &self.identity
1052 }
1053
1054 #[must_use]
1056 pub const fn start_mode(&self) -> StartMode {
1057 self.start_mode
1058 }
1059
1060 #[must_use]
1062 pub fn binary(&self) -> &Path {
1063 &self.binary
1064 }
1065
1066 #[must_use]
1072 pub fn source_binary(&self) -> Option<&Path> {
1073 self.source_binary.as_deref()
1074 }
1075
1076 #[must_use]
1078 pub fn arguments(&self) -> &[OsString] {
1079 &self.arguments
1080 }
1081
1082 #[must_use]
1084 pub const fn account(&self) -> &ServiceAccount {
1085 &self.account
1086 }
1087
1088 #[must_use]
1090 pub const fn restart(&self) -> RestartPolicy {
1091 self.restart
1092 }
1093
1094 #[must_use]
1096 pub const fn directories(&self) -> &ServiceDirectories {
1097 &self.directories
1098 }
1099
1100 #[must_use]
1102 pub fn command_line(&self) -> String {
1103 let mut out = quote_argument(&self.binary.to_string_lossy());
1104 for argument in &self.arguments {
1105 out.push(' ');
1106 out.push_str("e_argument(&argument.to_string_lossy()));
1107 }
1108 out
1109 }
1110}
1111
1112fn retained_runner_root(change: &RootAccessChange) -> Option<String> {
1120 let reversal = change.revert();
1121 matches!(reversal, Reversal::Retained { .. }).then(|| reversal.to_string())
1122}
1123
1124fn rolled_back<T>(
1137 retained: Option<String>,
1138 rollback: Result<T, ServiceError>,
1139 operation: &'static str,
1140 identity: &ServiceIdentity,
1141 cause: ServiceError,
1142) -> ServiceError {
1143 let left_behind = match (rollback.err(), retained) {
1144 (Some(rollback), Some(retained)) => Some(format!("{rollback}; {retained}")),
1145 (Some(rollback), None) => Some(rollback.to_string()),
1146 (None, retained) => retained,
1147 };
1148 match left_behind {
1149 Some(rollback) => ServiceError::Rollback {
1150 operation,
1151 name: identity.name().to_string(),
1152 cause: cause.to_string(),
1153 rollback,
1154 },
1155 None => cause,
1156 }
1157}
1158
1159fn undo_runner_root(
1162 change: &RootAccessChange,
1163 operation: &'static str,
1164 identity: &ServiceIdentity,
1165 cause: ServiceError,
1166) -> ServiceError {
1167 rolled_back(
1168 retained_runner_root(change),
1169 Ok(()),
1170 operation,
1171 identity,
1172 cause,
1173 )
1174}
1175
1176fn absolute(path: &Path) -> Result<PathBuf, ServiceError> {
1190 std::path::absolute(path).map_err(|error| ServiceError::BinaryPath {
1191 detail: format!("{} could not be made absolute: {error}", path.display()),
1192 })
1193}
1194
1195fn running_executable() -> Result<PathBuf, ServiceError> {
1197 let raw = std::env::current_exe().map_err(|error| ServiceError::BinaryPath {
1198 detail: error.to_string(),
1199 })?;
1200 absolute(&raw)
1201}
1202
1203pub(crate) fn quote_argument(argument: &str) -> String {
1212 if !argument.is_empty() && !argument.contains([' ', '"', '\t', '\n']) {
1213 return argument.to_string();
1214 }
1215 let mut out = String::with_capacity(argument.len() + 2);
1216 out.push('"');
1217 let mut backslashes = 0usize;
1218 for c in argument.chars() {
1219 match c {
1220 '\\' => {
1221 backslashes += 1;
1222 out.push('\\');
1223 }
1224 '"' => {
1225 for _ in 0..=backslashes {
1228 out.push('\\');
1229 }
1230 out.push('"');
1231 backslashes = 0;
1232 }
1233 other => {
1234 backslashes = 0;
1235 out.push(other);
1236 }
1237 }
1238 }
1239 for _ in 0..backslashes {
1241 out.push('\\');
1242 }
1243 out.push('"');
1244 out
1245}
1246
1247#[must_use]
1258pub fn executable_from_command_line(command_line: &str) -> Option<PathBuf> {
1259 let trimmed = command_line.trim_start();
1260 if trimmed.is_empty() {
1261 return None;
1262 }
1263 let mut out = String::new();
1264 let mut chars = trimmed.chars().peekable();
1265 let quoted = chars.peek() == Some(&'"');
1266 if quoted {
1267 chars.next();
1268 let mut backslashes = 0usize;
1269 for c in chars {
1270 match c {
1271 '\\' => {
1272 backslashes += 1;
1273 }
1274 '"' => {
1275 out.extend(std::iter::repeat_n('\\', backslashes / 2));
1278 if backslashes.is_multiple_of(2) {
1279 break;
1280 }
1281 backslashes = 0;
1282 out.push('"');
1283 }
1284 other => {
1285 out.extend(std::iter::repeat_n('\\', backslashes));
1286 backslashes = 0;
1287 out.push(other);
1288 }
1289 }
1290 }
1291 } else {
1292 for c in chars {
1293 if c == ' ' || c == '\t' {
1294 break;
1295 }
1296 out.push(c);
1297 }
1298 }
1299 if out.is_empty() {
1300 None
1301 } else {
1302 Some(PathBuf::from(out))
1303 }
1304}
1305
1306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1312pub enum DefinitionKind {
1313 WindowsService,
1317 WindowsScheduledTask,
1319 LaunchdPlist,
1321 SystemdUnit,
1323}
1324
1325impl DefinitionKind {
1326 #[must_use]
1328 pub const fn manager(self) -> &'static str {
1329 match self {
1330 Self::WindowsService => "the Windows Service Control Manager",
1331 Self::WindowsScheduledTask => "Windows Task Scheduler",
1332 Self::LaunchdPlist => "launchd",
1333 Self::SystemdUnit => "systemd",
1334 }
1335 }
1336}
1337
1338impl fmt::Display for DefinitionKind {
1339 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1340 f.write_str(self.manager())
1341 }
1342}
1343
1344#[derive(Debug, Clone, PartialEq, Eq)]
1351pub struct ServiceDefinition {
1352 kind: DefinitionKind,
1353 text: String,
1354 install_path: Option<PathBuf>,
1355}
1356
1357impl ServiceDefinition {
1358 #[must_use]
1360 pub const fn kind(&self) -> DefinitionKind {
1361 self.kind
1362 }
1363
1364 #[must_use]
1366 pub fn text(&self) -> &str {
1367 &self.text
1368 }
1369
1370 #[must_use]
1377 pub fn install_path(&self) -> Option<&Path> {
1378 self.install_path.as_deref()
1379 }
1380
1381 pub fn for_host(plan: &InstallPlan) -> Result<Self, ServiceError> {
1394 Ok(match host_definition_kind(plan.start_mode()) {
1395 DefinitionKind::WindowsService => Self::windows_service(plan),
1396 DefinitionKind::WindowsScheduledTask => {
1397 Self::windows_scheduled_task(plan, &TaskPrincipal::current()?)
1398 }
1399 DefinitionKind::LaunchdPlist => Self::launchd(plan, host_home().as_deref()),
1400 DefinitionKind::SystemdUnit => Self::systemd(plan, host_home().as_deref()),
1401 })
1402 }
1403
1404 #[must_use]
1406 pub fn windows_service(plan: &InstallPlan) -> Self {
1407 Self {
1408 kind: DefinitionKind::WindowsService,
1409 text: windows_service_descriptor(plan),
1410 install_path: None,
1411 }
1412 }
1413
1414 #[must_use]
1416 pub fn windows_scheduled_task(plan: &InstallPlan, principal: &TaskPrincipal) -> Self {
1417 Self {
1418 kind: DefinitionKind::WindowsScheduledTask,
1419 text: windows_scheduled_task_xml(plan, principal),
1420 install_path: None,
1421 }
1422 }
1423
1424 #[must_use]
1429 pub fn launchd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1430 let file = format!("{}.plist", plan.identity().launchd_label());
1431 let install_path = match plan.start_mode() {
1432 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
1433 StartMode::Login => home.map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file)),
1434 };
1435 Self {
1436 kind: DefinitionKind::LaunchdPlist,
1437 text: launchd_plist(plan),
1438 install_path,
1439 }
1440 }
1441
1442 #[must_use]
1450 pub fn from_text(kind: DefinitionKind, text: impl Into<String>) -> Self {
1451 Self {
1452 kind,
1453 text: text.into(),
1454 install_path: None,
1455 }
1456 }
1457
1458 #[must_use]
1463 pub fn systemd(plan: &InstallPlan, home: Option<&Path>) -> Self {
1464 let file = plan.identity().systemd_unit();
1465 let install_path = match plan.start_mode() {
1466 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
1467 StartMode::Login => home.map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file)),
1468 };
1469 Self {
1470 kind: DefinitionKind::SystemdUnit,
1471 text: systemd_unit(plan),
1472 install_path,
1473 }
1474 }
1475}
1476
1477pub const LAUNCH_DAEMONS_DIR: &str = "/Library/LaunchDaemons";
1479pub const LAUNCH_AGENTS_SUBDIR: &str = "Library/LaunchAgents";
1481pub const SYSTEMD_SYSTEM_DIR: &str = "/etc/systemd/system";
1483pub const SYSTEMD_USER_SUBDIR: &str = ".config/systemd/user";
1485
1486const DOCUMENTATION: &str = "https://github.com/IvanMurzak/GitHub-Runner-Scaler-UI";
1489
1490pub const START_LIMIT_BURST: u32 = 5;
1498
1499#[must_use]
1520pub fn systemd_unit(plan: &InstallPlan) -> String {
1521 let identity = plan.identity();
1522 let restart = plan.restart();
1523 let directories = plan.directories();
1524
1525 let mut out = String::new();
1526 out.push_str("[Unit]\n");
1527 out.push_str(&format!("Description={}\n", identity.display_name()));
1528 out.push_str(&format!("Documentation={DOCUMENTATION}\n"));
1529 out.push_str("After=network-online.target\n");
1530 out.push_str("Wants=network-online.target\n");
1531 out.push_str(&format!(
1534 "StartLimitIntervalSec={}\n",
1535 restart.reset_after().as_secs()
1536 ));
1537 out.push_str(&format!("StartLimitBurst={START_LIMIT_BURST}\n"));
1538
1539 out.push_str("\n[Service]\n");
1540 out.push_str("Type=simple\n");
1541 out.push_str("KillMode=process\n");
1545 out.push_str(&format!("ExecStart={}\n", plan.command_line()));
1546 out.push_str(&format!(
1547 "WorkingDirectory={}\n",
1548 directories.state.display()
1549 ));
1550 out.push_str(&format!("SyslogIdentifier={identity}\n"));
1551 out.push_str("Restart=on-failure\n");
1552 out.push_str(&format!("RestartSec={}\n", restart.delay().as_secs()));
1553
1554 out.push_str("\n# Least privilege. See docs/service-account.md.\n");
1555 for directive in SYSTEMD_HARDENING {
1556 out.push_str(directive);
1557 out.push('\n');
1558 }
1559 let mut writable = directories.all().to_vec();
1560 if let Some(secret_directory) = plan.secret_guard().and_then(Path::parent) {
1561 writable.push(secret_directory);
1562 }
1563 out.push_str(&format!(
1564 "ReadWritePaths={}\n",
1565 writable
1566 .iter()
1567 .map(|path| quote_argument(&path.to_string_lossy()))
1568 .collect::<Vec<_>>()
1569 .join(" ")
1570 ));
1571
1572 out.push_str("\n[Install]\n");
1573 out.push_str(match plan.start_mode() {
1574 StartMode::Boot => "WantedBy=multi-user.target\n",
1575 StartMode::Login => "WantedBy=default.target\n",
1576 });
1577 out
1578}
1579
1580pub const SYSTEMD_HARDENING: [&str; 13] = [
1587 "NoNewPrivileges=yes",
1588 "CapabilityBoundingSet=",
1589 "AmbientCapabilities=",
1590 "PrivateTmp=yes",
1591 "PrivateDevices=yes",
1592 "ProtectSystem=strict",
1593 "ProtectKernelTunables=yes",
1594 "ProtectKernelModules=yes",
1595 "ProtectControlGroups=yes",
1596 "RestrictNamespaces=yes",
1597 "RestrictRealtime=yes",
1598 "RestrictSUIDSGID=yes",
1599 "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
1600];
1601
1602#[must_use]
1611pub fn launchd_plist(plan: &InstallPlan) -> String {
1612 let identity = plan.identity();
1613 let directories = plan.directories();
1614 let mut out = String::new();
1615 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
1616 out.push_str(
1617 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
1618 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
1619 );
1620 out.push_str("<plist version=\"1.0\">\n<dict>\n");
1621 out.push_str(&plist_string("Label", &identity.launchd_label()));
1622
1623 out.push_str(" <key>ProgramArguments</key>\n <array>\n");
1624 out.push_str(&format!(
1625 " <string>{}</string>\n",
1626 xml_escape(&plan.binary().to_string_lossy())
1627 ));
1628 for argument in plan.arguments() {
1629 out.push_str(&format!(
1630 " <string>{}</string>\n",
1631 xml_escape(&argument.to_string_lossy())
1632 ));
1633 }
1634 out.push_str(" </array>\n");
1635
1636 out.push_str(" <key>RunAtLoad</key>\n <true/>\n");
1637 out.push_str(" <key>KeepAlive</key>\n <dict>\n");
1638 out.push_str(" <key>SuccessfulExit</key>\n <false/>\n");
1639 out.push_str(" </dict>\n");
1640 out.push_str(&format!(
1641 " <key>ThrottleInterval</key>\n <integer>{}</integer>\n",
1642 plan.restart().delay().as_secs()
1643 ));
1644 out.push_str(&plist_string("ProcessType", "Background"));
1647 out.push_str(&plist_string(
1648 "WorkingDirectory",
1649 &directories.state.to_string_lossy(),
1650 ));
1651 out.push_str(&plist_string(
1652 "StandardOutPath",
1653 &directories
1654 .logs
1655 .join("runner-manager.launchd.out.log")
1656 .to_string_lossy(),
1657 ));
1658 out.push_str(&plist_string(
1659 "StandardErrorPath",
1660 &directories
1661 .logs
1662 .join("runner-manager.launchd.err.log")
1663 .to_string_lossy(),
1664 ));
1665
1666 match plan.start_mode() {
1667 StartMode::Boot => {
1668 out.push_str(&plist_string(
1671 "UserName",
1672 ServiceAccount::for_definition(DefinitionKind::LaunchdPlist, StartMode::Boot)
1673 .as_str(),
1674 ));
1675 out.push_str(" <key>SessionCreate</key>\n <false/>\n");
1677 }
1678 StartMode::Login => {
1679 }
1683 }
1684
1685 out.push_str("</dict>\n</plist>\n");
1686 out
1687}
1688
1689fn plist_string(key: &str, value: &str) -> String {
1691 format!(
1692 " <key>{}</key>\n <string>{}</string>\n",
1693 xml_escape(key),
1694 xml_escape(value)
1695 )
1696}
1697
1698#[derive(Debug, Clone, PartialEq, Eq)]
1706pub struct TaskPrincipal {
1707 user_id: String,
1708}
1709
1710impl TaskPrincipal {
1711 pub fn current() -> Result<Self, ServiceError> {
1719 let user = std::env::var("USERNAME")
1720 .ok()
1721 .filter(|value| !value.trim().is_empty());
1722 let Some(user) = user else {
1723 return Err(ServiceError::Control {
1724 operation: "identify the account for",
1725 name: SERVICE_NAME.to_string(),
1726 manager: "Windows Task Scheduler",
1727 detail: "this session reports no %USERNAME%, so there is no principal to \
1728 register a logon-triggered task for"
1729 .to_string(),
1730 });
1731 };
1732 let domain = std::env::var("USERDOMAIN")
1733 .ok()
1734 .filter(|value| !value.trim().is_empty());
1735 Ok(Self {
1736 user_id: match domain {
1737 Some(domain) => format!("{domain}\\{user}"),
1738 None => user,
1739 },
1740 })
1741 }
1742
1743 #[must_use]
1745 pub fn named(user_id: impl Into<String>) -> Self {
1746 Self {
1747 user_id: user_id.into(),
1748 }
1749 }
1750
1751 #[must_use]
1753 pub fn user_id(&self) -> &str {
1754 &self.user_id
1755 }
1756}
1757
1758#[must_use]
1765pub fn windows_scheduled_task_xml(plan: &InstallPlan, principal: &TaskPrincipal) -> String {
1766 let identity = plan.identity();
1767 let user = xml_escape(principal.user_id());
1768 let arguments = plan
1769 .arguments()
1770 .iter()
1771 .map(|argument| quote_argument(&argument.to_string_lossy()))
1772 .collect::<Vec<_>>()
1773 .join(" ");
1774 let mut out = String::new();
1775 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
1776 out.push_str(
1777 "<Task version=\"1.4\" \
1778 xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n",
1779 );
1780 out.push_str(" <RegistrationInfo>\n");
1781 out.push_str(&format!(
1782 " <Description>{}</Description>\n",
1783 xml_escape(identity.description())
1784 ));
1785 out.push_str(&format!(
1786 " <URI>\\{}</URI>\n",
1787 xml_escape(identity.name())
1788 ));
1789 out.push_str(" </RegistrationInfo>\n");
1790
1791 out.push_str(" <Triggers>\n <LogonTrigger>\n");
1792 out.push_str(" <Enabled>true</Enabled>\n");
1793 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1794 out.push_str(" </LogonTrigger>\n </Triggers>\n");
1795
1796 out.push_str(" <Principals>\n <Principal id=\"Author\">\n");
1797 out.push_str(&format!(" <UserId>{user}</UserId>\n"));
1798 out.push_str(" <LogonType>InteractiveToken</LogonType>\n");
1799 out.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
1800 out.push_str(" </Principal>\n </Principals>\n");
1801
1802 out.push_str(" <Settings>\n");
1803 out.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
1807 out.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
1808 out.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
1809 out.push_str(" <AllowHardTerminate>true</AllowHardTerminate>\n");
1810 out.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
1811 out.push_str(" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n");
1812 out.push_str(" <IdleSettings>\n");
1813 out.push_str(" <StopOnIdleEnd>false</StopOnIdleEnd>\n");
1814 out.push_str(" <RestartOnIdle>false</RestartOnIdle>\n");
1815 out.push_str(" </IdleSettings>\n");
1816 out.push_str(" <AllowStartOnDemand>true</AllowStartOnDemand>\n");
1817 out.push_str(" <Enabled>true</Enabled>\n");
1818 out.push_str(" <Hidden>false</Hidden>\n");
1819 out.push_str(" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n");
1820 out.push_str(" <WakeToRun>false</WakeToRun>\n");
1821 out.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
1823 out.push_str(" <Priority>7</Priority>\n");
1824 out.push_str(" <RestartOnFailure>\n");
1825 out.push_str(&format!(
1826 " <Interval>{}</Interval>\n",
1827 iso8601_minutes(
1828 plan.restart()
1829 .effective_delay(DefinitionKind::WindowsScheduledTask)
1830 )
1831 ));
1832 out.push_str(&format!(" <Count>{START_LIMIT_BURST}</Count>\n"));
1833 out.push_str(" </RestartOnFailure>\n");
1834 out.push_str(" </Settings>\n");
1835
1836 out.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
1837 out.push_str(&format!(
1838 " <Command>{}</Command>\n",
1839 xml_escape(&plan.binary().to_string_lossy())
1840 ));
1841 if !arguments.is_empty() {
1842 out.push_str(&format!(
1843 " <Arguments>{}</Arguments>\n",
1844 xml_escape(&arguments)
1845 ));
1846 }
1847 out.push_str(&format!(
1848 " <WorkingDirectory>{}</WorkingDirectory>\n",
1849 xml_escape(&plan.directories().state.to_string_lossy())
1850 ));
1851 out.push_str(" </Exec>\n </Actions>\n");
1852 out.push_str("</Task>\n");
1853 out
1854}
1855
1856fn iso8601_minutes(duration: Duration) -> String {
1859 format!("PT{}M", duration.as_secs() / 60)
1860}
1861
1862pub(crate) fn xml_escape(value: &str) -> String {
1870 let mut out = String::with_capacity(value.len());
1871 for c in value.chars() {
1872 match c {
1873 '&' => out.push_str("&"),
1874 '<' => out.push_str("<"),
1875 '>' => out.push_str(">"),
1876 '"' => out.push_str("""),
1877 '\'' => out.push_str("'"),
1878 other => out.push(other),
1879 }
1880 }
1881 out
1882}
1883
1884fn xml_unescape(value: &str) -> String {
1890 value
1891 .replace("<", "<")
1892 .replace(">", ">")
1893 .replace(""", "\"")
1894 .replace("'", "'")
1895 .replace("&", "&")
1896}
1897
1898#[derive(Debug, Clone, PartialEq, Eq)]
1907pub struct WindowsServiceSpec {
1908 pub name: String,
1910 pub display_name: String,
1912 pub description: String,
1914 pub automatic_start: bool,
1916 pub account: Option<String>,
1919 pub command_line: String,
1921 pub restart: RestartPolicy,
1923}
1924
1925#[must_use]
1927pub fn windows_service_spec(plan: &InstallPlan) -> WindowsServiceSpec {
1928 WindowsServiceSpec {
1929 name: plan.identity().name().to_string(),
1930 display_name: plan.identity().display_name().to_string(),
1931 description: plan.identity().description().to_string(),
1932 automatic_start: plan.start_mode() == StartMode::Boot && !plan.is_on_demand(),
1937 account: match ServiceAccount::for_definition(
1938 DefinitionKind::WindowsService,
1939 plan.start_mode(),
1940 ) {
1941 ServiceAccount::LocalSystem => None,
1944 other => Some(other.as_str().to_string()),
1945 },
1946 command_line: plan.command_line(),
1947 restart: plan.restart(),
1948 }
1949}
1950
1951#[must_use]
1954fn windows_service_descriptor(plan: &InstallPlan) -> String {
1955 let spec = windows_service_spec(plan);
1956 let mut out = String::new();
1957 out.push_str("[windows-service]\n");
1958 out.push_str(&format!("Name={}\n", spec.name));
1959 out.push_str(&format!("DisplayName={}\n", spec.display_name));
1960 out.push_str(&format!("Description={}\n", spec.description));
1961 out.push_str("ServiceType=OWN_PROCESS\n");
1965 out.push_str(&format!(
1966 "StartType={}\n",
1967 if spec.automatic_start {
1968 "AutoStart"
1969 } else {
1970 "OnDemand"
1971 }
1972 ));
1973 out.push_str("ErrorControl=Normal\n");
1974 out.push_str(&format!(
1975 "Account={}\n",
1976 spec.account
1977 .as_deref()
1978 .unwrap_or(ServiceAccount::LocalSystem.as_str())
1979 ));
1980 out.push_str(&format!("CommandLine={}\n", spec.command_line));
1981 out.push_str(&format!(
1982 "FailureActionRestartDelaySecs={}\n",
1983 spec.restart.delay().as_secs()
1984 ));
1985 out.push_str(&format!(
1986 "FailureActionsResetPeriodSecs={}\n",
1987 spec.restart.reset_after().as_secs()
1988 ));
1989 out.push_str("FailureActionsOnNonCrashFailures=true\n");
1993 out.push_str(&format!(
1994 "ReadWritePaths={}\n",
1995 plan.directories()
1996 .all()
1997 .iter()
1998 .map(|path| quote_argument(&path.to_string_lossy()))
1999 .collect::<Vec<_>>()
2000 .join(" ")
2001 ));
2002 out
2003}
2004
2005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2011pub enum FindingKind {
2012 Excess,
2015 Shortfall,
2018}
2019
2020impl fmt::Display for FindingKind {
2021 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2022 f.write_str(match self {
2023 Self::Excess => "excess",
2024 Self::Shortfall => "shortfall",
2025 })
2026 }
2027}
2028
2029#[derive(Debug, Clone, PartialEq, Eq)]
2031pub struct PrivilegeFinding {
2032 pub kind: FindingKind,
2034 pub subject: String,
2036 pub detail: String,
2038}
2039
2040impl fmt::Display for PrivilegeFinding {
2041 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2042 write!(f, "{}: {} -- {}", self.kind, self.subject, self.detail)
2043 }
2044}
2045
2046#[derive(Debug, Clone, PartialEq, Eq)]
2060pub struct PrivilegeReview {
2061 kind: DefinitionKind,
2062 account: ServiceAccount,
2063 controls: Vec<String>,
2064 findings: Vec<PrivilegeFinding>,
2065}
2066
2067impl PrivilegeReview {
2068 #[must_use]
2070 pub fn is_least_privilege(&self) -> bool {
2071 !self
2072 .findings
2073 .iter()
2074 .any(|finding| finding.kind == FindingKind::Excess)
2075 }
2076
2077 #[must_use]
2079 pub fn findings(&self) -> &[PrivilegeFinding] {
2080 &self.findings
2081 }
2082
2083 #[must_use]
2086 pub fn excesses(&self) -> Vec<&PrivilegeFinding> {
2087 self.findings
2088 .iter()
2089 .filter(|finding| finding.kind == FindingKind::Excess)
2090 .collect()
2091 }
2092
2093 #[must_use]
2099 pub fn controls(&self) -> &[String] {
2100 &self.controls
2101 }
2102
2103 #[must_use]
2105 pub const fn account(&self) -> &ServiceAccount {
2106 &self.account
2107 }
2108
2109 #[must_use]
2111 pub const fn kind(&self) -> DefinitionKind {
2112 self.kind
2113 }
2114}
2115
2116impl fmt::Display for PrivilegeReview {
2117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2118 writeln!(
2119 f,
2120 "{} runs as {} ({})",
2121 self.kind,
2122 self.account,
2123 self.account.justification()
2124 )?;
2125 for control in &self.controls {
2126 writeln!(f, " confirmed {control}")?;
2127 }
2128 for finding in &self.findings {
2129 writeln!(f, " {finding}")?;
2130 }
2131 if self.is_least_privilege() {
2132 write!(f, " verdict least privilege")
2133 } else {
2134 write!(
2135 f,
2136 " verdict NOT least privilege: {} excess(es)",
2137 self.excesses().len()
2138 )
2139 }
2140 }
2141}
2142
2143#[must_use]
2149pub fn review_least_privilege(
2150 definition: &ServiceDefinition,
2151 plan: &InstallPlan,
2152) -> PrivilegeReview {
2153 let mut controls = Vec::new();
2154 let mut findings = Vec::new();
2155 match definition.kind() {
2156 DefinitionKind::SystemdUnit => {
2157 review_systemd(definition.text(), plan, &mut controls, &mut findings);
2158 }
2159 DefinitionKind::LaunchdPlist => {
2160 review_launchd(definition.text(), plan, &mut controls, &mut findings);
2161 }
2162 DefinitionKind::WindowsScheduledTask => {
2163 review_scheduled_task(definition.text(), &mut controls, &mut findings);
2164 }
2165 DefinitionKind::WindowsService => {
2166 review_windows_service(definition.text(), plan, &mut controls, &mut findings);
2167 }
2168 }
2169 PrivilegeReview {
2170 kind: definition.kind(),
2171 account: ServiceAccount::for_definition(definition.kind(), plan.start_mode()),
2176 controls,
2177 findings,
2178 }
2179}
2180
2181fn permitted_paths(kind: DefinitionKind, plan: &InstallPlan) -> Vec<String> {
2183 let mut permitted = plan
2184 .directories()
2185 .all()
2186 .iter()
2187 .map(|path| path.to_string_lossy().into_owned())
2188 .collect::<Vec<_>>();
2189 if kind == DefinitionKind::SystemdUnit
2190 && let Some(secret_directory) = plan.secret_guard().and_then(Path::parent)
2191 {
2192 permitted.push(secret_directory.to_string_lossy().into_owned());
2193 }
2194 permitted
2195}
2196
2197fn same_path_text(left: &str, right: &str) -> bool {
2204 if cfg!(windows) {
2205 left.eq_ignore_ascii_case(right)
2206 } else {
2207 left == right
2208 }
2209}
2210
2211fn same_path_for(kind: DefinitionKind, left: &str, right: &str) -> bool {
2214 match kind {
2215 DefinitionKind::WindowsService | DefinitionKind::WindowsScheduledTask => {
2216 left.eq_ignore_ascii_case(right)
2217 }
2218 DefinitionKind::LaunchdPlist | DefinitionKind::SystemdUnit => left == right,
2219 }
2220}
2221
2222fn review_writable_paths(
2224 kind: DefinitionKind,
2225 subject: &str,
2226 listed: &[String],
2227 plan: &InstallPlan,
2228 controls: &mut Vec<String>,
2229 findings: &mut Vec<PrivilegeFinding>,
2230) {
2231 let permitted = permitted_paths(kind, plan);
2232 for entry in listed {
2233 if !permitted
2234 .iter()
2235 .any(|allowed| same_path_for(kind, allowed, entry))
2236 {
2237 findings.push(PrivilegeFinding {
2238 kind: FindingKind::Excess,
2239 subject: subject.to_string(),
2240 detail: format!(
2241 "{entry} is writable but is not one of this registration's required paths"
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 required application-data and credential paths"
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
2680#[cfg(any(windows, test))]
2686fn windows_login_task_starts_automatically(document: &str) -> bool {
2687 document.contains("<LogonTrigger>")
2688 && xml_value(document, "Enabled").as_deref() != Some("false")
2689}
2690
2691fn plist_value_after_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
2693 let marker = format!("<key>{key}</key>");
2694 let start = text.find(&marker)? + marker.len();
2695 Some(text[start..].trim_start())
2696}
2697
2698fn plist_string_value(text: &str, key: &str) -> Option<String> {
2699 let rest = plist_value_after_key(text, key)?;
2700 if !rest.starts_with("<string>") {
2701 return None;
2702 }
2703 xml_value(rest, "string")
2704}
2705
2706fn plist_bool_value(text: &str, key: &str) -> Option<bool> {
2707 let rest = plist_value_after_key(text, key)?;
2708 if rest.starts_with("<true/>") {
2709 Some(true)
2710 } else if rest.starts_with("<false/>") {
2711 Some(false)
2712 } else {
2713 None
2714 }
2715}
2716
2717pub const RECORD_SCHEMA_VERSION: u32 = 1;
2728
2729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2741pub struct InstallRecord {
2742 pub schema_version: u32,
2744 pub service_name: String,
2746 pub manager: String,
2748 pub start_mode: StartMode,
2750 pub account: ServiceAccount,
2752 pub binary: PathBuf,
2754 #[serde(default)]
2762 pub source_binary: Option<PathBuf>,
2763 pub arguments: Vec<String>,
2765 pub restart_delay_secs: u64,
2767 pub restart_reset_secs: u64,
2769 pub log_file: PathBuf,
2771 #[serde(default)]
2779 pub starts_on_demand: bool,
2780 pub definition_path: Option<PathBuf>,
2782 pub installed_at: DateTime<Utc>,
2784 pub installed_by_version: String,
2786 pub directories: ServiceDirectories,
2791}
2792
2793impl InstallRecord {
2794 #[must_use]
2796 pub fn path(paths: &AppPaths) -> PathBuf {
2797 paths.config_dir().join(RECORD_FILE)
2798 }
2799
2800 #[must_use]
2802 pub fn of(plan: &InstallPlan, definition: &ServiceDefinition, at: DateTime<Utc>) -> Self {
2803 Self {
2804 schema_version: RECORD_SCHEMA_VERSION,
2805 service_name: plan.identity().name().to_string(),
2806 manager: definition.kind().manager().to_string(),
2807 start_mode: plan.start_mode(),
2808 account: plan.account().clone(),
2809 binary: plan.binary().to_path_buf(),
2810 arguments: plan
2811 .arguments()
2812 .iter()
2813 .map(|argument| argument.to_string_lossy().into_owned())
2814 .collect(),
2815 restart_delay_secs: plan.restart().delay().as_secs(),
2816 restart_reset_secs: plan.restart().reset_after().as_secs(),
2817 starts_on_demand: plan.is_on_demand(),
2818 source_binary: plan.source_binary().map(Path::to_path_buf),
2819 log_file: plan.directories().log_file(),
2820 definition_path: definition.install_path().map(Path::to_path_buf),
2821 installed_at: at,
2822 installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
2823 directories: plan.directories().clone(),
2824 }
2825 }
2826
2827 #[must_use]
2835 pub fn plan(&self, identity: ServiceIdentity) -> InstallPlan {
2836 let plan = InstallPlan::unchecked(
2837 identity,
2838 self.start_mode,
2839 self.binary.clone(),
2840 self.directories.clone(),
2841 )
2842 .with_arguments(self.arguments.clone())
2843 .with_restart(self.restart());
2844 let plan = if self.starts_on_demand {
2845 plan.started_on_demand()
2846 } else {
2847 plan
2848 };
2849 match crate::secrets::PlatformSecretStore::for_start_mode(self.start_mode) {
2850 Ok(store) => plan.with_secret_guard(store.guard()),
2851 Err(_) => plan,
2852 }
2853 }
2854
2855 #[must_use]
2859 pub fn restart(&self) -> RestartPolicy {
2860 RestartPolicy::new(
2861 Duration::from_secs(self.restart_delay_secs),
2862 Duration::from_secs(self.restart_reset_secs),
2863 )
2864 .unwrap_or_default()
2865 }
2866
2867 pub fn read(paths: &AppPaths) -> Result<Option<Self>, ServiceError> {
2878 let path = Self::path(paths);
2879 let text = match std::fs::read_to_string(&path) {
2880 Ok(text) => text,
2881 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2882 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2885 return Err(ServiceError::RecordNotPermitted {
2886 path,
2887 detail: error.to_string(),
2888 });
2889 }
2890 Err(error) => {
2891 return Err(ServiceError::Record {
2892 operation: "read",
2893 path,
2894 detail: error.to_string(),
2895 });
2896 }
2897 };
2898 let record: Self =
2899 toml::from_str(&text).map_err(|error| ServiceError::RecordUnreadable {
2900 path: path.clone(),
2901 detail: error.to_string(),
2902 })?;
2903 if record.schema_version != RECORD_SCHEMA_VERSION {
2904 return Err(ServiceError::RecordUnreadable {
2905 path,
2906 detail: format!(
2907 "it declares schema version {} and this build reads version {}",
2908 record.schema_version, RECORD_SCHEMA_VERSION
2909 ),
2910 });
2911 }
2912 Ok(Some(record))
2913 }
2914
2915 pub fn write(&self, paths: &AppPaths) -> Result<(), ServiceError> {
2921 use std::io::Write as _;
2922
2923 let path = Self::path(paths);
2924 let text = toml::to_string_pretty(self).map_err(|error| ServiceError::Record {
2925 operation: "encode",
2926 path: path.clone(),
2927 detail: error.to_string(),
2928 })?;
2929 if let Some(parent) = path.parent() {
2930 std::fs::create_dir_all(parent).map_err(|error| ServiceError::Record {
2931 operation: "write",
2932 path: path.clone(),
2933 detail: error.to_string(),
2934 })?;
2935 }
2936 let parent = path.parent().ok_or_else(|| ServiceError::Record {
2937 operation: "write",
2938 path: path.clone(),
2939 detail: "the record path has no parent directory".to_string(),
2940 })?;
2941 let mut temporary =
2942 tempfile::NamedTempFile::new_in(parent).map_err(|error| ServiceError::Record {
2943 operation: "write",
2944 path: path.clone(),
2945 detail: error.to_string(),
2946 })?;
2947 temporary
2948 .write_all(text.as_bytes())
2949 .and_then(|()| temporary.as_file().sync_all())
2950 .map_err(|error| ServiceError::Record {
2951 operation: "write",
2952 path: path.clone(),
2953 detail: error.to_string(),
2954 })?;
2955 #[cfg(unix)]
2971 {
2972 use std::os::unix::fs::PermissionsExt as _;
2973
2974 temporary
2975 .as_file()
2976 .set_permissions(std::fs::Permissions::from_mode(0o644))
2977 .map_err(|error| ServiceError::Record {
2978 operation: "write",
2979 path: path.clone(),
2980 detail: error.to_string(),
2981 })?;
2982 }
2983 temporary
2984 .persist(&path)
2985 .map(|_| ())
2986 .map_err(|error| ServiceError::Record {
2987 operation: "write",
2988 path,
2989 detail: error.error.to_string(),
2990 })
2991 }
2992
2993 pub fn remove(paths: &AppPaths) -> Result<bool, ServiceError> {
2999 let path = Self::path(paths);
3000 match std::fs::remove_file(&path) {
3001 Ok(()) => Ok(true),
3002 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
3003 Err(error) => Err(ServiceError::Record {
3004 operation: "remove",
3005 path,
3006 detail: error.to_string(),
3007 }),
3008 }
3009 }
3010}
3011
3012const CONTACT_SCHEMA_VERSION: u32 = 1;
3018
3019#[derive(Debug, Clone, Serialize, Deserialize)]
3020struct ContactRecord {
3021 schema_version: u32,
3022 last_success: DateTime<Utc>,
3023}
3024
3025pub fn record_github_contact(paths: &AppPaths, at: DateTime<Utc>) -> Result<(), ServiceError> {
3046 let path = contact_path(paths);
3047 let record = ContactRecord {
3048 schema_version: CONTACT_SCHEMA_VERSION,
3049 last_success: at,
3050 };
3051 let failed = |detail: String| ServiceError::Record {
3052 operation: "write",
3053 path: path.clone(),
3054 detail,
3055 };
3056 let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3057 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3058 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3059 let temporary = path.with_extension("toml.new");
3060 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3061 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3062}
3063
3064pub fn last_github_contact(paths: &AppPaths) -> Result<Option<DateTime<Utc>>, ServiceError> {
3076 let path = contact_path(paths);
3077 let text = match std::fs::read_to_string(&path) {
3078 Ok(text) => text,
3079 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3080 Err(error) => {
3081 return Err(ServiceError::Record {
3082 operation: "read",
3083 path,
3084 detail: error.to_string(),
3085 });
3086 }
3087 };
3088 let record: ContactRecord = toml::from_str(&text).map_err(|error| ServiceError::Record {
3089 operation: "read",
3090 path,
3091 detail: error.to_string(),
3092 })?;
3093 Ok(Some(record.last_success))
3094}
3095
3096#[must_use]
3098pub fn contact_path(paths: &AppPaths) -> PathBuf {
3099 paths.state_dir().join(CONTACT_FILE)
3100}
3101
3102#[derive(Debug, Serialize, Deserialize)]
3107#[serde(deny_unknown_fields)]
3108struct CredentialRejectionRecord {
3109 schema_version: u32,
3110 rejected_since: DateTime<Utc>,
3111}
3112
3113pub fn record_github_credential_rejected(
3129 paths: &AppPaths,
3130 at: DateTime<Utc>,
3131) -> Result<(), ServiceError> {
3132 if github_credential_rejected_since(paths)
3133 .ok()
3134 .flatten()
3135 .is_some()
3136 {
3137 return Ok(());
3138 }
3139 let path = credential_rejection_path(paths);
3140 let record = CredentialRejectionRecord {
3141 schema_version: CONTACT_SCHEMA_VERSION,
3142 rejected_since: at,
3143 };
3144 let failed = |detail: String| ServiceError::Record {
3145 operation: "write",
3146 path: path.clone(),
3147 detail,
3148 };
3149 let text = toml::to_string_pretty(&record).map_err(|error| failed(error.to_string()))?;
3150 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3151 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3152 let temporary = path.with_extension("toml.new");
3153 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3154 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3155}
3156
3157pub fn clear_github_credential_rejection(paths: &AppPaths) -> Result<(), ServiceError> {
3163 let path = credential_rejection_path(paths);
3164 match std::fs::remove_file(&path) {
3165 Ok(()) => Ok(()),
3166 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3167 Err(error) => Err(ServiceError::Record {
3168 operation: "remove",
3169 path,
3170 detail: error.to_string(),
3171 }),
3172 }
3173}
3174
3175pub fn github_credential_rejected_since(
3181 paths: &AppPaths,
3182) -> Result<Option<DateTime<Utc>>, ServiceError> {
3183 let path = credential_rejection_path(paths);
3184 let text = match std::fs::read_to_string(&path) {
3185 Ok(text) => text,
3186 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3187 Err(error) => {
3188 return Err(ServiceError::Record {
3189 operation: "read",
3190 path,
3191 detail: error.to_string(),
3192 });
3193 }
3194 };
3195 let record: CredentialRejectionRecord =
3196 toml::from_str(&text).map_err(|error| ServiceError::Record {
3197 operation: "read",
3198 path,
3199 detail: error.to_string(),
3200 })?;
3201 Ok(Some(record.rejected_since))
3202}
3203
3204#[must_use]
3206pub fn credential_rejection_path(paths: &AppPaths) -> PathBuf {
3207 paths.state_dir().join(CREDENTIAL_REJECTION_FILE)
3208}
3209
3210#[derive(Debug, Clone, PartialEq, Eq)]
3227pub struct DefinitionDrift {
3228 pub path: PathBuf,
3230 pub installed_by_version: String,
3232 pub rendered: String,
3234 pub start_mode: StartMode,
3236}
3237
3238fn definition_drift(
3245 controls: &dyn ControlFactory,
3246 identity: &ServiceIdentity,
3247 record: &InstallRecord,
3248) -> Option<DefinitionDrift> {
3249 if host_definition_kind(record.start_mode) != DefinitionKind::SystemdUnit {
3250 return None;
3251 }
3252 let path = record.definition_path.clone()?;
3253 let installed = controls.installed_definition(&path)?;
3254 systemd_definition_drift(identity, record, path, &installed)
3255}
3256
3257fn systemd_definition_drift(
3258 identity: &ServiceIdentity,
3259 record: &InstallRecord,
3260 path: PathBuf,
3261 installed: &str,
3262) -> Option<DefinitionDrift> {
3263 let rendered = systemd_unit(&record.plan(identity.clone()));
3264 (installed != rendered).then(|| DefinitionDrift {
3265 path,
3266 installed_by_version: record.installed_by_version.clone(),
3267 rendered,
3268 start_mode: record.start_mode,
3269 })
3270}
3271
3272const ROOT_REFUSAL_SCHEMA_VERSION: u32 = 1;
3277
3278#[derive(Debug, Clone, Serialize, Deserialize)]
3279struct RootRefusalFile {
3280 schema_version: u32,
3281 #[serde(default)]
3283 refusals: BTreeMap<String, RootRefusalEntry>,
3284}
3285
3286#[derive(Debug, Clone, Serialize, Deserialize)]
3287struct RootRefusalEntry {
3288 at: DateTime<Utc>,
3289 kind: String,
3290 root: String,
3291 detail: String,
3292}
3293
3294#[derive(Debug, Clone, PartialEq, Eq)]
3296pub struct RunnerRootRefusal {
3297 pub policy: String,
3299 pub at: DateTime<Utc>,
3301 pub kind: String,
3303 pub root: String,
3305 pub detail: String,
3308}
3309
3310pub fn record_runner_root_refusal(
3344 paths: &AppPaths,
3345 policy: &str,
3346 at: DateTime<Utc>,
3347 kind: &str,
3348 root: &str,
3349 detail: &str,
3350) -> Result<(), ServiceError> {
3351 let mut file = read_refusal_file(paths)?.unwrap_or(RootRefusalFile {
3352 schema_version: ROOT_REFUSAL_SCHEMA_VERSION,
3353 refusals: BTreeMap::new(),
3354 });
3355 file.schema_version = ROOT_REFUSAL_SCHEMA_VERSION;
3356 file.refusals.insert(
3357 policy.to_owned(),
3358 RootRefusalEntry {
3359 at,
3360 kind: kind.to_owned(),
3361 root: root.to_owned(),
3362 detail: detail.to_owned(),
3363 },
3364 );
3365 write_refusal_file(paths, &file)
3366}
3367
3368pub fn clear_runner_root_refusal(paths: &AppPaths, policy: &str) -> Result<(), ServiceError> {
3379 let Some(mut file) = read_refusal_file(paths)? else {
3380 return Ok(());
3381 };
3382 if file.refusals.remove(policy).is_none() {
3383 return Ok(());
3384 }
3385 if file.refusals.is_empty() {
3386 let path = root_refusal_path(paths);
3387 return match std::fs::remove_file(&path) {
3388 Ok(()) => Ok(()),
3389 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3390 Err(error) => Err(ServiceError::Record {
3391 operation: "remove",
3392 path,
3393 detail: error.to_string(),
3394 }),
3395 };
3396 }
3397 write_refusal_file(paths, &file)
3398}
3399
3400pub fn runner_root_refusals(paths: &AppPaths) -> Result<Vec<RunnerRootRefusal>, ServiceError> {
3412 Ok(read_refusal_file(paths)?
3413 .map(|file| {
3414 file.refusals
3415 .into_iter()
3416 .map(|(policy, entry)| RunnerRootRefusal {
3417 policy,
3418 at: entry.at,
3419 kind: entry.kind,
3420 root: entry.root,
3421 detail: entry.detail,
3422 })
3423 .collect()
3424 })
3425 .unwrap_or_default())
3426}
3427
3428fn read_refusal_file(paths: &AppPaths) -> Result<Option<RootRefusalFile>, ServiceError> {
3429 let path = root_refusal_path(paths);
3430 let text = match std::fs::read_to_string(&path) {
3431 Ok(text) => text,
3432 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3433 Err(error) => {
3434 return Err(ServiceError::Record {
3435 operation: "read",
3436 path,
3437 detail: error.to_string(),
3438 });
3439 }
3440 };
3441 toml::from_str(&text)
3442 .map(Some)
3443 .map_err(|error| ServiceError::Record {
3444 operation: "read",
3445 path,
3446 detail: error.to_string(),
3447 })
3448}
3449
3450fn write_refusal_file(paths: &AppPaths, file: &RootRefusalFile) -> Result<(), ServiceError> {
3451 let path = root_refusal_path(paths);
3452 let failed = |detail: String| ServiceError::Record {
3453 operation: "write",
3454 path: path.clone(),
3455 detail,
3456 };
3457 let text = toml::to_string_pretty(file).map_err(|error| failed(error.to_string()))?;
3458 let directory = path.parent().unwrap_or_else(|| Path::new("."));
3459 std::fs::create_dir_all(directory).map_err(|error| failed(error.to_string()))?;
3460 let temporary = path.with_extension("toml.new");
3461 std::fs::write(&temporary, text).map_err(|error| failed(error.to_string()))?;
3462 std::fs::rename(&temporary, &path).map_err(|error| failed(error.to_string()))
3463}
3464
3465#[must_use]
3467pub fn root_refusal_path(paths: &AppPaths) -> PathBuf {
3468 paths.state_dir().join(ROOT_REFUSAL_FILE)
3469}
3470
3471#[derive(Debug, Clone, PartialEq, Eq)]
3482pub enum BinaryPath {
3483 Current {
3485 path: PathBuf,
3487 },
3488 Missing {
3495 recorded: PathBuf,
3497 },
3498 NotExecutable {
3501 recorded: PathBuf,
3503 detail: String,
3505 },
3506 Diverged {
3508 recorded: PathBuf,
3510 registered: PathBuf,
3512 },
3513}
3514
3515impl BinaryPath {
3516 #[must_use]
3518 pub const fn is_error(&self) -> bool {
3519 !matches!(self, Self::Current { .. })
3520 }
3521
3522 #[must_use]
3524 pub fn recorded(&self) -> &Path {
3525 match self {
3526 Self::Current { path } => path,
3527 Self::Missing { recorded }
3528 | Self::NotExecutable { recorded, .. }
3529 | Self::Diverged { recorded, .. } => recorded,
3530 }
3531 }
3532}
3533
3534impl fmt::Display for BinaryPath {
3535 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3536 match self {
3537 Self::Current { path } => write!(f, "{}", path.display()),
3538 Self::Missing { recorded } => write!(
3539 f,
3540 "{} -- STALE: nothing is at the recorded path, so the service cannot start. A \
3541 package manager that moved the binary is the usual cause; an `npm i -g` \
3542 installation moves with the active Node version. Run `service install` again \
3543 from the binary that is now installed.",
3544 recorded.display()
3545 ),
3546 Self::NotExecutable { recorded, detail } => write!(
3547 f,
3548 "{} -- STALE: {detail}, so the service cannot start. Run `service install` again \
3549 from the installed binary.",
3550 recorded.display()
3551 ),
3552 Self::Diverged {
3553 recorded,
3554 registered,
3555 } => write!(
3556 f,
3557 "{} -- STALE: the service manager is registered to start {} instead. Something \
3558 has edited the registration since it was installed. Run `service uninstall` and \
3559 `service install`; neither touches configuration, secrets, or the cache.",
3560 recorded.display(),
3561 registered.display()
3562 ),
3563 }
3564 }
3565}
3566
3567#[must_use]
3575pub fn inspect_binary(recorded: &Path, registered: Option<&Path>) -> BinaryPath {
3576 match std::fs::metadata(recorded) {
3577 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3578 return BinaryPath::Missing {
3579 recorded: recorded.to_path_buf(),
3580 };
3581 }
3582 Err(error) => {
3583 return BinaryPath::NotExecutable {
3584 recorded: recorded.to_path_buf(),
3585 detail: format!("it cannot be inspected ({error})"),
3586 };
3587 }
3588 Ok(metadata) if !metadata.is_file() => {
3589 return BinaryPath::NotExecutable {
3590 recorded: recorded.to_path_buf(),
3591 detail: "what is there is not a file".to_string(),
3592 };
3593 }
3594 Ok(_) => {}
3595 }
3596 if let Some(registered) = registered
3597 && !same_path_text(&recorded.to_string_lossy(), ®istered.to_string_lossy())
3598 {
3599 return BinaryPath::Diverged {
3600 recorded: recorded.to_path_buf(),
3601 registered: registered.to_path_buf(),
3602 };
3603 }
3604 BinaryPath::Current {
3605 path: recorded.to_path_buf(),
3606 }
3607}
3608
3609#[derive(Debug, Clone, PartialEq, Eq)]
3615pub struct Registration {
3616 pub manager: DefinitionKind,
3621 pub start_mode: StartMode,
3623 pub command_line: String,
3625 pub account: Option<String>,
3627 pub running: bool,
3629 pub starts_automatically: bool,
3636 pub restart_delay: Option<Duration>,
3645}
3646
3647impl Registration {
3648 #[must_use]
3650 pub fn binary(&self) -> Option<PathBuf> {
3651 executable_from_command_line(&self.command_line)
3652 }
3653}
3654
3655pub trait ServiceControl: fmt::Debug {
3661 fn manager(&self) -> DefinitionKind;
3663
3664 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError>;
3670
3671 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3682
3683 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError>;
3689
3690 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError>;
3697
3698 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError>;
3704}
3705
3706pub trait ControlFactory: fmt::Debug + Send + Sync {
3712 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError>;
3718
3719 fn installed_definition(&self, _path: &Path) -> Option<String> {
3723 None
3724 }
3725}
3726
3727#[derive(Debug, Clone, Copy, Default)]
3729pub struct HostControls;
3730
3731#[derive(Debug, Clone)]
3737pub struct Installed {
3738 pub plan: InstallPlan,
3740 pub definition: ServiceDefinition,
3742 pub record: InstallRecord,
3744 pub review: PrivilegeReview,
3746 pub runner_root: RootAccessSummary,
3751 pub replaced_existing: bool,
3759}
3760
3761#[derive(Debug, Clone, PartialEq, Eq)]
3763pub struct Uninstalled {
3764 pub removed_registration: bool,
3766 pub removed_record: bool,
3768 pub removed_definition: Option<PathBuf>,
3770 pub preserved: Vec<PathBuf>,
3777}
3778
3779impl fmt::Display for Uninstalled {
3780 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3781 if self.removed_registration {
3782 writeln!(f, "The service registration was removed.")?;
3783 } else {
3784 writeln!(f, "There was no service registration to remove.")?;
3785 }
3786 writeln!(f, "Nothing else was deleted. These are untouched:")?;
3787 for path in &self.preserved {
3788 writeln!(f, " {}", path.display())?;
3789 }
3790 write!(
3791 f,
3792 "The stored GitHub token is untouched too; `auth logout` is what purges it."
3793 )
3794 }
3795}
3796
3797#[derive(Debug, Clone, PartialEq, Eq)]
3799pub struct StartModeChange {
3800 pub from: StartMode,
3802 pub to: StartMode,
3804 pub changed: bool,
3806 pub store_scope: crate::secrets::SecretScope,
3809 pub runner_root: RootAccessSummary,
3815}
3816
3817impl fmt::Display for StartModeChange {
3818 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3819 if !self.changed {
3820 return write!(f, "The service already starts at {}.", self.to);
3821 }
3822 write!(
3823 f,
3824 "The service now starts at {} instead of {}. It reads the {}-scoped secret store; \
3825 if the token was stored under the other scope, run `auth login` again. {}",
3826 self.to, self.from, self.store_scope, self.runner_root
3827 )
3828 }
3829}
3830
3831#[derive(Debug, Clone)]
3839pub struct ServiceOperations {
3840 paths: AppPaths,
3841 identity: ServiceIdentity,
3842 controls: std::sync::Arc<dyn ControlFactory>,
3843 runner_root: Option<LocalAbsolutePath>,
3844}
3845
3846impl ServiceOperations {
3847 #[must_use]
3849 pub fn on_this_host(paths: AppPaths) -> Self {
3850 Self::with_controls(
3851 paths,
3852 ServiceIdentity::product(),
3853 std::sync::Arc::new(HostControls),
3854 )
3855 }
3856
3857 #[must_use]
3865 pub fn with_controls(
3866 paths: AppPaths,
3867 identity: ServiceIdentity,
3868 controls: std::sync::Arc<dyn ControlFactory>,
3869 ) -> Self {
3870 Self {
3871 paths,
3872 identity,
3873 controls,
3874 runner_root: None,
3875 }
3876 }
3877
3878 #[must_use]
3897 pub fn with_runner_root(mut self, root: LocalAbsolutePath) -> Self {
3898 self.runner_root = Some(root);
3899 self
3900 }
3901
3902 #[must_use]
3904 pub const fn paths(&self) -> &AppPaths {
3905 &self.paths
3906 }
3907
3908 #[must_use]
3910 pub const fn identity(&self) -> &ServiceIdentity {
3911 &self.identity
3912 }
3913
3914 pub fn install(&self, request: &InstallRequest) -> Result<Installed, ServiceError> {
3934 self.paths
3935 .create_all()
3936 .map_err(|source| ServiceError::Paths {
3937 source: Box::new(source),
3938 })?;
3939
3940 let _guard = self.refuse_while_an_agent_runs()?;
3943
3944 let (replacing, previous_was_running) = match self.find_registration()? {
3973 Some((existing, registration)) if existing == request.start_mode() => {
3974 (true, registration.running)
3975 }
3976 Some((existing, _)) => {
3977 return Err(ServiceError::AlreadyInstalled {
3978 name: self.identity.name().to_string(),
3979 existing,
3980 requested: request.start_mode(),
3981 });
3982 }
3983 None => (false, false),
3984 };
3985
3986 let plan = InstallPlan::resolve(
3987 self.identity.clone(),
3988 request,
3989 ServiceDirectories::of(&self.paths),
3990 )?;
3991
3992 let control = self.controls.control(plan.start_mode())?;
3997
3998 let root = self.prepare_runner_root(plan.start_mode())?;
4004
4005 let previous = if replacing {
4010 InstallRecord::read(&self.paths).ok().flatten()
4011 } else {
4012 None
4013 };
4014 if replacing && let Err(cause) = control.uninstall(&self.identity) {
4015 return Err(undo_runner_root(&root, "install", &self.identity, cause));
4016 }
4017
4018 let definition = match control.install(&plan) {
4019 Ok(definition) => definition,
4020 Err(cause) => {
4021 let restored =
4024 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running);
4025 return Err(rolled_back(
4026 retained_runner_root(&root),
4027 restored,
4028 "install",
4029 &self.identity,
4030 cause,
4031 ));
4032 }
4033 };
4034 let review = review_least_privilege(&definition, &plan);
4035 let record = InstallRecord::of(&plan, &definition, Utc::now());
4036 if let Err(cause) = record.write(&self.paths) {
4037 let rollback = control
4038 .uninstall(&self.identity)
4039 .map(|_| ())
4040 .and_then(|()| {
4041 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4042 });
4043 return Err(rolled_back(
4044 retained_runner_root(&root),
4045 rollback,
4046 "install",
4047 &self.identity,
4048 cause,
4049 ));
4050 }
4051
4052 if let Err(cause) = control.start(&self.identity) {
4059 let remove_new = control.uninstall(&self.identity).map(|_| ());
4060 let restore_registration = remove_new.and_then(|()| {
4061 self.reinstate(control.as_ref(), previous.as_ref(), previous_was_running)
4062 });
4063 let restore_record = restore_registration.and_then(|()| match &previous {
4064 Some(previous) => previous.write(&self.paths),
4065 None => InstallRecord::remove(&self.paths).map(|_| ()),
4066 });
4067 return Err(rolled_back(
4068 retained_runner_root(&root),
4069 restore_record,
4070 "install and start",
4071 &self.identity,
4072 cause,
4073 ));
4074 }
4075 Ok(Installed {
4076 plan,
4077 definition,
4078 record,
4079 review,
4080 runner_root: root.summary().clone(),
4081 replaced_existing: replacing,
4082 })
4083 }
4084
4085 fn reinstate(
4096 &self,
4097 control: &dyn ServiceControl,
4098 previous: Option<&InstallRecord>,
4099 was_running: bool,
4100 ) -> Result<(), ServiceError> {
4101 let Some(record) = previous else {
4102 return Ok(());
4103 };
4104 control.install(&record.plan(self.identity.clone()))?;
4105 if was_running {
4106 control.start(&self.identity)?;
4107 }
4108 Ok(())
4109 }
4110
4111 pub fn uninstall(&self) -> Result<Uninstalled, ServiceError> {
4120 let record = InstallRecord::read(&self.paths).ok().flatten();
4121 let removed_definition = record
4122 .as_ref()
4123 .and_then(|record| record.definition_path.clone());
4124
4125 let mut removed_registration = false;
4126 for mode in [StartMode::Boot, StartMode::Login] {
4130 let control = self.controls.control(mode)?;
4131 if control.uninstall(&self.identity)? {
4132 removed_registration = true;
4133 }
4134 }
4135 let removed_record = InstallRecord::remove(&self.paths)?;
4136 Ok(Uninstalled {
4137 removed_registration,
4138 removed_record,
4139 removed_definition: removed_definition.filter(|_| removed_registration),
4140 preserved: self
4141 .paths
4142 .all()
4143 .iter()
4144 .map(|(_, path)| (*path).to_path_buf())
4145 .collect(),
4146 })
4147 }
4148
4149 pub fn set_start_mode(&self, to: StartMode) -> Result<StartModeChange, ServiceError> {
4171 let Some(record) = InstallRecord::read(&self.paths)? else {
4172 return Err(ServiceError::NotInstalled {
4173 name: self.identity.name().to_string(),
4174 operation: "switch the start mode of",
4175 });
4176 };
4177 let from = record.start_mode;
4178 if from == to {
4179 return Ok(StartModeChange {
4183 from,
4184 to,
4185 changed: false,
4186 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4187 runner_root: RootAccessSummary::NotApplicable,
4188 });
4189 }
4190
4191 #[cfg(windows)]
4192 let arguments = {
4193 let mut arguments = record.arguments.clone();
4194 arguments.retain(|argument| argument != WINDOWS_SCM_HOST_ARGUMENT);
4195 if to == StartMode::Boot {
4196 arguments.push(WINDOWS_SCM_HOST_ARGUMENT.to_string());
4197 }
4198 arguments
4199 };
4200 #[cfg(not(windows))]
4201 let arguments = record.arguments.clone();
4202 let plan = InstallPlan::unchecked(
4203 self.identity.clone(),
4204 to,
4205 record.binary.clone(),
4206 record.directories.clone(),
4207 )
4208 .with_arguments(arguments)
4209 .with_restart(record.restart());
4210 let plan = if record.starts_on_demand {
4211 plan.started_on_demand()
4212 } else {
4213 plan
4214 };
4215 let plan = match crate::secrets::PlatformSecretStore::for_start_mode(to) {
4216 Ok(store) => plan.with_secret_guard(store.guard()),
4217 Err(_) => plan,
4218 };
4219
4220 let target = self.controls.control(to)?;
4227 let previous = self.controls.control(from)?;
4232
4233 let root = self.prepare_runner_root(to)?;
4241
4242 let definition = match target.install(&plan) {
4243 Ok(definition) => definition,
4244 Err(cause) => {
4245 return Err(undo_runner_root(
4246 &root,
4247 "switch start mode",
4248 &self.identity,
4249 cause,
4250 ));
4251 }
4252 };
4253 let next_record = InstallRecord::of(&plan, &definition, record.installed_at);
4254 if let Err(cause) = next_record.write(&self.paths) {
4255 return Err(rolled_back(
4256 retained_runner_root(&root),
4257 target.uninstall(&self.identity),
4258 "switch start mode",
4259 &self.identity,
4260 cause,
4261 ));
4262 }
4263
4264 if let Err(cause) = previous.uninstall(&self.identity) {
4268 let target_rollback = target.uninstall(&self.identity);
4269 let record_rollback = record.write(&self.paths);
4270 return Err(rolled_back(
4271 retained_runner_root(&root),
4272 target_rollback.and(record_rollback),
4273 "switch start mode",
4274 &self.identity,
4275 cause,
4276 ));
4277 }
4278 Ok(StartModeChange {
4279 from,
4280 to,
4281 changed: true,
4282 store_scope: crate::secrets::SecretScope::for_start_mode(to),
4283 runner_root: root.summary().clone(),
4284 })
4285 }
4286
4287 pub fn start(&self) -> Result<(), ServiceError> {
4293 let Some((mode, _)) = self.find_registration()? else {
4294 return Err(ServiceError::NotInstalled {
4295 name: self.identity.name().to_string(),
4296 operation: "start",
4297 });
4298 };
4299 self.controls.control(mode)?.start(&self.identity)
4300 }
4301
4302 pub fn stop(&self) -> Result<bool, ServiceError> {
4308 let Some((mode, _)) = self.find_registration()? else {
4309 return Err(ServiceError::NotInstalled {
4310 name: self.identity.name().to_string(),
4311 operation: "stop",
4312 });
4313 };
4314 self.controls.control(mode)?.stop(&self.identity)
4315 }
4316
4317 pub fn status(&self) -> Result<ServiceStatus, ServiceError> {
4336 let (record, record_refused) = match InstallRecord::read(&self.paths) {
4337 Ok(record) => (record, None),
4338 Err(refusal @ ServiceError::RecordNotPermitted { .. }) => (None, Some(refusal)),
4339 Err(error) => return Err(error),
4340 };
4341 let found = self.find_registration()?;
4342 let last_github_contact = last_github_contact(&self.paths)?;
4343 let drift = record
4344 .as_ref()
4345 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, record));
4346 let credential_rejected_since =
4347 github_credential_rejected_since(&self.paths).ok().flatten();
4348 Ok(ServiceStatus::compose(
4349 self.identity.clone(),
4350 record,
4351 record_refused.as_ref(),
4352 found.map(|(_, registration)| registration),
4353 last_github_contact,
4354 &self.paths,
4355 )
4356 .with_definition_drift(drift)
4357 .with_credential_rejection(credential_rejected_since))
4358 }
4359
4360 pub fn definition_drift(&self) -> Result<Option<DefinitionDrift>, ServiceError> {
4367 Ok(InstallRecord::read(&self.paths)?
4368 .and_then(|record| definition_drift(self.controls.as_ref(), &self.identity, &record)))
4369 }
4370
4371 fn prepare_runner_root(&self, mode: StartMode) -> Result<RootAccessChange, ServiceError> {
4381 #[cfg(not(windows))]
4382 {
4383 let _ = (mode, &self.runner_root);
4386 Ok(RootAccessChange::not_applicable())
4387 }
4388 #[cfg(windows)]
4389 {
4390 let wrap = |source| ServiceError::RunnerRoot {
4391 source: Box::new(source),
4392 };
4393 if let Some(root) = self
4411 .runner_root
4412 .as_ref()
4413 .filter(|_| self.identity.is_fixture() || cfg!(test))
4414 {
4415 let admission = RootAdmission::of_this_account().map_err(wrap)?;
4416 return crate::runner_root_access::reconcile(&self.paths, root, &admission)
4417 .map_err(wrap);
4418 }
4419
4420 let admission = match ServiceAccount::for_start_mode(mode) {
4421 ServiceAccount::LocalSystem => RootAdmission::LocalSystem,
4424 ServiceAccount::InvokingUser | ServiceAccount::Root => {
4428 RootAdmission::of_this_account().map_err(wrap)?
4429 }
4430 };
4431 crate::runner_root_access::ensure_default_root(&self.paths, &admission).map_err(wrap)
4432 }
4433 }
4434
4435 fn refuse_while_an_agent_runs(&self) -> Result<HostLock, ServiceError> {
4437 HostLock::try_acquire(&self.paths, LockKind::SingleInstance).map_err(
4438 |source| match source {
4439 held @ LockError::Held { .. } => ServiceError::LockHeld {
4440 source: Box::new(held),
4441 },
4442 other => ServiceError::LockUnreadable {
4443 source: Box::new(other),
4444 },
4445 },
4446 )
4447 }
4448
4449 fn find_registration(&self) -> Result<Option<(StartMode, Registration)>, ServiceError> {
4451 for mode in [StartMode::Boot, StartMode::Login] {
4452 let control = self.controls.control(mode)?;
4453 if let Some(registration) = control.query(&self.identity)? {
4454 return Ok(Some((registration.start_mode, registration)));
4455 }
4456 }
4457 Ok(None)
4458 }
4459}
4460
4461#[derive(Debug, Clone, PartialEq, Eq)]
4467pub struct StatusProblem {
4468 pub subject: &'static str,
4470 pub detail: String,
4472}
4473
4474impl fmt::Display for StatusProblem {
4475 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4476 write!(f, "{}: {}", self.subject, self.detail)
4477 }
4478}
4479
4480#[derive(Debug, Clone)]
4489pub struct ServiceStatus {
4490 identity: ServiceIdentity,
4491 record: Option<InstallRecord>,
4492 registration: Option<Registration>,
4493 binary: Option<BinaryPath>,
4494 log_file: PathBuf,
4495 store: Option<crate::secrets::ActiveStore>,
4496 last_github_contact: Option<DateTime<Utc>>,
4497 runner_root: Option<(PathBuf, RootAccessReport)>,
4498 credential_rejected_since: Option<DateTime<Utc>>,
4499 problems: Vec<StatusProblem>,
4500 notes: Vec<String>,
4501}
4502
4503impl ServiceStatus {
4504 fn compose(
4509 identity: ServiceIdentity,
4510 record: Option<InstallRecord>,
4511 record_refused: Option<&ServiceError>,
4512 registration: Option<Registration>,
4513 last_github_contact: Option<DateTime<Utc>>,
4514 paths: &AppPaths,
4515 ) -> Self {
4516 let mut problems = Vec::new();
4517 let mut notes = Vec::new();
4518
4519 if let Some(refusal) = record_refused {
4520 problems.push(StatusProblem {
4525 subject: "install record",
4526 detail: refusal.to_string(),
4527 });
4528 }
4529
4530 let log_file = record.as_ref().map_or_else(
4531 || ServiceDirectories::of(paths).log_file(),
4532 |record| record.log_file.clone(),
4533 );
4534
4535 let binary = record.as_ref().map(|record| {
4536 inspect_binary(
4537 &record.binary,
4538 registration
4539 .as_ref()
4540 .and_then(Registration::binary)
4541 .as_deref(),
4542 )
4543 });
4544 if let Some(state) = &binary
4545 && state.is_error()
4546 {
4547 problems.push(StatusProblem {
4548 subject: "binary",
4549 detail: state.to_string(),
4550 });
4551 }
4552
4553 match (&record, ®istration) {
4554 (Some(_), None) => problems.push(StatusProblem {
4555 subject: "registration",
4556 detail: "this host has a service record but no service manager knows the \
4557 registration. Run `service install` again; it deletes nothing."
4558 .to_string(),
4559 }),
4560 (None, Some(found)) if record_refused.is_none() => problems.push(StatusProblem {
4565 subject: "record",
4566 detail: format!(
4567 "{} holds a registration for this service but there is no install record, so \
4568 the path it was installed from and the directories it was installed against \
4569 are unknown. Run `service uninstall` and `service install`.",
4570 found.manager
4571 ),
4572 }),
4573 (None, Some(_)) => {}
4574 (Some(record), Some(found)) => {
4575 if record.start_mode != found.start_mode {
4576 problems.push(StatusProblem {
4577 subject: "start mode",
4578 detail: format!(
4579 "the record says {} and {} holds a {} registration. Switch the start \
4580 mode again to make them agree.",
4581 record.start_mode, found.manager, found.start_mode
4582 ),
4583 });
4584 }
4585 if record.start_mode == StartMode::Boot && !found.starts_automatically {
4586 problems.push(StatusProblem {
4587 subject: "start mode",
4588 detail: format!(
4589 "{} holds the registration but will not start it by itself, so this \
4590 host does not resume work after a reboot.",
4591 found.manager
4592 ),
4593 });
4594 }
4595 if let Some(actual) = found.restart_delay {
4596 let expected = record.restart().effective_delay(found.manager);
4597 if actual != expected {
4598 problems.push(StatusProblem {
4599 subject: "restart policy",
4600 detail: format!(
4601 "the record says the service restarts after {}s and {} reports \
4602 {}s. Something has edited the registration since it was \
4603 installed.",
4604 expected.as_secs(),
4605 found.manager,
4606 actual.as_secs()
4607 ),
4608 });
4609 } else if expected != record.restart().delay() {
4610 notes.push(format!(
4615 "{} expresses the restart delay in whole minutes, so the {}s asked \
4616 for is enforced as {}s. The service therefore never restarts faster \
4617 than the configured bound.",
4618 found.manager,
4619 record.restart().delay().as_secs(),
4620 expected.as_secs()
4621 ));
4622 }
4623 }
4624 if found.starts_automatically && !found.running {
4625 problems.push(StatusProblem {
4626 subject: "runtime",
4627 detail: format!(
4628 "{} holds an automatic registration, but the daemon is stopped. Run \
4629 `runner-manager service install --start-at {}`; if the service \
4630 manager denies replacement, retry from an elevated terminal. If it \
4631 stops again, inspect {}.",
4632 found.manager,
4633 found.start_mode,
4634 log_file.display()
4635 ),
4636 });
4637 }
4638 }
4639 (None, None) => {}
4640 }
4641
4642 let store = record.as_ref().and_then(|record| {
4647 let registered_mode = registration
4648 .as_ref()
4649 .map_or(record.start_mode, |found| found.start_mode);
4650 crate::secrets::PlatformSecretStore::for_start_mode(record.start_mode)
4651 .ok()
4652 .map(|store| crate::secrets::ActiveStore::of(&store, registered_mode))
4653 });
4654 if let Some(store) = &store
4655 && !store.agrees_with_start_mode()
4656 {
4657 problems.push(StatusProblem {
4658 subject: "secret store",
4659 detail: format!(
4660 "{store}. Run `auth login` again so the token is stored where the registered \
4661 start mode can read it."
4662 ),
4663 });
4664 }
4665
4666 if record.as_ref().map(|record| record.start_mode) == Some(StartMode::Login) {
4667 notes.push(
4668 "This registration starts at login, so the agent does not run until the operator \
4669 signs in; this host does not resume work after an unattended reboot."
4670 .to_string(),
4671 );
4672 }
4673 if last_github_contact.is_none() {
4674 notes.push(
4675 "GitHub has not been reached successfully since this host's state directory was \
4676 created."
4677 .to_string(),
4678 );
4679 }
4680
4681 let runner_root = crate::runner_root::default_runner_root(paths)
4688 .ok()
4689 .map(|root| {
4690 let path = root.as_path().to_path_buf();
4691 let report = crate::runner_root_access::report(&path);
4692 (path, report)
4693 });
4694 if let Some((
4704 path,
4705 RootAccessReport::Present {
4706 broad_write: true, ..
4707 },
4708 )) = &runner_root
4709 {
4710 notes.push(format!(
4711 "the platform default runner root {} can be written by ordinary local users, so \
4712 it is not a safe place to run jobs. `service install` refuses it rather than \
4713 tightening it, because the contents of a directory anybody could write cannot \
4714 be trusted: remove or empty it, or choose another root with `runner-manager \
4715 host set-runtime-root --path <PATH>`.",
4716 path.display()
4717 ));
4718 }
4719
4720 match runner_root_refusals(paths) {
4743 Ok(refusals) => {
4744 for refusal in refusals {
4745 notes.push(format!(
4746 "policy {} started no runner: its runner root {} refused the launch \
4747 ({}), last at {}. {} This clears when that policy next places a \
4748 runner.",
4749 refusal.policy,
4750 refusal.root,
4751 refusal.kind,
4752 refusal.at.to_rfc3339(),
4753 refusal.detail,
4754 ));
4755 }
4756 }
4757 Err(error) => notes.push(format!(
4758 "whether the agent could use its runner roots could not be read: {error}"
4759 )),
4760 }
4761
4762 Self {
4763 identity,
4764 record,
4765 registration,
4766 binary,
4767 log_file,
4768 store,
4769 last_github_contact,
4770 runner_root,
4771 credential_rejected_since: None,
4772 problems,
4773 notes,
4774 }
4775 }
4776
4777 #[must_use]
4779 pub fn with_definition_drift(mut self, drift: Option<DefinitionDrift>) -> Self {
4780 if let Some(drift) = drift {
4781 let repair = match drift.start_mode {
4782 StartMode::Boot => {
4783 "`sudo systemctl stop runner-manager && sudo runner-manager service install \
4784 --start-at boot` (for a managed WSL host, run `runner-manager wsl install \
4785 --distribution <name>` from Windows)"
4786 }
4787 StartMode::Login => {
4788 "`systemctl --user stop runner-manager && runner-manager service install \
4789 --start-at login`"
4790 }
4791 };
4792 self.problems.push(StatusProblem {
4793 subject: "definition",
4794 detail: format!(
4795 "{} was written by runner-manager {} and differs from what {} renders, so \
4796 the daemon runs under outdated settings (for example a credential frozen \
4797 at service start). Re-register it with {repair}; configuration and \
4798 credentials are kept.",
4799 drift.path.display(),
4800 drift.installed_by_version,
4801 env!("CARGO_PKG_VERSION"),
4802 ),
4803 });
4804 }
4805 self
4806 }
4807
4808 #[must_use]
4810 pub fn with_credential_rejection(mut self, since: Option<DateTime<Utc>>) -> Self {
4811 if let Some(since) = since {
4812 self.credential_rejected_since = Some(since);
4813 self.problems.push(StatusProblem {
4814 subject: "github credential",
4815 detail: format!(
4816 "GitHub has rejected the service's credential since {}, so the service \
4817 cannot see queued jobs or start runners. Run `runner-manager auth login` \
4818 (for a managed WSL host, `runner-manager --host wsl:<name> auth login` \
4819 from Windows).",
4820 since.to_rfc3339()
4821 ),
4822 });
4823 }
4824 self
4825 }
4826
4827 #[must_use]
4829 pub const fn credential_rejected_since(&self) -> Option<DateTime<Utc>> {
4830 self.credential_rejected_since
4831 }
4832
4833 #[must_use]
4841 pub fn runner_root(&self) -> Option<(&Path, &RootAccessReport)> {
4842 self.runner_root
4843 .as_ref()
4844 .map(|(path, report)| (path.as_path(), report))
4845 }
4846
4847 #[must_use]
4849 pub const fn is_installed(&self) -> bool {
4850 self.registration.is_some() || self.record.is_some()
4851 }
4852
4853 #[must_use]
4855 pub fn is_running(&self) -> bool {
4856 self.registration
4857 .as_ref()
4858 .is_some_and(|registration| registration.running)
4859 }
4860
4861 #[must_use]
4863 pub fn is_healthy(&self) -> bool {
4864 self.problems.is_empty()
4865 }
4866
4867 #[must_use]
4869 pub fn problems(&self) -> &[StatusProblem] {
4870 &self.problems
4871 }
4872
4873 #[must_use]
4876 pub fn notes(&self) -> &[String] {
4877 &self.notes
4878 }
4879
4880 #[must_use]
4882 pub fn start_mode(&self) -> Option<StartMode> {
4883 self.record.as_ref().map(|record| record.start_mode)
4884 }
4885
4886 #[must_use]
4889 pub const fn binary(&self) -> Option<&BinaryPath> {
4890 self.binary.as_ref()
4891 }
4892
4893 #[must_use]
4895 pub fn log_file(&self) -> &Path {
4896 &self.log_file
4897 }
4898
4899 #[must_use]
4901 pub const fn last_github_contact(&self) -> Option<DateTime<Utc>> {
4902 self.last_github_contact
4903 }
4904
4905 #[must_use]
4908 pub const fn secret_store(&self) -> Option<&crate::secrets::ActiveStore> {
4909 self.store.as_ref()
4910 }
4911
4912 #[must_use]
4914 pub const fn record(&self) -> Option<&InstallRecord> {
4915 self.record.as_ref()
4916 }
4917
4918 #[must_use]
4920 pub const fn registration(&self) -> Option<&Registration> {
4921 self.registration.as_ref()
4922 }
4923}
4924
4925impl fmt::Display for ServiceStatus {
4926 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4927 writeln!(f, "Service: {}", self.identity)?;
4928 match (&self.record, &self.registration) {
4929 (None, None) => {
4930 writeln!(
4931 f,
4932 " installed no. `service install` registers `{} {}`.",
4933 SERVICE_NAME,
4934 DAEMON_ARGUMENTS.join(" ")
4935 )?;
4936 }
4937 _ => {
4938 let manager = self
4939 .registration
4940 .as_ref()
4941 .map(|registration| registration.manager.manager());
4942 writeln!(
4943 f,
4944 " installed {}",
4945 manager.unwrap_or("yes, but no service manager knows it")
4946 )?;
4947 writeln!(
4948 f,
4949 " state {}",
4950 if self.is_running() {
4951 "running"
4952 } else {
4953 "not running"
4954 }
4955 )?;
4956 }
4957 }
4958 if let Some(record) = &self.record {
4959 writeln!(f, " start mode {}", record.start_mode)?;
4960 writeln!(f, " account {}", record.account)?;
4961 writeln!(f, " restart on failure {}", record.restart())?;
4962 writeln!(
4963 f,
4964 " arguments {}",
4965 record.arguments.join(" ")
4966 )?;
4967 }
4968 if let Some(binary) = &self.binary {
4969 writeln!(f, " binary {binary}")?;
4970 }
4971 writeln!(f, " diagnostic log {}", self.log_file.display())?;
4972 if let Some((path, report)) = &self.runner_root {
4973 writeln!(f, " default runner root {}", path.display())?;
4980 if *report != RootAccessReport::NotApplicable {
4981 writeln!(f, " default root access {report}")?;
4982 }
4983 }
4984 if let Some(store) = &self.store {
4985 writeln!(f, " secret store {store}")?;
4986 }
4987 writeln!(
4988 f,
4989 " last GitHub contact {}",
4990 match self.last_github_contact {
4991 Some(at) => at.to_rfc3339(),
4992 None => "never".to_string(),
4993 }
4994 )?;
4995 for note in &self.notes {
4996 writeln!(f, " note {note}")?;
4997 }
4998 for problem in &self.problems {
4999 writeln!(f, " ERROR {problem}")?;
5000 }
5001 write!(
5002 f,
5003 " verdict {}",
5004 if self.is_healthy() {
5005 "healthy"
5006 } else {
5007 "NOT healthy"
5008 }
5009 )
5010 }
5011}
5012
5013#[derive(Debug, Clone, Default)]
5030pub struct RecordingControls {
5031 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5032}
5033
5034#[derive(Debug, Default)]
5035struct RecordingState {
5036 registrations: BTreeMap<(StartMode, String), Registration>,
5037 definitions: BTreeMap<String, ServiceDefinition>,
5038 calls: Vec<String>,
5039 #[cfg(test)]
5040 install_failures: BTreeMap<StartMode, String>,
5041 #[cfg(test)]
5042 after_install: BTreeMap<StartMode, TestInstallSideEffect>,
5043}
5044
5045#[cfg(test)]
5046#[derive(Debug, Clone)]
5047enum TestInstallSideEffect {
5048 HideDirectory { directory: PathBuf, hidden: PathBuf },
5049}
5050
5051impl RecordingControls {
5052 #[must_use]
5054 pub fn new() -> Self {
5055 Self::default()
5056 }
5057
5058 #[must_use]
5061 pub fn calls(&self) -> Vec<String> {
5062 self.state.lock().expect("not poisoned").calls.clone()
5063 }
5064
5065 #[must_use]
5067 pub fn registrations(&self) -> Vec<(StartMode, String, Registration)> {
5068 self.state
5069 .lock()
5070 .expect("not poisoned")
5071 .registrations
5072 .iter()
5073 .map(|((mode, name), registration)| (*mode, name.clone(), registration.clone()))
5074 .collect()
5075 }
5076
5077 #[must_use]
5079 pub fn definition(&self, name: &str) -> Option<ServiceDefinition> {
5080 self.state
5081 .lock()
5082 .expect("not poisoned")
5083 .definitions
5084 .get(name)
5085 .cloned()
5086 }
5087
5088 pub fn edit(&self, name: &str, edit: impl FnOnce(&mut Registration)) {
5098 let mut state = self.state.lock().expect("not poisoned");
5099 if let Some((_, registration)) = state
5100 .registrations
5101 .iter_mut()
5102 .find(|((_, held), _)| held == name)
5103 {
5104 edit(registration);
5105 }
5106 }
5107
5108 #[cfg(test)]
5109 fn fail_next_install(&self, mode: StartMode, detail: &str) {
5110 self.state
5111 .lock()
5112 .expect("not poisoned")
5113 .install_failures
5114 .insert(mode, detail.to_string());
5115 }
5116
5117 #[cfg(test)]
5118 fn hide_directory_after_install(&self, mode: StartMode, directory: PathBuf, hidden: PathBuf) {
5119 self.state
5120 .lock()
5121 .expect("not poisoned")
5122 .after_install
5123 .insert(
5124 mode,
5125 TestInstallSideEffect::HideDirectory { directory, hidden },
5126 );
5127 }
5128}
5129
5130impl ControlFactory for RecordingControls {
5131 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5132 Ok(Box::new(RecordingControl {
5133 mode,
5134 state: std::sync::Arc::clone(&self.state),
5135 }))
5136 }
5137}
5138
5139#[derive(Debug)]
5140struct RecordingControl {
5141 mode: StartMode,
5142 state: std::sync::Arc<std::sync::Mutex<RecordingState>>,
5143}
5144
5145impl RecordingControl {
5146 fn note(&self, operation: &str, name: &str) {
5147 self.state
5148 .lock()
5149 .expect("not poisoned")
5150 .calls
5151 .push(format!("{operation} {name} ({})", self.mode));
5152 }
5153}
5154
5155impl ServiceControl for RecordingControl {
5156 fn manager(&self) -> DefinitionKind {
5157 host_definition_kind(self.mode)
5161 }
5162
5163 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5164 self.note("install", plan.identity().name());
5165 #[cfg(test)]
5166 if let Some(detail) = self
5167 .state
5168 .lock()
5169 .expect("not poisoned")
5170 .install_failures
5171 .remove(&self.mode)
5172 {
5173 return Err(ServiceError::Control {
5174 operation: "install",
5175 name: plan.identity().name().to_string(),
5176 manager: "recording control",
5177 detail,
5178 });
5179 }
5180 let definition = ServiceDefinition::for_host(plan)?;
5185 let mut state = self.state.lock().expect("not poisoned");
5186 state.registrations.insert(
5187 (self.mode, plan.identity().name().to_string()),
5188 Registration {
5189 manager: host_definition_kind(self.mode),
5190 start_mode: self.mode,
5191 command_line: plan.command_line(),
5192 account: Some(plan.account().as_str().to_string()),
5193 running: false,
5194 starts_automatically: true,
5195 restart_delay: Some(plan.restart().delay()),
5196 },
5197 );
5198 state
5199 .definitions
5200 .insert(plan.identity().name().to_string(), definition.clone());
5201 #[cfg(test)]
5202 let side_effect = state.after_install.remove(&self.mode);
5203 drop(state);
5204 #[cfg(test)]
5205 if let Some(TestInstallSideEffect::HideDirectory { directory, hidden }) = side_effect {
5206 std::fs::rename(&directory, &hidden).expect("test fault can hide the record directory");
5207 std::fs::write(&directory, b"blocks recreation")
5208 .expect("test fault can block record directory recreation");
5209 }
5210 Ok(definition)
5211 }
5212
5213 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5214 self.note("uninstall", identity.name());
5215 let mut state = self.state.lock().expect("not poisoned");
5216 state.definitions.remove(identity.name());
5217 Ok(state
5218 .registrations
5219 .remove(&(self.mode, identity.name().to_string()))
5220 .is_some())
5221 }
5222
5223 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5224 self.note("query", identity.name());
5225 Ok(self
5226 .state
5227 .lock()
5228 .expect("not poisoned")
5229 .registrations
5230 .get(&(self.mode, identity.name().to_string()))
5231 .cloned())
5232 }
5233
5234 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5235 self.note("start", identity.name());
5236 let mut state = self.state.lock().expect("not poisoned");
5237 match state
5238 .registrations
5239 .get_mut(&(self.mode, identity.name().to_string()))
5240 {
5241 Some(registration) => {
5242 registration.running = true;
5243 Ok(())
5244 }
5245 None => Err(ServiceError::NotInstalled {
5246 name: identity.name().to_string(),
5247 operation: "start",
5248 }),
5249 }
5250 }
5251
5252 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5253 self.note("stop", identity.name());
5254 let mut state = self.state.lock().expect("not poisoned");
5255 match state
5256 .registrations
5257 .get_mut(&(self.mode, identity.name().to_string()))
5258 {
5259 Some(registration) => Ok(std::mem::replace(&mut registration.running, false)),
5260 None => Err(ServiceError::NotInstalled {
5261 name: identity.name().to_string(),
5262 operation: "stop",
5263 }),
5264 }
5265 }
5266}
5267
5268impl ControlFactory for HostControls {
5269 fn control(&self, mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5270 sys::control(mode)
5271 }
5272
5273 fn installed_definition(&self, path: &Path) -> Option<String> {
5274 std::fs::read_to_string(path).ok()
5275 }
5276}
5277
5278fn host_home() -> Option<PathBuf> {
5285 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
5286}
5287
5288#[cfg(unix)]
5290fn home_directory() -> Option<PathBuf> {
5291 host_home()
5292}
5293
5294fn run(program: &str, arguments: &[&std::ffi::OsStr]) -> std::io::Result<(bool, String, String)> {
5303 let output = std::process::Command::new(program)
5304 .args(arguments)
5305 .output()?;
5306 Ok((
5307 output.status.success(),
5308 String::from_utf8_lossy(&output.stdout).into_owned(),
5309 String::from_utf8_lossy(&output.stderr).into_owned(),
5310 ))
5311}
5312
5313#[must_use]
5316pub const fn host_definition_kind(mode: StartMode) -> DefinitionKind {
5317 if cfg!(windows) {
5318 match mode {
5319 StartMode::Boot => DefinitionKind::WindowsService,
5320 StartMode::Login => DefinitionKind::WindowsScheduledTask,
5321 }
5322 } else if cfg!(target_os = "macos") {
5323 DefinitionKind::LaunchdPlist
5324 } else {
5325 DefinitionKind::SystemdUnit
5326 }
5327}
5328
5329#[cfg(any(target_os = "macos", test))]
5336fn enable_launchd_registration(
5337 mut launchctl: impl FnMut(&[&std::ffi::OsStr]) -> (bool, String),
5338 domain: &str,
5339 service_target: &str,
5340 plist: &Path,
5341 name: &str,
5342 elevation_remedy: &'static str,
5343) -> Result<(), ServiceError> {
5344 let (enabled, cause) = launchctl(&[
5345 std::ffi::OsStr::new("enable"),
5346 std::ffi::OsStr::new(service_target),
5347 ]);
5348 if enabled {
5349 return Ok(());
5350 }
5351
5352 let (booted_out, bootout_detail) = launchctl(&[
5353 std::ffi::OsStr::new("bootout"),
5354 std::ffi::OsStr::new(service_target),
5355 ]);
5356 let removed = std::fs::remove_file(plist);
5357 if !booted_out || removed.is_err() {
5358 return Err(ServiceError::Rollback {
5359 operation: "enable launchd registration",
5360 name: name.to_string(),
5361 cause,
5362 rollback: format!(
5363 "launchctl bootout {domain}: {}; remove {}: {}",
5364 if booted_out {
5365 "succeeded".to_string()
5366 } else {
5367 bootout_detail
5368 },
5369 plist.display(),
5370 removed
5371 .err()
5372 .map_or_else(|| "succeeded".to_string(), |error| error.to_string())
5373 ),
5374 });
5375 }
5376
5377 if cause.to_ascii_lowercase().contains("permission denied") {
5378 Err(ServiceError::NeedsElevation {
5379 operation: "enable",
5380 name: name.to_string(),
5381 detail: cause,
5382 remedy: elevation_remedy,
5383 })
5384 } else {
5385 Err(ServiceError::Control {
5386 operation: "enable",
5387 name: name.to_string(),
5388 manager: "launchd",
5389 detail: cause,
5390 })
5391 }
5392}
5393
5394#[derive(Debug, Clone)]
5403pub struct ServiceShutdown(tokio::sync::watch::Receiver<bool>);
5404
5405impl ServiceShutdown {
5406 pub async fn wait(mut self) {
5408 if !*self.0.borrow() {
5409 let _ = self.0.changed().await;
5410 }
5411 }
5412}
5413
5414#[cfg(windows)]
5421pub fn run_windows_service_host<F>(run: F) -> Result<u8, ServiceError>
5422where
5423 F: FnOnce(ServiceShutdown) -> u8 + Send + 'static,
5424{
5425 windows_host::run(Box::new(run))
5426}
5427
5428#[cfg(windows)]
5429mod windows_host {
5430 use std::ffi::OsString;
5431 use std::sync::{Arc, Mutex, OnceLock, mpsc};
5432 use std::time::Duration;
5433
5434 use windows_service::service::{
5435 ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
5436 ServiceType,
5437 };
5438 use windows_service::service_control_handler::{
5439 self, ServiceControlHandlerResult, ServiceStatusHandle,
5440 };
5441
5442 use super::{SERVICE_NAME, ServiceError, ServiceShutdown};
5443
5444 type Runner = Box<dyn FnOnce(ServiceShutdown) -> u8 + Send>;
5445
5446 struct Invocation {
5447 run: Runner,
5448 result: mpsc::SyncSender<Result<u8, String>>,
5449 }
5450
5451 static INVOCATION: OnceLock<Mutex<Option<Invocation>>> = OnceLock::new();
5452
5453 windows_service::define_windows_service!(ffi_service_main, service_main);
5454
5455 pub(super) fn run(run: Runner) -> Result<u8, ServiceError> {
5456 let (result_tx, result_rx) = mpsc::sync_channel(1);
5457 let slot = INVOCATION.get_or_init(|| Mutex::new(None));
5458 let mut invocation = slot
5459 .lock()
5460 .map_err(|_| host_error("prepare", "the service-host slot is poisoned"))?;
5461 if invocation.is_some() {
5462 return Err(host_error(
5463 "prepare",
5464 "the service-host slot was already used",
5465 ));
5466 }
5467 *invocation = Some(Invocation {
5468 run,
5469 result: result_tx,
5470 });
5471 drop(invocation);
5472
5473 windows_service::service_dispatcher::start("", ffi_service_main)
5474 .map_err(|error| host_error("connect", &error.to_string()))?;
5475 result_rx
5476 .recv()
5477 .map_err(|error| host_error("finish", &error.to_string()))?
5478 .map_err(|detail| host_error("run", &detail))
5479 }
5480
5481 fn service_main(_arguments: Vec<OsString>) {
5482 let Some(invocation) = INVOCATION.get().and_then(|slot| slot.lock().ok()?.take()) else {
5483 return;
5484 };
5485 let result = run_service(invocation.run);
5486 let _ = invocation.result.send(result);
5487 }
5488
5489 fn run_service(run: Runner) -> Result<u8, String> {
5490 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
5491 let status: Arc<Mutex<Option<ServiceStatusHandle>>> = Arc::new(Mutex::new(None));
5492 let handler_status = Arc::clone(&status);
5493 let handler = move |control| match control {
5494 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
5495 ServiceControl::Stop | ServiceControl::Shutdown => {
5496 if let Some(handle) = handler_status.lock().ok().and_then(|guard| *guard) {
5497 let _ = handle.set_service_status(service_status(
5498 ServiceState::StopPending,
5499 ServiceControlAccept::empty(),
5500 1,
5501 Duration::from_secs(300),
5502 0,
5503 ));
5504 }
5505 let _ = stop_tx.send(true);
5506 ServiceControlHandlerResult::NoError
5507 }
5508 _ => ServiceControlHandlerResult::NotImplemented,
5509 };
5510 let handle = service_control_handler::register("", handler)
5511 .map_err(|error| format!("cannot register the service control handler: {error}"))?;
5512 *status
5513 .lock()
5514 .map_err(|_| "the service status handle is poisoned".to_string())? = Some(handle);
5515 handle
5516 .set_service_status(service_status(
5517 ServiceState::Running,
5518 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
5519 0,
5520 Duration::default(),
5521 0,
5522 ))
5523 .map_err(|error| format!("cannot report SERVICE_RUNNING: {error}"))?;
5524
5525 let exit = run(ServiceShutdown(stop_rx));
5526 handle
5527 .set_service_status(service_status(
5528 ServiceState::Stopped,
5529 ServiceControlAccept::empty(),
5530 0,
5531 Duration::default(),
5532 u32::from(exit),
5533 ))
5534 .map_err(|error| format!("cannot report SERVICE_STOPPED: {error}"))?;
5535 Ok(exit)
5536 }
5537
5538 fn service_status(
5539 state: ServiceState,
5540 accepted: ServiceControlAccept,
5541 checkpoint: u32,
5542 wait_hint: Duration,
5543 exit: u32,
5544 ) -> ServiceStatus {
5545 ServiceStatus {
5546 service_type: ServiceType::OWN_PROCESS,
5547 current_state: state,
5548 controls_accepted: accepted,
5549 exit_code: if exit == 0 {
5550 ServiceExitCode::Win32(0)
5551 } else {
5552 ServiceExitCode::ServiceSpecific(exit)
5553 },
5554 checkpoint,
5555 wait_hint,
5556 process_id: None,
5557 }
5558 }
5559
5560 fn host_error(operation: &'static str, detail: &str) -> ServiceError {
5561 ServiceError::Control {
5562 operation,
5563 name: SERVICE_NAME.to_string(),
5564 manager: "the Windows Service Control Manager",
5565 detail: detail.to_string(),
5566 }
5567 }
5568}
5569
5570#[cfg(windows)]
5571mod sys {
5572 use std::ffi::{OsStr, OsString};
5582 use std::time::{Duration, Instant};
5583
5584 use runner_manager_domain::model::StartMode;
5585 use windows_service::service::{
5586 ServiceAccess, ServiceAction, ServiceActionType, ServiceErrorControl,
5587 ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType,
5588 ServiceState, ServiceType,
5589 };
5590 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
5591
5592 use super::{
5593 DefinitionKind, InstallPlan, Registration, ServiceControl, ServiceDefinition, ServiceError,
5594 ServiceIdentity, TaskPrincipal, run, windows_service_spec, xml_value,
5595 };
5596
5597 const SERVICE_DOES_NOT_EXIST: i32 = 1060;
5600 const SERVICE_MARKED_FOR_DELETE: i32 = 1072;
5604 const ACCESS_DENIED: i32 = 5;
5606 const DELETE_TIMEOUT: Duration = Duration::from_secs(30);
5607 const DELETE_POLL_INTERVAL: Duration = Duration::from_millis(200);
5608
5609 const ELEVATION_REMEDY: &str = "Run the command from an elevated prompt: right-click Windows Terminal or PowerShell and \
5610 choose \"Run as administrator\".";
5611
5612 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
5613 Ok(match mode {
5614 StartMode::Boot => Box::new(ScmControl),
5615 StartMode::Login => Box::new(TaskControl),
5616 })
5617 }
5618
5619 #[derive(Debug)]
5622 struct ScmControl;
5623
5624 fn scm_error(
5628 operation: &'static str,
5629 name: &str,
5630 error: &windows_service::Error,
5631 ) -> ServiceError {
5632 let raw = match error {
5633 windows_service::Error::Winapi(io) => io.raw_os_error(),
5634 _ => None,
5635 };
5636 let detail = match error {
5637 windows_service::Error::Winapi(io) => io.to_string(),
5638 other => other.to_string(),
5639 };
5640 if raw == Some(ACCESS_DENIED) {
5641 return ServiceError::NeedsElevation {
5642 operation,
5643 name: name.to_string(),
5644 detail,
5645 remedy: ELEVATION_REMEDY,
5646 };
5647 }
5648 ServiceError::Control {
5649 operation,
5650 name: name.to_string(),
5651 manager: "the Windows Service Control Manager",
5652 detail,
5653 }
5654 }
5655
5656 fn open_manager(
5657 access: ServiceManagerAccess,
5658 operation: &'static str,
5659 name: &str,
5660 ) -> Result<ServiceManager, ServiceError> {
5661 ServiceManager::local_computer(None::<&OsStr>, access)
5662 .map_err(|error| scm_error(operation, name, &error))
5663 }
5664
5665 impl ServiceControl for ScmControl {
5666 fn manager(&self) -> DefinitionKind {
5667 DefinitionKind::WindowsService
5668 }
5669
5670 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5671 let spec = windows_service_spec(plan);
5672 let manager = open_manager(
5673 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
5674 "install",
5675 &spec.name,
5676 )?;
5677 let info = ServiceInfo {
5678 name: OsString::from(&spec.name),
5679 display_name: OsString::from(&spec.display_name),
5680 service_type: ServiceType::OWN_PROCESS,
5682 start_type: if spec.automatic_start {
5683 ServiceStartType::AutoStart
5684 } else {
5685 ServiceStartType::OnDemand
5686 },
5687 error_control: ServiceErrorControl::Normal,
5688 executable_path: plan.binary().to_path_buf(),
5689 launch_arguments: plan.arguments().to_vec(),
5690 dependencies: Vec::new(),
5691 account_name: spec.account.as_ref().map(OsString::from),
5693 account_password: None,
5696 };
5697 let service = manager
5698 .create_service(
5699 &info,
5700 ServiceAccess::CHANGE_CONFIG
5701 | ServiceAccess::QUERY_CONFIG
5702 | ServiceAccess::QUERY_STATUS
5703 | ServiceAccess::START
5704 | ServiceAccess::STOP
5705 | ServiceAccess::DELETE,
5706 )
5707 .map_err(|error| scm_error("install", &spec.name, &error))?;
5708 service
5709 .set_description(&spec.description)
5710 .map_err(|error| scm_error("describe", &spec.name, &error))?;
5711 service
5712 .update_failure_actions(ServiceFailureActions {
5713 reset_period: ServiceFailureResetPeriod::After(spec.restart.reset_after()),
5714 reboot_msg: None,
5715 command: None,
5716 actions: Some(vec![
5723 ServiceAction {
5724 action_type: ServiceActionType::Restart,
5725 delay: spec.restart.delay(),
5726 };
5727 3
5728 ]),
5729 })
5730 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5731 service
5734 .set_failure_actions_on_non_crash_failures(true)
5735 .map_err(|error| scm_error("set the restart policy of", &spec.name, &error))?;
5736 Ok(ServiceDefinition::windows_service(plan))
5737 }
5738
5739 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5740 let manager =
5741 open_manager(ServiceManagerAccess::CONNECT, "uninstall", identity.name())?;
5742 let service = match manager.open_service(
5743 identity.name(),
5744 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
5745 ) {
5746 Ok(service) => Some(service),
5747 Err(error) if is_missing(&error) => return Ok(false),
5748 Err(error) if is_marked_for_delete(&error) => None,
5753 Err(error) => return Err(scm_error("uninstall", identity.name(), &error)),
5754 };
5755 if let Some(service) = service {
5756 if let Ok(status) = service.query_status()
5760 && status.current_state != ServiceState::Stopped
5761 {
5762 let _ = service.stop();
5763 }
5764 match service.delete() {
5765 Ok(()) => {}
5766 Err(error) if is_marked_for_delete(&error) => {}
5770 Err(error) => {
5771 return Err(scm_error("uninstall", identity.name(), &error));
5772 }
5773 }
5774 drop(service);
5775 }
5776
5777 let absent = wait_until_scm_absent(DELETE_TIMEOUT, DELETE_POLL_INTERVAL, || {
5784 match manager.open_service(identity.name(), ServiceAccess::QUERY_STATUS) {
5785 Ok(service) => {
5786 drop(service);
5787 Ok(false)
5788 }
5789 Err(error) if is_missing(&error) => Ok(true),
5790 Err(error) if is_marked_for_delete(&error) => Ok(false),
5791 Err(error) => Err(scm_error("verify uninstall of", identity.name(), &error)),
5792 }
5793 })?;
5794 if !absent {
5795 return Err(ServiceError::Control {
5796 operation: "verify uninstall of",
5797 name: identity.name().to_string(),
5798 manager: "the Windows Service Control Manager",
5799 detail: format!(
5800 "the registration was still visible {} seconds after DeleteService; \
5801 retry `service uninstall` from an elevated prompt",
5802 DELETE_TIMEOUT.as_secs()
5803 ),
5804 });
5805 }
5806 Ok(true)
5807 }
5808
5809 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
5810 let manager = open_manager(ServiceManagerAccess::CONNECT, "inspect", identity.name())?;
5811 let service = match manager.open_service(
5812 identity.name(),
5813 ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
5814 ) {
5815 Ok(service) => service,
5816 Err(error) if is_missing(&error) => return Ok(None),
5817 Err(error) => return Err(scm_error("inspect", identity.name(), &error)),
5818 };
5819 let config = service
5820 .query_config()
5821 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5822 let status = service
5823 .query_status()
5824 .map_err(|error| scm_error("inspect", identity.name(), &error))?;
5825 let restart_delay = service.get_failure_actions().ok().and_then(|actions| {
5826 actions
5827 .actions
5828 .and_then(|actions| actions.into_iter().next())
5829 .filter(|action| action.action_type == ServiceActionType::Restart)
5830 .map(|action| action.delay)
5831 });
5832 Ok(Some(Registration {
5833 manager: DefinitionKind::WindowsService,
5834 start_mode: StartMode::Boot,
5835 command_line: config.executable_path.to_string_lossy().into_owned(),
5839 account: config
5840 .account_name
5841 .map(|account| account.to_string_lossy().into_owned()),
5842 running: status.current_state == ServiceState::Running,
5843 starts_automatically: config.start_type == ServiceStartType::AutoStart,
5844 restart_delay,
5845 }))
5846 }
5847
5848 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
5849 let manager = open_manager(ServiceManagerAccess::CONNECT, "start", identity.name())?;
5850 let service = manager
5851 .open_service(identity.name(), ServiceAccess::START)
5852 .map_err(|error| scm_error("start", identity.name(), &error))?;
5853 service
5854 .start::<&OsStr>(&[])
5855 .map_err(|error| scm_error("start", identity.name(), &error))
5856 }
5857
5858 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5859 let manager = open_manager(ServiceManagerAccess::CONNECT, "stop", identity.name())?;
5860 let service = manager
5861 .open_service(
5862 identity.name(),
5863 ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
5864 )
5865 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5866 let status = service
5867 .query_status()
5868 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5869 if status.current_state == ServiceState::Stopped {
5870 return Ok(false);
5871 }
5872 service
5873 .stop()
5874 .map_err(|error| scm_error("stop", identity.name(), &error))?;
5875 Ok(true)
5876 }
5877 }
5878
5879 fn is_missing(error: &windows_service::Error) -> bool {
5880 matches!(error, windows_service::Error::Winapi(io)
5881 if io.raw_os_error() == Some(SERVICE_DOES_NOT_EXIST))
5882 }
5883
5884 pub(super) fn is_marked_for_delete(error: &windows_service::Error) -> bool {
5885 matches!(error, windows_service::Error::Winapi(io)
5886 if io.raw_os_error() == Some(SERVICE_MARKED_FOR_DELETE))
5887 }
5888
5889 pub(super) fn wait_until_scm_absent(
5890 timeout: Duration,
5891 poll_interval: Duration,
5892 mut probe_absent: impl FnMut() -> Result<bool, ServiceError>,
5893 ) -> Result<bool, ServiceError> {
5894 let deadline = Instant::now() + timeout;
5895 loop {
5896 if probe_absent()? {
5897 return Ok(true);
5898 }
5899 if Instant::now() >= deadline {
5900 return Ok(false);
5901 }
5902 std::thread::sleep(poll_interval);
5903 }
5904 }
5905
5906 #[derive(Debug)]
5909 struct TaskControl;
5910
5911 fn task_error(operation: &'static str, name: &str, detail: String) -> ServiceError {
5912 if detail.to_ascii_lowercase().contains("access is denied") {
5913 return ServiceError::NeedsElevation {
5914 operation,
5915 name: name.to_string(),
5916 detail,
5917 remedy: ELEVATION_REMEDY,
5918 };
5919 }
5920 ServiceError::Control {
5921 operation,
5922 name: name.to_string(),
5923 manager: "Windows Task Scheduler",
5924 detail,
5925 }
5926 }
5927
5928 fn schtasks(
5929 operation: &'static str,
5930 name: &str,
5931 arguments: &[&OsStr],
5932 ) -> Result<(bool, String), ServiceError> {
5933 match run("schtasks.exe", arguments) {
5934 Ok((ok, stdout, stderr)) => Ok((ok, if ok { stdout } else { stderr })),
5935 Err(error) => Err(task_error(operation, name, error.to_string())),
5936 }
5937 }
5938
5939 fn write_utf16(path: &std::path::Path, text: &str) -> std::io::Result<()> {
5942 let mut bytes = vec![0xFF, 0xFE];
5943 for unit in text.encode_utf16() {
5944 bytes.extend_from_slice(&unit.to_le_bytes());
5945 }
5946 std::fs::write(path, bytes)
5947 }
5948
5949 impl ServiceControl for TaskControl {
5950 fn manager(&self) -> DefinitionKind {
5951 DefinitionKind::WindowsScheduledTask
5952 }
5953
5954 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
5955 let name = plan.identity().name().to_string();
5956 let principal = TaskPrincipal::current()?;
5957 let definition = ServiceDefinition::windows_scheduled_task(plan, &principal);
5958 let directory = tempfile::tempdir()
5959 .map_err(|error| task_error("install", &name, error.to_string()))?;
5960 let document = directory.path().join("task.xml");
5961 write_utf16(&document, definition.text())
5962 .map_err(|error| task_error("install", &name, error.to_string()))?;
5963 let (ok, message) = schtasks(
5964 "install",
5965 &name,
5966 &[
5967 OsStr::new("/Create"),
5968 OsStr::new("/TN"),
5969 OsStr::new(&name),
5970 OsStr::new("/XML"),
5971 document.as_os_str(),
5972 ],
5973 )?;
5974 if !ok {
5975 return Err(task_error("install", &name, message));
5976 }
5977 Ok(definition)
5978 }
5979
5980 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
5981 if self.query(identity)?.is_none() {
5982 return Ok(false);
5983 }
5984 let name = identity.name().to_string();
5985 let (ok, message) = schtasks(
5986 "uninstall",
5987 &name,
5988 &[
5989 OsStr::new("/Delete"),
5990 OsStr::new("/TN"),
5991 OsStr::new(&name),
5992 OsStr::new("/F"),
5993 ],
5994 )?;
5995 if !ok {
5996 return Err(task_error("uninstall", &name, message));
5997 }
5998 Ok(true)
5999 }
6000
6001 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6002 let name = identity.name().to_string();
6003 let (ok, document) = schtasks(
6004 "inspect",
6005 &name,
6006 &[
6007 OsStr::new("/Query"),
6008 OsStr::new("/TN"),
6009 OsStr::new(&name),
6010 OsStr::new("/XML"),
6011 OsStr::new("ONE"),
6012 ],
6013 )?;
6014 if !ok {
6015 return Ok(None);
6021 }
6022 let command = xml_value(&document, "Command").unwrap_or_default();
6023 let arguments = xml_value(&document, "Arguments").unwrap_or_default();
6024 let command_line = if arguments.is_empty() {
6025 super::quote_argument(&command)
6026 } else {
6027 format!("{} {arguments}", super::quote_argument(&command))
6028 };
6029 Ok(Some(Registration {
6030 manager: DefinitionKind::WindowsScheduledTask,
6031 start_mode: StartMode::Login,
6032 command_line,
6033 account: xml_value(&document, "UserId"),
6034 running: task_is_running(&name),
6035 starts_automatically: super::windows_login_task_starts_automatically(&document),
6036 restart_delay: xml_value(&document, "Interval")
6037 .as_deref()
6038 .and_then(parse_iso8601),
6039 }))
6040 }
6041
6042 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6043 let name = identity.name().to_string();
6044 let (ok, message) = schtasks(
6045 "start",
6046 &name,
6047 &[OsStr::new("/Run"), OsStr::new("/TN"), OsStr::new(&name)],
6048 )?;
6049 if ok {
6050 Ok(())
6051 } else {
6052 Err(task_error("start", &name, message))
6053 }
6054 }
6055
6056 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6057 let running = self
6058 .query(identity)?
6059 .is_some_and(|registration| registration.running);
6060 if !running {
6061 return Ok(false);
6062 }
6063 let name = identity.name().to_string();
6064 let (ok, message) = schtasks(
6065 "stop",
6066 &name,
6067 &[OsStr::new("/End"), OsStr::new("/TN"), OsStr::new(&name)],
6068 )?;
6069 if ok {
6070 Ok(true)
6071 } else {
6072 Err(task_error("stop", &name, message))
6073 }
6074 }
6075 }
6076
6077 fn task_is_running(name: &str) -> bool {
6091 let Ok((true, stdout, _)) = run(
6092 "schtasks.exe",
6093 &[
6094 OsStr::new("/Query"),
6095 OsStr::new("/TN"),
6096 OsStr::new(name),
6097 OsStr::new("/FO"),
6098 OsStr::new("CSV"),
6099 OsStr::new("/NH"),
6100 ],
6101 ) else {
6102 return false;
6103 };
6104 stdout
6105 .lines()
6106 .filter_map(|line| line.rsplit(',').next())
6107 .any(|status| {
6108 status
6109 .trim()
6110 .trim_matches('"')
6111 .eq_ignore_ascii_case("running")
6112 })
6113 }
6114
6115 fn parse_iso8601(value: &str) -> Option<Duration> {
6119 let rest = value.strip_prefix("PT")?;
6120 if let Some(minutes) = rest.strip_suffix('M') {
6121 return minutes
6122 .parse::<u64>()
6123 .ok()
6124 .map(|minutes| Duration::from_secs(minutes * 60));
6125 }
6126 rest.strip_suffix('S')?
6127 .parse::<u64>()
6128 .ok()
6129 .map(Duration::from_secs)
6130 }
6131}
6132
6133#[cfg(unix)]
6145fn write_definition(
6146 operation: &'static str,
6147 name: &str,
6148 path: &Path,
6149 text: &str,
6150 remedy: &'static str,
6151) -> Result<(), ServiceError> {
6152 if let Some(parent) = path.parent()
6153 && let Err(error) = std::fs::create_dir_all(parent)
6154 && error.kind() != std::io::ErrorKind::AlreadyExists
6155 {
6156 return Err(definition_error(operation, name, error, remedy, parent));
6157 }
6158 std::fs::write(path, text)
6159 .map_err(|error| definition_error(operation, name, error, remedy, path))
6160}
6161
6162#[cfg(unix)]
6163fn definition_error(
6164 operation: &'static str,
6165 name: &str,
6166 error: std::io::Error,
6167 remedy: &'static str,
6168 path: &Path,
6169) -> ServiceError {
6170 let detail = format!("{}: {error}", path.display());
6171 if error.kind() == std::io::ErrorKind::PermissionDenied {
6172 ServiceError::NeedsElevation {
6173 operation,
6174 name: name.to_string(),
6175 detail,
6176 remedy,
6177 }
6178 } else {
6179 ServiceError::Control {
6180 operation,
6181 name: name.to_string(),
6182 manager: "the local service manager",
6183 detail,
6184 }
6185 }
6186}
6187
6188#[cfg(unix)]
6190const SUDO_REMEDY: &str = "A boot-start registration is machine-wide, so it needs root: run the same command with \
6191 `sudo`. `service install --start-at login` needs no elevation at all, at the cost of the \
6192 agent not running until you sign in.";
6193
6194#[cfg(target_os = "macos")]
6199mod sys {
6200 use std::ffi::OsStr;
6213 use std::path::PathBuf;
6214 use std::time::Duration;
6215
6216 use runner_manager_domain::model::StartMode;
6217
6218 use super::{
6219 DefinitionKind, InstallPlan, LAUNCH_AGENTS_SUBDIR, LAUNCH_DAEMONS_DIR, Registration,
6220 SUDO_REMEDY, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6221 enable_launchd_registration, home_directory, plist_string_value, quote_argument, run,
6222 write_definition, xml_value,
6223 };
6224
6225 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6226 Ok(Box::new(LaunchdControl { mode }))
6227 }
6228
6229 #[derive(Debug)]
6230 struct LaunchdControl {
6231 mode: StartMode,
6232 }
6233
6234 impl LaunchdControl {
6235 fn domain(&self) -> String {
6237 match self.mode {
6238 StartMode::Boot => "system".to_string(),
6239 StartMode::Login => format!("gui/{}", unsafe { libc::getuid() }),
6242 }
6243 }
6244
6245 fn service_target(&self, identity: &ServiceIdentity) -> String {
6246 format!("{}/{}", self.domain(), identity.launchd_label())
6247 }
6248
6249 fn plist_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6252 let file = format!("{}.plist", identity.launchd_label());
6253 match self.mode {
6254 StartMode::Boot => Some(PathBuf::from(LAUNCH_DAEMONS_DIR).join(file)),
6255 StartMode::Login => {
6256 home_directory().map(|home| home.join(LAUNCH_AGENTS_SUBDIR).join(file))
6257 }
6258 }
6259 }
6260
6261 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6262 ServiceError::Control {
6263 operation,
6264 name: name.to_string(),
6265 manager: "launchd",
6266 detail,
6267 }
6268 }
6269
6270 fn launchctl(&self, arguments: &[&OsStr]) -> (bool, String) {
6271 match run("launchctl", arguments) {
6272 Ok((ok, stdout, stderr)) => (ok, if ok { stdout } else { stderr }),
6273 Err(error) => (false, error.to_string()),
6274 }
6275 }
6276 }
6277
6278 impl ServiceControl for LaunchdControl {
6279 fn manager(&self) -> DefinitionKind {
6280 DefinitionKind::LaunchdPlist
6281 }
6282
6283 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6284 let name = plan.identity().name().to_string();
6285 let definition = ServiceDefinition::launchd(plan, home_directory().as_deref());
6286 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6287 return Err(self.failed(
6288 "install",
6289 &name,
6290 "this account has no home directory, so there is nowhere to put a \
6291 LaunchAgent. Use --start-at boot, which installs a LaunchDaemon under \
6292 /Library/LaunchDaemons."
6293 .to_string(),
6294 ));
6295 };
6296 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6297 let target = self.domain();
6298 let (ok, message) = self.launchctl(&[
6299 OsStr::new("bootstrap"),
6300 OsStr::new(&target),
6301 path.as_os_str(),
6302 ]);
6303 if !ok {
6304 let _ = std::fs::remove_file(&path);
6308 if message.to_ascii_lowercase().contains("permission denied") {
6309 return Err(ServiceError::NeedsElevation {
6310 operation: "install",
6311 name,
6312 detail: message,
6313 remedy: SUDO_REMEDY,
6314 });
6315 }
6316 return Err(self.failed("install", &name, message));
6317 }
6318 let service_target = self.service_target(plan.identity());
6321 enable_launchd_registration(
6322 |arguments| self.launchctl(arguments),
6323 &target,
6324 &service_target,
6325 &path,
6326 &name,
6327 SUDO_REMEDY,
6328 )?;
6329 Ok(definition)
6330 }
6331
6332 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6333 let Some(path) = self.plist_path(identity) else {
6334 return Ok(false);
6335 };
6336 if !path.exists() {
6337 return Ok(false);
6338 }
6339 let target = self.service_target(identity);
6340 let (ok, message) = self.launchctl(&[OsStr::new("bootout"), OsStr::new(&target)]);
6341 if !ok
6342 && !message.to_ascii_lowercase().contains("no such process")
6343 && !message.contains("113")
6344 {
6345 if message.to_ascii_lowercase().contains("permission denied") {
6346 return Err(ServiceError::NeedsElevation {
6347 operation: "uninstall",
6348 name: identity.name().to_string(),
6349 detail: message,
6350 remedy: SUDO_REMEDY,
6351 });
6352 }
6353 return Err(self.failed("uninstall", identity.name(), message));
6354 }
6355 std::fs::remove_file(&path).map_err(|error| {
6358 super::definition_error(
6359 "uninstall",
6360 identity.name(),
6361 error,
6362 SUDO_REMEDY,
6363 path.as_path(),
6364 )
6365 })?;
6366 Ok(true)
6367 }
6368
6369 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6370 let Some(path) = self.plist_path(identity) else {
6371 return Ok(None);
6372 };
6373 let Ok(document) = std::fs::read_to_string(&path) else {
6374 return Ok(None);
6375 };
6376 let target = self.service_target(identity);
6377 let (loaded, printed) = self.launchctl(&[OsStr::new("print"), OsStr::new(&target)]);
6378 Ok(Some(Registration {
6379 manager: DefinitionKind::LaunchdPlist,
6380 start_mode: self.mode,
6381 command_line: program_arguments(&document),
6382 account: plist_string_value(&document, "UserName")
6383 .or_else(|| Some("the invoking user".to_string())),
6384 running: loaded && printed.contains("state = running"),
6385 starts_automatically: document.contains("<key>RunAtLoad</key>")
6386 && super::plist_bool_value(&document, "RunAtLoad") == Some(true),
6387 restart_delay: xml_value(
6388 super::plist_value_after_key(&document, "ThrottleInterval").unwrap_or(""),
6389 "integer",
6390 )
6391 .and_then(|value| value.parse::<u64>().ok())
6392 .map(Duration::from_secs),
6393 }))
6394 }
6395
6396 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6397 let target = self.service_target(identity);
6398 let (ok, message) = self.launchctl(&[
6399 OsStr::new("kickstart"),
6400 OsStr::new("-k"),
6401 OsStr::new(&target),
6402 ]);
6403 if ok {
6404 Ok(())
6405 } else {
6406 Err(self.failed("start", identity.name(), message))
6407 }
6408 }
6409
6410 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6411 let running = self
6412 .query(identity)?
6413 .is_some_and(|registration| registration.running);
6414 if !running {
6415 return Ok(false);
6416 }
6417 let target = self.service_target(identity);
6418 let (ok, message) = self.launchctl(&[
6419 OsStr::new("kill"),
6420 OsStr::new("SIGTERM"),
6421 OsStr::new(&target),
6422 ]);
6423 if ok {
6424 Ok(true)
6425 } else {
6426 Err(self.failed("stop", identity.name(), message))
6427 }
6428 }
6429 }
6430
6431 fn program_arguments(document: &str) -> String {
6433 let Some(rest) = super::plist_value_after_key(document, "ProgramArguments") else {
6434 return String::new();
6435 };
6436 let Some(end) = rest.find("</array>") else {
6437 return String::new();
6438 };
6439 let mut out = Vec::new();
6440 let mut cursor = &rest[..end];
6441 while let Some(open) = cursor.find("<string>") {
6442 let after = &cursor[open + "<string>".len()..];
6443 let Some(close) = after.find("</string>") else {
6444 break;
6445 };
6446 out.push(quote_argument(&super::xml_unescape(&after[..close])));
6447 cursor = &after[close..];
6448 }
6449 out.join(" ")
6450 }
6451}
6452
6453#[cfg(all(unix, not(target_os = "macos")))]
6458mod sys {
6459 use std::ffi::OsStr;
6469 use std::path::PathBuf;
6470 use std::time::Duration;
6471
6472 use runner_manager_domain::model::StartMode;
6473
6474 use super::{
6475 DefinitionKind, InstallPlan, Registration, SUDO_REMEDY, SYSTEMD_SYSTEM_DIR,
6476 SYSTEMD_USER_SUBDIR, ServiceControl, ServiceDefinition, ServiceError, ServiceIdentity,
6477 home_directory, ini_directives, run, write_definition,
6478 };
6479
6480 pub(super) fn control(mode: StartMode) -> Result<Box<dyn ServiceControl>, ServiceError> {
6481 Ok(Box::new(SystemdControl { mode }))
6482 }
6483
6484 #[derive(Debug)]
6485 struct SystemdControl {
6486 mode: StartMode,
6487 }
6488
6489 impl SystemdControl {
6490 fn unit_path(&self, identity: &ServiceIdentity) -> Option<PathBuf> {
6491 let file = identity.systemd_unit();
6492 match self.mode {
6493 StartMode::Boot => Some(PathBuf::from(SYSTEMD_SYSTEM_DIR).join(file)),
6494 StartMode::Login => {
6495 home_directory().map(|home| home.join(SYSTEMD_USER_SUBDIR).join(file))
6496 }
6497 }
6498 }
6499
6500 fn systemctl(&self, arguments: &[&str]) -> (bool, String) {
6502 let mut all: Vec<&OsStr> = Vec::with_capacity(arguments.len() + 1);
6503 if self.mode == StartMode::Login {
6504 all.push(OsStr::new("--user"));
6505 }
6506 all.extend(arguments.iter().map(OsStr::new));
6507 match run("systemctl", &all) {
6508 Ok((ok, stdout, stderr)) => (
6509 ok,
6510 if stdout.trim().is_empty() {
6511 stderr
6512 } else {
6513 stdout
6514 },
6515 ),
6516 Err(error) => (false, error.to_string()),
6517 }
6518 }
6519
6520 fn failed(&self, operation: &'static str, name: &str, detail: String) -> ServiceError {
6521 ServiceError::Control {
6522 operation,
6523 name: name.to_string(),
6524 manager: "systemd",
6525 detail,
6526 }
6527 }
6528 }
6529
6530 impl ServiceControl for SystemdControl {
6531 fn manager(&self) -> DefinitionKind {
6532 DefinitionKind::SystemdUnit
6533 }
6534
6535 fn install(&self, plan: &InstallPlan) -> Result<ServiceDefinition, ServiceError> {
6536 let name = plan.identity().name().to_string();
6537 let definition = ServiceDefinition::systemd(plan, home_directory().as_deref());
6538 let Some(path) = definition.install_path().map(std::path::Path::to_path_buf) else {
6539 return Err(self.failed(
6540 "install",
6541 &name,
6542 "this account has no home directory, so there is nowhere to put a systemd \
6543 user unit. Use --start-at boot, which installs a system unit under \
6544 /etc/systemd/system."
6545 .to_string(),
6546 ));
6547 };
6548 write_definition("install", &name, &path, definition.text(), SUDO_REMEDY)?;
6549 let unit = plan.identity().systemd_unit();
6550 let (reloaded, message) = self.systemctl(&["daemon-reload"]);
6551 if !reloaded {
6552 let _ = std::fs::remove_file(&path);
6553 return Err(self.failed("install", &name, message));
6554 }
6555 let (enabled, message) = self.systemctl(&["enable", &unit]);
6556 if !enabled {
6557 let _ = std::fs::remove_file(&path);
6558 let _ = self.systemctl(&["daemon-reload"]);
6559 return Err(self.failed("install", &name, message));
6560 }
6561 Ok(definition)
6562 }
6563
6564 fn uninstall(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6565 let Some(path) = self.unit_path(identity) else {
6566 return Ok(false);
6567 };
6568 if !path.exists() {
6569 return Ok(false);
6570 }
6571 let unit = identity.systemd_unit();
6572 let _ = self.systemctl(&["disable", "--now", &unit]);
6577 std::fs::remove_file(&path).map_err(|error| {
6578 super::definition_error(
6579 "uninstall",
6580 identity.name(),
6581 error,
6582 SUDO_REMEDY,
6583 path.as_path(),
6584 )
6585 })?;
6586 let _ = self.systemctl(&["daemon-reload"]);
6587 Ok(true)
6588 }
6589
6590 fn query(&self, identity: &ServiceIdentity) -> Result<Option<Registration>, ServiceError> {
6591 let Some(path) = self.unit_path(identity) else {
6592 return Ok(None);
6593 };
6594 let Ok(document) = std::fs::read_to_string(&path) else {
6595 return Ok(None);
6596 };
6597 let unit = identity.systemd_unit();
6598 let directives = ini_directives(&document, "Service");
6599 let (_, active) = self.systemctl(&["is-active", &unit]);
6600 let (_, enabled) = self.systemctl(&["is-enabled", &unit]);
6601 Ok(Some(Registration {
6602 manager: DefinitionKind::SystemdUnit,
6603 start_mode: self.mode,
6604 command_line: directives.get("ExecStart").cloned().unwrap_or_default(),
6605 account: directives.get("User").cloned().or_else(|| {
6606 Some(match self.mode {
6607 StartMode::Boot => "root".to_string(),
6608 StartMode::Login => "the invoking user".to_string(),
6609 })
6610 }),
6611 running: active.trim() == "active",
6612 starts_automatically: enabled.trim() == "enabled",
6613 restart_delay: directives
6614 .get("RestartSec")
6615 .and_then(|value| value.trim().trim_end_matches('s').parse::<u64>().ok())
6616 .map(Duration::from_secs),
6617 }))
6618 }
6619
6620 fn start(&self, identity: &ServiceIdentity) -> Result<(), ServiceError> {
6621 let unit = identity.systemd_unit();
6622 let (ok, message) = self.systemctl(&["start", &unit]);
6623 if ok {
6624 Ok(())
6625 } else {
6626 Err(self.failed("start", identity.name(), message))
6627 }
6628 }
6629
6630 fn stop(&self, identity: &ServiceIdentity) -> Result<bool, ServiceError> {
6631 let running = self
6632 .query(identity)?
6633 .is_some_and(|registration| registration.running);
6634 if !running {
6635 return Ok(false);
6636 }
6637 let unit = identity.systemd_unit();
6638 let (ok, message) = self.systemctl(&["stop", &unit]);
6639 if ok {
6640 Ok(true)
6641 } else {
6642 Err(self.failed("stop", identity.name(), message))
6643 }
6644 }
6645 }
6646}
6647
6648#[cfg(test)]
6649mod tests {
6650 use super::*;
6651
6652 use std::collections::BTreeMap;
6653
6654 #[test]
6655 fn task_scheduler_omitting_default_enabled_still_means_automatic() {
6656 assert!(windows_login_task_starts_automatically(
6657 "<Task><Triggers><LogonTrigger></LogonTrigger></Triggers></Task>"
6658 ));
6659 assert!(windows_login_task_starts_automatically(
6660 "<Task><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Task>"
6661 ));
6662 assert!(!windows_login_task_starts_automatically(
6663 "<Task><LogonTrigger><Enabled>false</Enabled></LogonTrigger></Task>"
6664 ));
6665 assert!(!windows_login_task_starts_automatically(
6666 "<Task><BootTrigger></BootTrigger></Task>"
6667 ));
6668 }
6669
6670 fn linux_plan(mode: StartMode) -> InstallPlan {
6677 InstallPlan::unchecked(
6678 ServiceIdentity::product(),
6679 mode,
6680 "/opt/runner-manager/bin/runner-manager",
6681 ServiceDirectories {
6682 config: PathBuf::from("/var/lib/runner-manager/config"),
6683 state: PathBuf::from("/var/lib/runner-manager/state"),
6684 runtime: PathBuf::from("/var/lib/runner-manager/runtime"),
6685 logs: PathBuf::from("/var/log/runner-manager"),
6686 },
6687 )
6688 .with_secret_guard("/var/lib/runner-manager/secrets/user-access-token")
6689 }
6690
6691 fn windows_plan(mode: StartMode) -> InstallPlan {
6692 InstallPlan::unchecked(
6693 ServiceIdentity::product(),
6694 mode,
6695 "C:\\Program Files\\runner-manager\\runner-manager.exe",
6696 ServiceDirectories {
6697 config: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\config"),
6698 state: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\state"),
6699 runtime: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\runtime"),
6700 logs: PathBuf::from("C:\\Users\\op\\AppData\\Local\\rm\\logs"),
6701 },
6702 )
6703 }
6704
6705 fn edited(text: &str, from: &str, to: &str) -> String {
6713 assert!(
6714 text.contains(from),
6715 "the rendered definition does not contain `{from}`, so this test would assert \
6716 nothing about a widened one"
6717 );
6718 text.replace(from, to)
6719 }
6720
6721 fn snapshot(roots: &[&Path]) -> BTreeMap<PathBuf, Vec<u8>> {
6723 fn walk(directory: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
6724 let Ok(entries) = std::fs::read_dir(directory) else {
6725 return;
6726 };
6727 for entry in entries.flatten() {
6728 let path = entry.path();
6729 if path.is_dir() {
6730 walk(&path, out);
6731 } else if let Ok(bytes) = std::fs::read(&path) {
6732 out.insert(path, bytes);
6733 }
6734 }
6735 }
6736 let mut out = BTreeMap::new();
6737 for root in roots {
6738 walk(root, &mut out);
6739 }
6740 out
6741 }
6742
6743 struct Host {
6744 _root: tempfile::TempDir,
6745 paths: AppPaths,
6746 binary: PathBuf,
6747 runner_root: LocalAbsolutePath,
6756 controls: RecordingControls,
6757 }
6758
6759 impl Host {
6760 fn new() -> Self {
6761 let root = tempfile::tempdir().expect("a temporary directory");
6762 let paths = AppPaths::rooted_at(root.path());
6763 paths.create_all().expect("the four directories");
6764 let binary = root.path().join(if cfg!(windows) {
6765 "runner-manager.exe"
6766 } else {
6767 "runner-manager"
6768 });
6769 std::fs::write(&binary, b"not a real binary").expect("a stand-in binary");
6770 let runner_root = LocalAbsolutePath::new(
6771 root.path()
6772 .join("runner-root")
6773 .to_str()
6774 .expect("a unicode temporary path"),
6775 )
6776 .expect("a local absolute path");
6777 Self {
6778 _root: root,
6779 paths,
6780 binary,
6781 runner_root,
6782 controls: RecordingControls::new(),
6783 }
6784 }
6785
6786 fn operations(&self) -> ServiceOperations {
6787 ServiceOperations::with_controls(
6788 self.paths.clone(),
6789 ServiceIdentity::product(),
6790 std::sync::Arc::new(self.controls.clone()),
6791 )
6792 .with_runner_root(self.runner_root.clone())
6793 }
6794
6795 fn request(&self, mode: StartMode) -> InstallRequest {
6796 InstallRequest::new(mode).for_binary(&self.binary)
6797 }
6798 }
6799
6800 #[cfg(windows)]
6801 #[test]
6802 fn windows_uninstall_waits_through_the_marked_for_deletion_window() {
6803 let probes = std::cell::Cell::new(0);
6804 let absent =
6805 super::sys::wait_until_scm_absent(Duration::from_secs(1), Duration::ZERO, || {
6806 let next = probes.get() + 1;
6807 probes.set(next);
6808 Ok(next == 3)
6809 })
6810 .expect("the simulated SCM probe succeeds");
6811
6812 assert!(absent);
6813 assert_eq!(
6814 probes.get(),
6815 3,
6816 "uninstall must recheck after transient presence instead of treating it as a leak"
6817 );
6818 }
6819
6820 #[cfg(windows)]
6821 #[test]
6822 fn windows_recognises_an_already_pending_service_deletion() {
6823 let error = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(1072));
6824
6825 assert!(super::sys::is_marked_for_delete(&error));
6826 }
6827
6828 #[test]
6829 fn launchd_enable_failure_is_returned_and_removes_the_bootstrapped_registration() {
6830 let root = tempfile::tempdir().expect("a temporary directory");
6831 let plist = root.path().join("fixture.plist");
6832 std::fs::write(&plist, b"fixture").expect("a plist fixture");
6833 let calls = std::cell::RefCell::new(Vec::new());
6834
6835 let error = enable_launchd_registration(
6836 |arguments| {
6837 let call = arguments
6838 .iter()
6839 .map(|argument| argument.to_string_lossy().into_owned())
6840 .collect::<Vec<_>>();
6841 let operation = call[0].clone();
6842 calls.borrow_mut().push(call);
6843 if operation == "enable" {
6844 (false, "label remains disabled".to_string())
6845 } else {
6846 (true, String::new())
6847 }
6848 },
6849 "system",
6850 "system/com.openai.runner-manager-selftest",
6851 &plist,
6852 "runner-manager-selftest",
6853 "rerun with administrative rights",
6854 )
6855 .expect_err("enable failure must fail the install");
6856
6857 assert!(
6858 matches!(
6859 error,
6860 ServiceError::Control {
6861 operation: "enable",
6862 ..
6863 }
6864 ),
6865 "{error}"
6866 );
6867 assert_eq!(calls.borrow().len(), 2);
6868 assert_eq!(calls.borrow()[0][0], "enable");
6869 assert_eq!(calls.borrow()[1][0], "bootout");
6870 assert!(!plist.exists(), "rollback must remove the plist");
6871 }
6872
6873 #[test]
6878 fn a_path_with_spaces_survives_a_round_trip_through_a_command_line() {
6879 let plan = windows_plan(StartMode::Boot);
6880 let command_line = plan.command_line();
6881 assert!(
6882 command_line.starts_with('"'),
6883 "a path with a space must be quoted, got {command_line}"
6884 );
6885 assert_eq!(
6886 executable_from_command_line(&command_line).as_deref(),
6887 Some(plan.binary())
6888 );
6889 }
6890
6891 #[test]
6892 fn a_path_without_spaces_is_not_quoted_and_still_reads_back() {
6893 let plan = linux_plan(StartMode::Boot);
6894 let command_line = plan.command_line();
6895 assert!(!command_line.starts_with('"'), "got {command_line}");
6896 assert_eq!(
6897 executable_from_command_line(&command_line).as_deref(),
6898 Some(plan.binary())
6899 );
6900 }
6901
6902 #[test]
6903 fn a_quoted_path_containing_a_quote_reads_back_verbatim() {
6904 let awkward = r#"C:\odd "name"\rm.exe"#;
6907 let quoted = quote_argument(awkward);
6908 assert_eq!(
6909 executable_from_command_line(&format!("{quoted} daemon run"))
6910 .as_deref()
6911 .map(Path::to_string_lossy)
6912 .as_deref(),
6913 Some(awkward)
6914 );
6915 }
6916
6917 #[test]
6918 fn an_empty_command_line_has_no_executable() {
6919 assert_eq!(executable_from_command_line(" "), None);
6920 assert_eq!(executable_from_command_line(""), None);
6921 }
6922
6923 #[test]
6924 fn xml_escaping_round_trips_the_characters_a_path_or_an_account_may_hold() {
6925 let awkward = r#"DOMAIN\R&D <team> "ops""#;
6926 assert_eq!(
6927 xml_escape(awkward),
6928 "DOMAIN\\R&D <team> "ops""
6929 );
6930 assert_eq!(xml_unescape(&xml_escape(awkward)), awkward);
6931 }
6932
6933 #[test]
6938 fn a_restart_delay_under_the_floor_is_refused() {
6939 let error = RestartPolicy::new(Duration::from_millis(500), Duration::from_secs(60))
6940 .expect_err("half a second is under the one-second floor");
6941 assert!(
6942 matches!(error, ServiceError::RestartDelay { .. }),
6943 "{error}"
6944 );
6945 }
6946
6947 #[test]
6948 fn a_restart_delay_over_the_ceiling_is_refused() {
6949 let error = RestartPolicy::new(Duration::from_secs(3600), Duration::from_secs(7200))
6950 .expect_err("an hour is over the five-minute ceiling");
6951 assert!(
6952 matches!(error, ServiceError::RestartDelay { .. }),
6953 "{error}"
6954 );
6955 }
6956
6957 #[test]
6958 fn a_reset_window_no_longer_than_the_delay_is_refused() {
6959 let error = RestartPolicy::new(Duration::from_secs(15), Duration::from_secs(15))
6960 .expect_err("a window equal to the delay can never elapse between restarts");
6961 assert!(
6962 matches!(error, ServiceError::RestartResetWindow { .. }),
6963 "{error}"
6964 );
6965 }
6966
6967 #[test]
6968 fn a_delay_inside_the_bound_is_accepted() {
6969 let policy = RestartPolicy::new(Duration::from_secs(20), Duration::from_secs(300))
6970 .expect("twenty seconds is inside the bound");
6971 assert_eq!(policy.delay(), Duration::from_secs(20));
6972 assert_eq!(policy.reset_after(), Duration::from_secs(300));
6973 }
6974
6975 #[test]
6980 fn a_fixture_identity_can_never_be_the_product_identity() {
6981 let fixture = ServiceIdentity::fixture("abc123");
6982 assert!(fixture.is_fixture());
6983 assert!(!ServiceIdentity::product().is_fixture());
6984 assert_ne!(fixture.name(), ServiceIdentity::product().name());
6985 assert_ne!(
6986 fixture.launchd_label(),
6987 ServiceIdentity::product().launchd_label()
6988 );
6989 assert_ne!(
6990 fixture.systemd_unit(),
6991 ServiceIdentity::product().systemd_unit()
6992 );
6993 }
6994
6995 #[test]
6996 fn a_fixture_tag_is_reduced_to_characters_every_manager_accepts() {
6997 let fixture = ServiceIdentity::fixture("A b/c:\\d");
6998 assert!(
6999 fixture
7000 .name()
7001 .chars()
7002 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
7003 "got {}",
7004 fixture.name()
7005 );
7006 }
7007
7008 #[test]
7013 fn the_boot_unit_restarts_on_failure_after_the_bounded_delay() {
7014 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7015 assert!(unit.contains("KillMode=process\n"), "{unit}");
7016 assert!(unit.contains("Restart=on-failure\n"), "{unit}");
7017 assert!(unit.contains("RestartSec=15\n"), "{unit}");
7018 assert!(unit.contains("StartLimitIntervalSec=600\n"), "{unit}");
7019 assert!(unit.contains("StartLimitBurst=5\n"), "{unit}");
7020 assert!(unit.contains("WantedBy=multi-user.target\n"), "{unit}");
7021 }
7022
7023 #[test]
7024 fn the_boot_unit_reads_the_live_store_instead_of_a_frozen_systemd_copy() {
7025 let unit = systemd_unit(&linux_plan(StartMode::Boot));
7026 assert!(
7027 !unit.contains("LoadCredential="),
7028 "a startup snapshot would shadow every rotated credential until restart:\n{unit}"
7029 );
7030 }
7031
7032 #[test]
7033 fn a_login_unit_carries_no_machine_credential_and_wants_the_session_target() {
7034 let unit = systemd_unit(&linux_plan(StartMode::Login));
7035 assert!(
7036 !unit.contains("LoadCredential="),
7037 "a user unit must not name a root-owned credential file, got:\n{unit}"
7038 );
7039 assert!(unit.contains("WantedBy=default.target\n"), "{unit}");
7040 }
7041
7042 #[test]
7043 fn the_unit_can_atomically_replace_the_credential_and_write_only_required_directories() {
7044 let plan = linux_plan(StartMode::Boot);
7045 let unit = systemd_unit(&plan);
7046 let directives = ini_directives(&unit, "Service");
7047 let listed = split_quoted(
7048 directives
7049 .get("ReadWritePaths")
7050 .expect("the unit names its writable paths"),
7051 );
7052 assert_eq!(listed.len(), 5, "{listed:?}");
7053 for path in plan.directories().all() {
7054 assert!(
7055 listed.iter().any(|entry| entry == &path.to_string_lossy()),
7056 "{} is missing from {listed:?}",
7057 path.display()
7058 );
7059 }
7060 assert!(
7061 listed
7062 .iter()
7063 .any(|entry| entry == "/var/lib/runner-manager/secrets"),
7064 "atomic credential replacement needs its parent directory in {listed:?}"
7065 );
7066 }
7067
7068 #[test]
7069 fn the_unit_records_the_absolute_binary_path() {
7070 let plan = linux_plan(StartMode::Boot);
7071 let unit = systemd_unit(&plan);
7072 assert!(
7073 unit.contains("ExecStart=/opt/runner-manager/bin/runner-manager daemon run\n"),
7074 "{unit}"
7075 );
7076 }
7077
7078 #[test]
7083 fn the_daemon_restarts_only_after_an_unsuccessful_exit() {
7084 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7085 assert!(
7088 plist.contains(
7089 "<key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n"
7090 ),
7091 "{plist}"
7092 );
7093 assert!(
7094 plist.contains("<key>ThrottleInterval</key>\n <integer>15</integer>"),
7095 "{plist}"
7096 );
7097 }
7098
7099 #[test]
7100 fn a_launch_daemon_names_root_and_a_launch_agent_names_nobody() {
7101 let daemon = launchd_plist(&linux_plan(StartMode::Boot));
7102 assert_eq!(
7103 plist_string_value(&daemon, "UserName").as_deref(),
7104 Some("root")
7105 );
7106 assert_eq!(plist_bool_value(&daemon, "SessionCreate"), Some(false));
7107
7108 let agent = launchd_plist(&linux_plan(StartMode::Login));
7109 assert_eq!(
7110 plist_string_value(&agent, "UserName"),
7111 None,
7112 "a LaunchAgent already runs as the operator:\n{agent}"
7113 );
7114 }
7115
7116 #[test]
7117 fn the_plist_records_the_absolute_binary_path_and_the_daemon_arguments() {
7118 let plist = launchd_plist(&linux_plan(StartMode::Boot));
7119 assert!(
7120 plist.contains("<string>/opt/runner-manager/bin/runner-manager</string>"),
7121 "{plist}"
7122 );
7123 assert!(plist.contains("<string>daemon</string>"), "{plist}");
7124 assert!(plist.contains("<string>run</string>"), "{plist}");
7125 }
7126
7127 #[test]
7128 fn the_launchd_label_is_the_product_identity_in_reverse_domain_form() {
7129 assert_eq!(
7130 ServiceIdentity::product().launchd_label(),
7131 "io.github.IvanMurzak.runner-manager"
7132 );
7133 }
7134
7135 #[test]
7140 fn the_task_runs_at_least_privilege_on_a_logon_trigger() {
7141 let xml = windows_scheduled_task_xml(
7142 &windows_plan(StartMode::Login),
7143 &TaskPrincipal::named("HOST\\operator"),
7144 );
7145 assert!(xml.contains("<LogonTrigger>"), "{xml}");
7146 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"), "{xml}");
7147 assert!(
7148 xml.contains("<LogonType>InteractiveToken</LogonType>"),
7149 "{xml}"
7150 );
7151 assert!(xml.contains("<UserId>HOST\\operator</UserId>"), "{xml}");
7152 assert!(
7153 xml.contains("<Interval>PT1M</Interval>"),
7154 "Task Scheduler takes whole minutes only, and rejects the registration outright \
7155 for anything finer:\n{xml}"
7156 );
7157 }
7158
7159 #[test]
7160 fn task_schedulers_minute_granularity_only_ever_rounds_the_delay_up() {
7161 for (asked, enforced) in [(1u64, 60u64), (15, 60), (60, 60), (61, 120), (300, 300)] {
7166 let policy =
7167 RestartPolicy::new(Duration::from_secs(asked), Duration::from_secs(asked + 600))
7168 .expect("inside the supported range");
7169 assert_eq!(
7170 policy
7171 .effective_delay(DefinitionKind::WindowsScheduledTask)
7172 .as_secs(),
7173 enforced,
7174 "a {asked}s delay must be enforced as {enforced}s"
7175 );
7176 assert!(
7177 policy.effective_delay(DefinitionKind::WindowsScheduledTask) >= policy.delay(),
7178 "rounding must never shorten the bound"
7179 );
7180 }
7181 }
7182
7183 #[test]
7184 fn every_other_manager_enforces_the_delay_exactly_as_configured() {
7185 let policy = RestartPolicy::default();
7186 for kind in [
7187 DefinitionKind::WindowsService,
7188 DefinitionKind::LaunchdPlist,
7189 DefinitionKind::SystemdUnit,
7190 ] {
7191 assert_eq!(
7192 policy.effective_delay(kind),
7193 policy.delay(),
7194 "{kind:?} takes seconds and enforces exactly what it is given"
7195 );
7196 }
7197 }
7198
7199 #[test]
7200 fn a_task_whose_manager_reports_the_rounded_delay_is_not_a_fault() {
7201 let host = Host::new();
7202 let operations = host.operations();
7203 operations
7204 .install(&host.request(StartMode::Login))
7205 .expect("an install at login");
7206 host.controls.edit("runner-manager", |registration| {
7208 registration.manager = DefinitionKind::WindowsScheduledTask;
7209 registration.restart_delay = Some(Duration::from_secs(60));
7210 });
7211
7212 let status = operations.status().expect("a status");
7213 assert!(
7214 status.is_healthy(),
7215 "minute granularity is the manager's, not a mis-registration: {status}"
7216 );
7217 assert!(
7218 status
7219 .notes()
7220 .iter()
7221 .any(|note| note.contains("whole minutes")),
7222 "but the operator must be told why 15 became 60: {status}"
7223 );
7224
7225 host.controls.edit("runner-manager", |registration| {
7228 registration.restart_delay = Some(Duration::from_secs(1));
7229 });
7230 assert!(
7231 !operations.status().expect("a status").is_healthy(),
7232 "a one-second delay is not what any manager was asked for"
7233 );
7234 }
7235
7236 #[test]
7237 fn the_task_records_the_absolute_binary_path_and_the_daemon_arguments() {
7238 let plan = windows_plan(StartMode::Login);
7239 let xml = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\operator"));
7240 assert_eq!(
7241 xml_value(&xml, "Command").as_deref(),
7242 Some("C:\\Program Files\\runner-manager\\runner-manager.exe"),
7243 "{xml}"
7244 );
7245 assert_eq!(xml_value(&xml, "Arguments").as_deref(), Some("daemon run"));
7246 }
7247
7248 #[test]
7249 fn an_account_name_holding_xml_punctuation_is_escaped() {
7250 let xml = windows_scheduled_task_xml(
7251 &windows_plan(StartMode::Login),
7252 &TaskPrincipal::named("R&D\\ops"),
7253 );
7254 assert!(xml.contains("<UserId>R&D\\ops</UserId>"), "{xml}");
7255 assert_eq!(xml_value(&xml, "UserId").as_deref(), Some("R&D\\ops"));
7256 }
7257
7258 #[test]
7263 fn the_service_starts_automatically_under_the_account_the_store_admits() {
7264 let text = windows_service_descriptor(&windows_plan(StartMode::Boot));
7265 let directives = ini_directives(&text, "windows-service");
7266 assert_eq!(
7267 directives.get("StartType").map(String::as_str),
7268 Some("AutoStart")
7269 );
7270 assert_eq!(
7271 directives.get("Account").map(String::as_str),
7272 Some("NT AUTHORITY\\SYSTEM")
7273 );
7274 assert_eq!(
7275 directives.get("ServiceType").map(String::as_str),
7276 Some("OWN_PROCESS")
7277 );
7278 assert_eq!(
7279 directives
7280 .get("FailureActionRestartDelaySecs")
7281 .map(String::as_str),
7282 Some("15")
7283 );
7284 assert_eq!(
7285 directives
7286 .get("FailureActionsOnNonCrashFailures")
7287 .map(String::as_str),
7288 Some("true"),
7289 "without this flag a non-zero exit is not a failure the manager restarts"
7290 );
7291 }
7292
7293 #[test]
7294 fn the_service_spec_leaves_the_account_unnamed_so_the_api_means_local_system() {
7295 let spec = windows_service_spec(&windows_plan(StartMode::Boot));
7296 assert_eq!(spec.account, None);
7297 assert!(spec.automatic_start);
7298 assert!(
7299 spec.command_line.contains("daemon run"),
7300 "{}",
7301 spec.command_line
7302 );
7303 }
7304
7305 #[test]
7310 fn each_definition_goes_where_its_platform_expects_it() {
7311 let home = PathBuf::from("/home/op");
7312 assert_eq!(
7313 ServiceDefinition::launchd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
7314 Some(Path::new(
7315 "/Library/LaunchDaemons/io.github.IvanMurzak.runner-manager.plist"
7316 ))
7317 );
7318 assert_eq!(
7319 ServiceDefinition::launchd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
7320 Some(Path::new(
7321 "/home/op/Library/LaunchAgents/io.github.IvanMurzak.runner-manager.plist"
7322 ))
7323 );
7324 assert_eq!(
7325 ServiceDefinition::systemd(&linux_plan(StartMode::Boot), Some(&home)).install_path(),
7326 Some(Path::new("/etc/systemd/system/runner-manager.service"))
7327 );
7328 assert_eq!(
7329 ServiceDefinition::systemd(&linux_plan(StartMode::Login), Some(&home)).install_path(),
7330 Some(Path::new(
7331 "/home/op/.config/systemd/user/runner-manager.service"
7332 ))
7333 );
7334 assert_eq!(
7335 ServiceDefinition::windows_service(&windows_plan(StartMode::Boot)).install_path(),
7336 None,
7337 "the Service Control Manager has no file"
7338 );
7339 }
7340
7341 #[test]
7342 fn a_login_definition_without_a_home_directory_has_nowhere_to_go() {
7343 assert_eq!(
7344 ServiceDefinition::systemd(&linux_plan(StartMode::Login), None).install_path(),
7345 None
7346 );
7347 assert_eq!(
7348 ServiceDefinition::launchd(&linux_plan(StartMode::Login), None).install_path(),
7349 None
7350 );
7351 }
7352
7353 #[test]
7358 fn the_rendered_definitions_are_all_least_privilege() {
7359 let linux = linux_plan(StartMode::Boot);
7360 let windows = windows_plan(StartMode::Boot);
7361 for (definition, plan) in [
7362 (ServiceDefinition::systemd(&linux, None), &linux),
7363 (ServiceDefinition::launchd(&linux, None), &linux),
7364 (ServiceDefinition::windows_service(&windows), &windows),
7365 ] {
7366 let review = review_least_privilege(&definition, plan);
7367 assert!(
7368 review.is_least_privilege(),
7369 "{:?} should be least privilege, got:\n{review}",
7370 definition.kind()
7371 );
7372 assert!(
7373 !review.controls().is_empty(),
7374 "a review that confirms nothing proves nothing: {:?}",
7375 definition.kind()
7376 );
7377 }
7378 }
7379
7380 #[test]
7381 fn the_rendered_task_is_least_privilege_and_says_what_it_checked() {
7382 let plan = windows_plan(StartMode::Login);
7383 let definition =
7384 ServiceDefinition::windows_scheduled_task(&plan, &TaskPrincipal::named("HOST\\op"));
7385 let review = review_least_privilege(&definition, &plan);
7386 assert!(review.is_least_privilege(), "{review}");
7387 assert!(
7388 review
7389 .controls()
7390 .iter()
7391 .any(|control| control.contains("LeastPrivilege")),
7392 "{review}"
7393 );
7394 }
7395
7396 #[test]
7397 fn a_unit_that_makes_one_more_directory_writable_is_not_least_privilege() {
7398 let plan = linux_plan(StartMode::Boot);
7399 let rendered = systemd_unit(&plan);
7400 let widened = edited(
7401 &rendered,
7402 "ReadWritePaths=/var/lib/runner-manager/config",
7403 "ReadWritePaths=/etc /var/lib/runner-manager/config",
7404 );
7405 let review = review_least_privilege(
7406 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7407 &plan,
7408 );
7409 assert!(!review.is_least_privilege(), "{review}");
7410 assert!(
7411 review
7412 .excesses()
7413 .iter()
7414 .any(|finding| finding.detail.contains("/etc")),
7415 "the review must name the directory it objects to: {review}"
7416 );
7417 }
7418
7419 #[test]
7420 fn a_unit_that_drops_a_hardening_directive_is_not_least_privilege() {
7421 let plan = linux_plan(StartMode::Boot);
7422 let rendered = systemd_unit(&plan);
7423 let weakened = edited(&rendered, "NoNewPrivileges=yes\n", "");
7424 let review = review_least_privilege(
7425 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, weakened),
7426 &plan,
7427 );
7428 assert!(!review.is_least_privilege(), "{review}");
7429 assert!(
7430 review
7431 .excesses()
7432 .iter()
7433 .any(|finding| finding.subject == "NoNewPrivileges"),
7434 "{review}"
7435 );
7436 }
7437
7438 #[test]
7439 fn a_unit_that_keeps_capabilities_is_not_least_privilege() {
7440 let plan = linux_plan(StartMode::Boot);
7441 let rendered = systemd_unit(&plan);
7442 let widened = edited(
7443 &rendered,
7444 "CapabilityBoundingSet=\n",
7445 "CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN\n",
7446 );
7447 let review = review_least_privilege(
7448 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7449 &plan,
7450 );
7451 assert!(!review.is_least_privilege(), "{review}");
7452 assert!(
7453 review
7454 .excesses()
7455 .iter()
7456 .any(|finding| finding.subject == "CapabilityBoundingSet"),
7457 "{review}"
7458 );
7459 }
7460
7461 #[test]
7462 fn a_unit_that_opens_a_listening_socket_is_not_least_privilege() {
7463 let plan = linux_plan(StartMode::Boot);
7464 let rendered = systemd_unit(&plan);
7465 let widened = edited(
7466 &rendered,
7467 "[Install]",
7468 "ListenStream=127.0.0.1:9000\n\n[Install]",
7469 );
7470 let review = review_least_privilege(
7471 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, widened),
7472 &plan,
7473 );
7474 assert!(
7475 !review.is_least_privilege(),
7476 "07-security.md rule 2 forbids any inbound surface: {review}"
7477 );
7478 }
7479
7480 #[test]
7481 fn a_unit_that_makes_a_directory_unwritable_is_a_shortfall_not_an_excess() {
7482 let plan = linux_plan(StartMode::Boot);
7483 let rendered = systemd_unit(&plan);
7484 let narrowed = edited(&rendered, " /var/lib/runner-manager/runtime", "");
7485 let review = review_least_privilege(
7486 &ServiceDefinition::from_text(DefinitionKind::SystemdUnit, narrowed),
7487 &plan,
7488 );
7489 assert!(
7490 review.is_least_privilege(),
7491 "too little authority is not an excess: {review}"
7492 );
7493 assert!(
7494 review
7495 .findings()
7496 .iter()
7497 .any(|finding| finding.kind == FindingKind::Shortfall
7498 && finding.detail.contains("runtime")),
7499 "{review}"
7500 );
7501 }
7502
7503 #[test]
7504 fn a_launch_agent_that_names_an_account_is_not_least_privilege() {
7505 let plan = linux_plan(StartMode::Login);
7506 let rendered = launchd_plist(&plan);
7507 let widened = edited(
7508 &rendered,
7509 "<key>ProcessType</key>",
7510 "<key>UserName</key>\n <string>root</string>\n <key>ProcessType</key>",
7511 );
7512 let review = review_least_privilege(
7513 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7514 &plan,
7515 );
7516 assert!(!review.is_least_privilege(), "{review}");
7517 assert!(
7518 review
7519 .excesses()
7520 .iter()
7521 .any(|finding| finding.subject == "UserName"),
7522 "{review}"
7523 );
7524 }
7525
7526 #[test]
7527 fn a_launch_daemon_that_asks_for_a_session_is_not_least_privilege() {
7528 let plan = linux_plan(StartMode::Boot);
7529 let rendered = launchd_plist(&plan);
7530 let widened = edited(
7531 &rendered,
7532 "<key>SessionCreate</key>\n <false/>",
7533 "<key>SessionCreate</key>\n <true/>",
7534 );
7535 let review = review_least_privilege(
7536 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7537 &plan,
7538 );
7539 assert!(!review.is_least_privilege(), "{review}");
7540 assert!(
7541 review
7542 .excesses()
7543 .iter()
7544 .any(|finding| finding.subject == "SessionCreate"),
7545 "{review}"
7546 );
7547 }
7548
7549 #[test]
7550 fn a_launchd_job_that_publishes_a_mach_service_is_not_least_privilege() {
7551 let plan = linux_plan(StartMode::Boot);
7552 let rendered = launchd_plist(&plan);
7553 let widened = edited(
7554 &rendered,
7555 "<key>ProcessType</key>",
7556 "<key>MachServices</key>\n <dict/>\n <key>ProcessType</key>",
7557 );
7558 let review = review_least_privilege(
7559 &ServiceDefinition::from_text(DefinitionKind::LaunchdPlist, widened),
7560 &plan,
7561 );
7562 assert!(!review.is_least_privilege(), "{review}");
7563 }
7564
7565 #[test]
7566 fn a_task_asking_for_the_highest_available_token_is_not_least_privilege() {
7567 let plan = windows_plan(StartMode::Login);
7568 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7569 let widened = edited(
7570 &rendered,
7571 "<RunLevel>LeastPrivilege</RunLevel>",
7572 "<RunLevel>HighestAvailable</RunLevel>",
7573 );
7574 let review = review_least_privilege(
7575 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7576 &plan,
7577 );
7578 assert!(!review.is_least_privilege(), "{review}");
7579 assert!(
7580 review
7581 .excesses()
7582 .iter()
7583 .any(|finding| finding.subject == "RunLevel"),
7584 "{review}"
7585 );
7586 }
7587
7588 #[test]
7589 fn a_task_that_would_store_a_password_is_not_least_privilege() {
7590 let plan = windows_plan(StartMode::Login);
7591 let rendered = windows_scheduled_task_xml(&plan, &TaskPrincipal::named("HOST\\op"));
7592 let widened = edited(
7593 &rendered,
7594 "<LogonType>InteractiveToken</LogonType>",
7595 "<LogonType>Password</LogonType>",
7596 );
7597 let review = review_least_privilege(
7598 &ServiceDefinition::from_text(DefinitionKind::WindowsScheduledTask, widened),
7599 &plan,
7600 );
7601 assert!(!review.is_least_privilege(), "{review}");
7602 }
7603
7604 #[test]
7605 fn an_interactive_windows_service_is_not_least_privilege() {
7606 let plan = windows_plan(StartMode::Boot);
7607 let rendered = windows_service_descriptor(&plan);
7608 let widened = edited(
7609 &rendered,
7610 "ServiceType=OWN_PROCESS",
7611 "ServiceType=OWN_PROCESS|INTERACTIVE_PROCESS",
7612 );
7613 let review = review_least_privilege(
7614 &ServiceDefinition::from_text(DefinitionKind::WindowsService, widened),
7615 &plan,
7616 );
7617 assert!(!review.is_least_privilege(), "{review}");
7618 assert!(
7619 review
7620 .excesses()
7621 .iter()
7622 .any(|finding| finding.subject == "ServiceType"),
7623 "{review}"
7624 );
7625 }
7626
7627 #[test]
7628 fn a_windows_service_under_an_account_the_store_dacl_does_not_name_is_reported() {
7629 let plan = windows_plan(StartMode::Boot);
7630 let rendered = windows_service_descriptor(&plan);
7631 let changed = edited(
7632 &rendered,
7633 "Account=NT AUTHORITY\\SYSTEM",
7634 "Account=NT AUTHORITY\\LocalService",
7635 );
7636 let review = review_least_privilege(
7637 &ServiceDefinition::from_text(DefinitionKind::WindowsService, changed),
7638 &plan,
7639 );
7640 assert!(review.is_least_privilege(), "{review}");
7646 assert!(
7647 review.findings().iter().any(|finding| {
7648 finding.kind == FindingKind::Shortfall && finding.subject == "Account"
7649 }),
7650 "{review}"
7651 );
7652 }
7653
7654 #[test]
7659 fn a_recorded_path_that_is_still_there_is_current() {
7660 let host = Host::new();
7661 let state = inspect_binary(&host.binary, Some(&host.binary));
7662 assert!(!state.is_error(), "{state}");
7663 assert!(matches!(state, BinaryPath::Current { .. }), "{state}");
7664 }
7665
7666 #[test]
7667 fn the_npm_upgrade_case_reports_a_stale_path_as_an_error() {
7668 let host = Host::new();
7669 let recorded = host.binary.clone();
7673 let healthy = inspect_binary(&recorded, Some(&recorded));
7674 assert!(
7675 !healthy.is_error(),
7676 "the discriminator: before the binary moves, this must be healthy"
7677 );
7678
7679 std::fs::remove_file(&recorded).expect("the binary moves out from under the record");
7680
7681 let state = inspect_binary(&recorded, Some(&recorded));
7682 assert!(state.is_error(), "{state}");
7683 assert!(matches!(state, BinaryPath::Missing { .. }), "{state}");
7684 assert!(
7685 state.to_string().contains("npm"),
7686 "the message must name the cause an operator will not otherwise connect: {state}"
7687 );
7688 }
7689
7690 #[test]
7691 fn a_directory_at_the_recorded_path_is_not_something_the_manager_can_start() {
7692 let root = tempfile::tempdir().expect("a temporary directory");
7693 let state = inspect_binary(root.path(), None);
7694 assert!(state.is_error(), "{state}");
7695 assert!(matches!(state, BinaryPath::NotExecutable { .. }), "{state}");
7696 }
7697
7698 #[test]
7699 fn a_registration_naming_a_different_binary_is_a_divergence() {
7700 let host = Host::new();
7701 let other = host.binary.with_file_name("something-else");
7702 let state = inspect_binary(&host.binary, Some(&other));
7703 assert!(state.is_error(), "{state}");
7704 assert!(matches!(state, BinaryPath::Diverged { .. }), "{state}");
7705 }
7706
7707 #[test]
7708 fn absence_is_reported_before_divergence() {
7709 let host = Host::new();
7710 let recorded = host.binary.clone();
7711 std::fs::remove_file(&recorded).expect("removable");
7712 let other = recorded.with_file_name("something-else");
7713 assert!(matches!(
7717 inspect_binary(&recorded, Some(&other)),
7718 BinaryPath::Missing { .. }
7719 ));
7720 }
7721
7722 #[test]
7727 fn the_record_round_trips_through_toml() {
7728 let host = Host::new();
7729 let plan = InstallPlan::resolve(
7730 ServiceIdentity::product(),
7731 &host.request(StartMode::Boot),
7732 ServiceDirectories::of(&host.paths),
7733 )
7734 .expect("a resolvable plan");
7735 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7736 let record = InstallRecord::of(&plan, &definition, Utc::now());
7737 record.write(&host.paths).expect("a writable record");
7738 let read = InstallRecord::read(&host.paths)
7739 .expect("a readable record")
7740 .expect("a record is there");
7741 assert_eq!(read, record);
7742 assert_eq!(read.binary, host.binary);
7743 assert!(read.binary.is_absolute());
7744 }
7745
7746 #[test]
7747 fn a_unit_written_by_an_older_build_is_reported_and_a_current_one_is_not() {
7748 let host = Host::new();
7749 let identity = ServiceIdentity::product();
7750 let plan = InstallPlan::resolve(
7751 identity.clone(),
7752 &host.request(StartMode::Boot),
7753 ServiceDirectories::of(&host.paths),
7754 )
7755 .expect("a resolvable plan");
7756 let installed = systemd_unit(&plan);
7757 let definition =
7758 ServiceDefinition::from_text(DefinitionKind::SystemdUnit, installed.clone());
7759 let mut record = InstallRecord::of(&plan, &definition, Utc::now());
7760 let path = PathBuf::from("/etc/systemd/system/runner-manager.service");
7761
7762 assert_eq!(
7763 systemd_definition_drift(&identity, &record, path.clone(), &installed),
7764 None,
7765 "the record must rebuild exactly the unit `service install` wrote"
7766 );
7767
7768 record.installed_by_version = "0.4.7".into();
7769 let frozen = installed.replace(
7770 "[Service]\n",
7771 "[Service]\nLoadCredential=runner-manager.user-access-token:/var/lib/runner-manager/secrets/user-access-token\n",
7772 );
7773 let drift = systemd_definition_drift(&identity, &record, path, &frozen)
7774 .expect("an outdated unit is drift");
7775 assert_eq!(drift.installed_by_version, "0.4.7");
7776 assert_eq!(drift.rendered, installed);
7777
7778 let status = ServiceStatus::compose(identity, None, None, None, None, &host.paths)
7779 .with_definition_drift(Some(drift));
7780 assert!(!status.is_healthy());
7781 assert!(
7782 status
7783 .problems()
7784 .iter()
7785 .any(|problem| problem.subject == "definition" && problem.detail.contains("0.4.7")),
7786 "{status}"
7787 );
7788 }
7789
7790 #[test]
7791 fn a_rejected_credential_keeps_its_first_moment_and_makes_the_service_unhealthy() {
7792 let host = Host::new();
7793 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
7794 let first = Utc::now() - chrono::Duration::hours(9);
7795 record_github_credential_rejected(&host.paths, first).unwrap();
7796 record_github_credential_rejected(&host.paths, Utc::now()).unwrap();
7797 let since = github_credential_rejected_since(&host.paths).unwrap();
7798 assert_eq!(since, Some(first));
7799
7800 let status = ServiceStatus::compose(
7801 ServiceIdentity::product(),
7802 None,
7803 None,
7804 None,
7805 None,
7806 &host.paths,
7807 )
7808 .with_credential_rejection(since);
7809 assert!(!status.is_healthy());
7810 assert_eq!(status.credential_rejected_since(), Some(first));
7811 assert!(status.to_string().contains("auth login"), "{status}");
7812
7813 clear_github_credential_rejection(&host.paths).unwrap();
7814 clear_github_credential_rejection(&host.paths).unwrap();
7815 assert_eq!(github_credential_rejected_since(&host.paths).unwrap(), None);
7816 }
7817
7818 #[cfg(unix)]
7827 #[test]
7828 fn the_record_is_not_written_readable_only_by_whoever_installed_it() {
7829 use std::os::unix::fs::PermissionsExt as _;
7830
7831 let host = Host::new();
7832 let plan = InstallPlan::resolve(
7833 ServiceIdentity::product(),
7834 &host.request(StartMode::Boot),
7835 ServiceDirectories::of(&host.paths),
7836 )
7837 .expect("a resolvable plan");
7838 let definition = ServiceDefinition::from_text(DefinitionKind::SystemdUnit, "[Service]\n");
7839 InstallRecord::of(&plan, &definition, Utc::now())
7840 .write(&host.paths)
7841 .expect("a writable record");
7842
7843 let mode = std::fs::metadata(InstallRecord::path(&host.paths))
7844 .expect("the record is there")
7845 .permissions()
7846 .mode()
7847 & 0o777;
7848 assert_eq!(
7849 mode, 0o644,
7850 "the record is mode {mode:04o}; at 0600 an operator cannot read a record `sudo \
7851 service install` wrote, and `service status` fails on their own host. It holds no \
7852 credential and sits in a 0700 directory, so 0644 discloses nothing"
7853 );
7854 }
7855
7856 #[test]
7860 fn a_record_without_a_source_binary_still_reads_and_says_it_has_none() {
7861 let host = Host::new();
7862 let path = InstallRecord::path(&host.paths);
7863 std::fs::write(
7864 &path,
7865 format!(
7866 "schema_version = {RECORD_SCHEMA_VERSION}
7867service_name = \"runner-manager\"
7868 manager = \"systemd\"
7869start_mode = \"boot\"
7870account = \"root\"
7871 binary = \"/x\"
7872arguments = []
7873restart_delay_secs = 15
7874 restart_reset_secs = 600
7875log_file = \"/x\"
7876 installed_at = \"2026-01-01T00:00:00Z\"
7877installed_by_version = \"0.1.0\"
7878 [directories]
7879config = \"/a\"
7880state = \"/b\"
7881runtime = \"/c\"
7882logs = \"/d\"
7883"
7884 ),
7885 )
7886 .expect("a writable record");
7887 let read = InstallRecord::read(&host.paths)
7888 .expect("a record missing an optional field is still readable")
7889 .expect("a record is there");
7890 assert_eq!(
7891 read.source_binary, None,
7892 "the legacy layout has no source, and must not invent one"
7893 );
7894 }
7895
7896 #[test]
7898 fn a_registration_remembers_the_file_it_was_copied_from() {
7899 let host = Host::new();
7900 let source = host.binary.with_file_name("npm-installed-runner-manager");
7901 std::fs::copy(&host.binary, &source).expect("a second file to stand in for the package");
7902 let plan = InstallPlan::resolve(
7903 ServiceIdentity::product(),
7904 &host.request(StartMode::Boot).copied_from(&source),
7905 ServiceDirectories::of(&host.paths),
7906 )
7907 .expect("a resolvable plan");
7908 let definition = ServiceDefinition::from_text(
7909 DefinitionKind::SystemdUnit,
7910 "[Service]
7911",
7912 );
7913 let record = InstallRecord::of(&plan, &definition, Utc::now());
7914 record.write(&host.paths).expect("a writable record");
7915
7916 let read = InstallRecord::read(&host.paths)
7917 .expect("a readable record")
7918 .expect("a record is there");
7919 assert_eq!(read.source_binary.as_deref(), Some(source.as_path()));
7920 assert_ne!(
7921 read.source_binary.as_deref(),
7922 Some(read.binary.as_path()),
7923 "the whole point is that the two are different files: one the service holds open, one the package manager is free to replace"
7924 );
7925 }
7926
7927 #[test]
7928 fn a_record_from_a_schema_this_build_cannot_read_is_refused_with_a_remedy() {
7929 let host = Host::new();
7930 let path = InstallRecord::path(&host.paths);
7931 std::fs::write(
7932 &path,
7933 format!(
7934 "schema_version = {}\nservice_name = \"runner-manager\"\nmanager = \"systemd\"\n\
7935 start_mode = \"boot\"\naccount = \"root\"\nbinary = \"/x\"\narguments = []\n\
7936 restart_delay_secs = 15\nrestart_reset_secs = 600\nlog_file = \"/x\"\n\
7937 installed_at = \"2026-01-01T00:00:00Z\"\ninstalled_by_version = \"0.1.0\"\n\
7938 [directories]\nconfig = \"/a\"\nstate = \"/b\"\nruntime = \"/c\"\nlogs = \"/d\"\n",
7939 RECORD_SCHEMA_VERSION + 1
7940 ),
7941 )
7942 .expect("a writable record");
7943 let error = InstallRecord::read(&host.paths).expect_err("a future schema is refused");
7944 assert!(
7945 matches!(error, ServiceError::RecordUnreadable { .. }),
7946 "{error}"
7947 );
7948 assert!(
7949 error.to_string().contains("service uninstall"),
7950 "the message must say how to recover: {error}"
7951 );
7952 }
7953
7954 #[test]
7955 fn no_record_is_not_an_error() {
7956 let host = Host::new();
7957 assert_eq!(InstallRecord::read(&host.paths).expect("no record"), None);
7958 assert!(!InstallRecord::remove(&host.paths).expect("nothing to remove"));
7959 }
7960
7961 #[test]
7966 fn no_heartbeat_reads_as_never_rather_than_as_the_epoch() {
7967 let host = Host::new();
7968 assert_eq!(last_github_contact(&host.paths).expect("readable"), None);
7969 }
7970
7971 #[test]
7972 fn the_heartbeat_round_trips_to_the_second() {
7973 let host = Host::new();
7974 let at = DateTime::parse_from_rfc3339("2026-08-22T10:11:12Z")
7975 .expect("a valid timestamp")
7976 .with_timezone(&Utc);
7977 record_github_contact(&host.paths, at).expect("a writable heartbeat");
7978 assert_eq!(
7979 last_github_contact(&host.paths).expect("readable"),
7980 Some(at)
7981 );
7982 }
7983
7984 #[test]
7985 fn a_malformed_heartbeat_is_an_error_and_not_silently_never() {
7986 let host = Host::new();
7987 std::fs::write(contact_path(&host.paths), b"this is not toml \x00").expect("writable");
7988 let error = last_github_contact(&host.paths)
7989 .expect_err("a heartbeat that cannot be parsed is not the same as no heartbeat");
7990 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
7991 }
7992
7993 #[test]
7999 fn a_runner_root_refusal_round_trips_for_service_status() {
8000 let host = Host::new();
8001 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8002
8003 assert!(
8004 runner_root_refusals(&host.paths)
8005 .expect("readable")
8006 .is_empty(),
8007 "no record means every policy is placing runners"
8008 );
8009
8010 record_runner_root_refusal(
8011 &host.paths,
8012 "policy-a",
8013 at,
8014 "denied_by_privacy_policy",
8015 "/Volumes/NVME/runners",
8016 "the runner root /Volumes/NVME/runners cannot be used: ... Grant Full Disk Access",
8017 )
8018 .expect("a writable record");
8019
8020 let refusals = runner_root_refusals(&host.paths).expect("readable");
8021 assert_eq!(refusals.len(), 1);
8022 assert_eq!(refusals[0].policy, "policy-a");
8023 assert_eq!(refusals[0].at, at);
8024 assert_eq!(refusals[0].kind, "denied_by_privacy_policy");
8025 assert_eq!(refusals[0].root, "/Volumes/NVME/runners");
8026 assert!(
8027 refusals[0].detail.contains("/Volumes/NVME/runners")
8028 && refusals[0].detail.contains("Full Disk Access"),
8029 "the path and the remediation are the whole point of this file: {refusals:?}"
8030 );
8031 }
8032
8033 #[test]
8041 fn one_policy_placing_a_runner_does_not_clear_another_policys_refusal() {
8042 let host = Host::new();
8043 let at = DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant");
8044 record_runner_root_refusal(
8045 &host.paths,
8046 "broken",
8047 at,
8048 "denied_by_privacy_policy",
8049 "/Volumes/NVME/runners",
8050 "detail",
8051 )
8052 .expect("a writable record");
8053 record_runner_root_refusal(
8054 &host.paths,
8055 "also-broken",
8056 at,
8057 "not_writable",
8058 "/srv/other",
8059 "detail",
8060 )
8061 .expect("a writable record");
8062
8063 clear_runner_root_refusal(&host.paths, "healthy")
8065 .expect("clearing an absent policy is fine");
8066 clear_runner_root_refusal(&host.paths, "also-broken").expect("that policy recovered");
8067
8068 let refusals = runner_root_refusals(&host.paths).expect("readable");
8069 assert_eq!(
8070 refusals
8071 .iter()
8072 .map(|r| r.policy.as_str())
8073 .collect::<Vec<_>>(),
8074 vec!["broken"],
8075 "the policy that is still refused must keep its record"
8076 );
8077 }
8078
8079 #[test]
8082 fn clearing_the_last_refusal_removes_the_file_and_is_idempotent() {
8083 let host = Host::new();
8084 record_runner_root_refusal(
8085 &host.paths,
8086 "p",
8087 Utc::now(),
8088 "not_writable",
8089 "/srv/x",
8090 "detail",
8091 )
8092 .expect("a writable record");
8093
8094 clear_runner_root_refusal(&host.paths, "p").expect("the record is removed");
8095 assert!(
8096 !root_refusal_path(&host.paths).exists(),
8097 "a host with nothing refused leaves nothing behind"
8098 );
8099 clear_runner_root_refusal(&host.paths, "p").expect("removing what is gone is not an error");
8100 }
8101
8102 #[test]
8105 fn a_malformed_refusal_is_an_error_and_not_silently_none() {
8106 let host = Host::new();
8107 std::fs::write(root_refusal_path(&host.paths), b"not toml \x00").expect("writable");
8108 let error = runner_root_refusals(&host.paths)
8109 .expect_err("an unparseable record is not the same as no record");
8110 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8111 }
8112
8113 #[test]
8123 fn service_status_reports_a_refusal_as_a_note_and_stays_healthy() {
8124 let host = Host::new();
8125 record_runner_root_refusal(
8126 &host.paths,
8127 "policy-a",
8128 DateTime::from_timestamp(1_760_000_000, 0).expect("a valid instant"),
8129 "denied_by_privacy_policy",
8130 "/Volumes/NVME/runners",
8131 "Grant Full Disk Access to the program that runs the service",
8132 )
8133 .expect("a writable record");
8134
8135 let status = host.operations().status().expect("a readable status");
8136 let notes = status.notes().join("\n");
8137
8138 assert!(
8139 notes.contains("/Volumes/NVME/runners") && notes.contains("Full Disk Access"),
8140 "the directory and the remediation the log had to scrub must appear here: {notes}"
8141 );
8142 assert!(
8143 notes.contains("policy-a"),
8144 "the operator has to know which target placed no runner: {notes}"
8145 );
8146 assert!(
8147 !status
8148 .problems()
8149 .iter()
8150 .any(|problem| problem.subject == "runner root"),
8151 "a record nothing an operator types can clear must not drive the exit code"
8152 );
8153 }
8154
8155 #[test]
8161 fn install_records_the_absolute_binary_path_and_the_four_directories() {
8162 let host = Host::new();
8163 let installed = host
8164 .operations()
8165 .install(&host.request(StartMode::Boot))
8166 .expect("an install against the recording controls");
8167
8168 assert_eq!(installed.record.binary, host.binary);
8169 assert!(installed.record.binary.is_absolute());
8170 assert_eq!(installed.record.start_mode, StartMode::Boot);
8171 assert_eq!(installed.record.arguments, vec!["daemon", "run"]);
8172 assert_eq!(
8173 installed.record.directories,
8174 ServiceDirectories::of(&host.paths)
8175 );
8176 assert_eq!(
8177 installed.record.log_file,
8178 host.paths.logs_dir().join(LOG_FILE_STEM)
8179 );
8180 assert_eq!(installed.record.restart_delay_secs, 15);
8181
8182 let registrations = host.controls.registrations();
8183 assert_eq!(registrations.len(), 1);
8184 assert_eq!(registrations[0].0, StartMode::Boot);
8185 assert_eq!(registrations[0].1, "runner-manager");
8186 }
8187
8188 #[test]
8189 fn install_is_refused_while_the_single_instance_lock_is_held() {
8190 let host = Host::new();
8191 {
8193 let operations = host.operations();
8194 operations
8195 .install(&host.request(StartMode::Boot))
8196 .expect("an install with the lock free");
8197 operations.uninstall().expect("a clean slate");
8198 }
8199
8200 let _held = HostLock::try_acquire(&host.paths, LockKind::SingleInstance)
8201 .expect("this process takes the lock first");
8202
8203 let error = host
8204 .operations()
8205 .install(&host.request(StartMode::Boot))
8206 .expect_err("a second agent must not be registered while one is running");
8207 assert!(matches!(error, ServiceError::LockHeld { .. }), "{error}");
8208 assert!(
8209 error.to_string().contains("already running"),
8210 "the message must be actionable: {error}"
8211 );
8212 assert!(
8213 host.controls.registrations().is_empty(),
8214 "a refused install must register nothing"
8215 );
8216 assert_eq!(
8217 InstallRecord::read(&host.paths).expect("readable"),
8218 None,
8219 "a refused install must write no record"
8220 );
8221 }
8222
8223 #[test]
8231 fn installing_over_the_same_start_mode_replaces_the_registration() {
8232 let host = Host::new();
8233 let operations = host.operations();
8234 operations
8235 .install(&host.request(StartMode::Boot))
8236 .expect("the first install");
8237
8238 let again = operations
8239 .install(&host.request(StartMode::Boot))
8240 .expect("an install over the same mode replaces rather than refusing");
8241
8242 assert!(
8243 again.replaced_existing,
8244 "the operator is told this replaced something rather than made it"
8245 );
8246 assert_eq!(
8247 host.controls.registrations().len(),
8248 1,
8249 "replacing must not leave two registrations behind"
8250 );
8251 assert_eq!(
8252 InstallRecord::read(&host.paths)
8253 .expect("readable")
8254 .expect("a record")
8255 .start_mode,
8256 StartMode::Boot
8257 );
8258 }
8259
8260 #[test]
8264 fn installing_over_the_other_start_mode_is_refused() {
8265 let host = Host::new();
8266 let operations = host.operations();
8267 operations
8268 .install(&host.request(StartMode::Boot))
8269 .expect("the first install");
8270
8271 let error = operations
8272 .install(&host.request(StartMode::Login))
8273 .expect_err("a mode change is not an install");
8274 assert!(
8275 matches!(
8276 error,
8277 ServiceError::AlreadyInstalled {
8278 existing: StartMode::Boot,
8279 requested: StartMode::Login,
8280 ..
8281 }
8282 ),
8283 "{error}"
8284 );
8285 assert!(
8286 !error
8287 .to_string()
8288 .contains("switch the start mode in place,"),
8289 "the old remedy named a capability no command offers; the terminal UI is where \
8290 the start mode moves: {error}"
8291 );
8292 assert_eq!(host.controls.registrations().len(), 1);
8293 }
8294
8295 #[test]
8296 fn install_rolls_back_the_registration_when_record_persistence_fails() {
8297 let host = Host::new();
8298 let record_path = InstallRecord::path(&host.paths);
8299 std::fs::create_dir(&record_path).expect("a directory blocks the record file");
8300
8301 let error = host
8302 .operations()
8303 .install(&host.request(StartMode::Boot))
8304 .expect_err("record persistence must fail");
8305
8306 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8307 assert!(
8308 host.controls.registrations().is_empty(),
8309 "a failed install must not leave a live unrecorded registration"
8310 );
8311 assert!(
8312 host.controls
8313 .calls()
8314 .iter()
8315 .any(|call| call == "uninstall runner-manager (boot)"),
8316 "the registration must be explicitly rolled back: {:?}",
8317 host.controls.calls()
8318 );
8319 assert!(
8320 !host.runner_root.as_path().exists(),
8321 "the rollback must take the runner root this install created with it; a directory \
8322 prepared for a registration that does not exist is litter, and on Windows it is \
8323 litter with a security descriptor"
8324 );
8325 }
8326
8327 #[test]
8332 fn the_runner_root_a_boot_registration_needs_admits_only_the_service() {
8333 use crate::runner_root_access::{RootAdmission, default_root_sddl, grants_broad_write};
8334
8335 assert_eq!(
8339 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
8340 ServiceAccount::LocalSystem
8341 );
8342 assert_eq!(
8343 ServiceAccount::for_definition(DefinitionKind::WindowsScheduledTask, StartMode::Login),
8344 ServiceAccount::InvokingUser
8345 );
8346
8347 let boot = default_root_sddl(&RootAdmission::LocalSystem);
8348 assert!(!grants_broad_write(&boot), "{boot}");
8349 assert!(
8350 !boot.contains("S-1-5-21"),
8351 "a boot registration runs as LocalSystem, so its root names no operator: {boot}"
8352 );
8353
8354 let login = default_root_sddl(&RootAdmission::Account("S-1-5-21-1-2-3-1001".to_owned()));
8359 assert!(login.contains("S-1-5-21-1-2-3-1001"), "{login}");
8360 assert!(!grants_broad_write(&login), "{login}");
8361 }
8362
8363 #[test]
8364 fn an_install_reports_the_runner_root_it_prepared() {
8365 let host = Host::new();
8366 let installed = host
8367 .operations()
8368 .install(&host.request(StartMode::Boot))
8369 .expect("an install");
8370 let rendered = installed.runner_root.to_string();
8371 assert!(
8372 !rendered.contains("S-1-5-21"),
8373 "the report must add no identity to the output: {rendered}"
8374 );
8375 if cfg!(windows) {
8376 assert_eq!(
8377 installed.runner_root.path(),
8378 Some(host.runner_root.as_path())
8379 );
8380 assert!(
8381 host.runner_root.as_path().is_dir(),
8382 "the directory jobs would run in has to exist once the service is registered"
8383 );
8384 } else {
8385 assert_eq!(
8386 installed.runner_root,
8387 crate::runner_root_access::RootAccessSummary::NotApplicable,
8388 "macOS and Linux keep the runtime directory they have always used"
8389 );
8390 }
8391 }
8392
8393 #[test]
8394 fn switching_start_mode_reconciles_the_runner_root_for_the_new_account() {
8395 let host = Host::new();
8396 let operations = host.operations();
8397 operations
8398 .install(&host.request(StartMode::Boot))
8399 .expect("an install at boot");
8400
8401 let change = operations
8402 .set_start_mode(StartMode::Login)
8403 .expect("a switch to login");
8404
8405 assert!(change.changed);
8406 if cfg!(windows) {
8407 assert_eq!(change.runner_root.path(), Some(host.runner_root.as_path()));
8408 assert!(
8409 host.runner_root.as_path().is_dir(),
8410 "the switch must not remove the directory it reconciled"
8411 );
8412 }
8413 assert!(
8416 change.to_string().contains("runner root"),
8417 "{}",
8418 change.to_string()
8419 );
8420 }
8421
8422 #[test]
8423 fn switching_to_the_mode_already_in_force_touches_no_runner_root() {
8424 let host = Host::new();
8425 let operations = host.operations();
8426 operations
8427 .install(&host.request(StartMode::Boot))
8428 .expect("an install at boot");
8429
8430 let change = operations
8431 .set_start_mode(StartMode::Boot)
8432 .expect("a switch to the mode already in force");
8433
8434 assert!(!change.changed);
8435 assert_eq!(
8436 change.runner_root,
8437 crate::runner_root_access::RootAccessSummary::NotApplicable,
8438 "nothing moves, so nothing about the root's access control has to; reconciling here \
8439 would turn a no-op command into one that can fail on a permission it does not need"
8440 );
8441 }
8442
8443 #[cfg(windows)]
8444 #[test]
8445 fn a_registration_the_manager_refuses_leaves_no_runner_root_behind() {
8446 let host = Host::new();
8447 host.controls
8448 .fail_next_install(StartMode::Boot, "injected registration failure");
8449
8450 let error = host
8451 .operations()
8452 .install(&host.request(StartMode::Boot))
8453 .expect_err("the manager refuses the registration");
8454
8455 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8456 assert!(
8457 !host.runner_root.as_path().exists(),
8458 "the directory was created for a registration that does not exist"
8459 );
8460 }
8461
8462 #[cfg(windows)]
8463 #[test]
8464 fn an_existing_broad_runner_root_refuses_the_install_before_anything_is_registered() {
8465 let host = Host::new();
8466 crate::runner_root_access::create_with_descriptor_for_tests(
8470 host.runner_root.as_path(),
8471 "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;WD)",
8472 )
8473 .expect("a deliberately open runner root");
8474 let before = crate::runner_root_access::report(host.runner_root.as_path());
8475
8476 let error = host
8477 .operations()
8478 .install(&host.request(StartMode::Boot))
8479 .expect_err("an open runner root is refused");
8480
8481 assert!(matches!(error, ServiceError::RunnerRoot { .. }), "{error}");
8482 assert!(
8483 error.to_string().contains("nothing was registered"),
8484 "{error}"
8485 );
8486 assert!(
8487 host.controls.registrations().is_empty(),
8488 "the refusal has to come before the platform is asked to register anything: {:?}",
8489 host.controls.calls()
8490 );
8491 assert_eq!(
8492 crate::runner_root_access::report(host.runner_root.as_path()),
8493 before,
8494 "an open directory is refused rather than tightened: its contents cannot be trusted, \
8495 so adopting it would be worse than declining it"
8496 );
8497 }
8498
8499 #[cfg(windows)]
8500 #[test]
8501 fn uninstall_leaves_the_runner_root_exactly_where_it_is() {
8502 let host = Host::new();
8503 let operations = host.operations();
8504 operations
8505 .install(&host.request(StartMode::Boot))
8506 .expect("an install");
8507 assert!(host.runner_root.as_path().is_dir());
8508
8509 operations.uninstall().expect("an uninstall");
8510
8511 assert!(
8512 host.runner_root.as_path().is_dir(),
8513 "`05-infrastructure.md` item 5: uninstall deregisters and deletes nothing else. A \
8514 runner root may hold an operator's retained workspaces."
8515 );
8516 }
8517
8518 #[test]
8519 fn install_reviews_what_it_registered() {
8520 let host = Host::new();
8521 let installed = host
8522 .operations()
8523 .install(&host.request(StartMode::Boot))
8524 .expect("an install");
8525 assert!(
8526 installed.review.is_least_privilege(),
8527 "{}",
8528 installed.review
8529 );
8530 assert!(
8531 !installed.review.controls().is_empty(),
8532 "a review that confirms nothing proves nothing: {}",
8533 installed.review
8534 );
8535 assert_eq!(
8536 installed.review.kind(),
8537 host_definition_kind(StartMode::Boot),
8538 "the review must be of the definition this host's manager was given"
8539 );
8540 assert!(
8541 !installed.review.account().justification().is_empty(),
8542 "a privileged account with no stated reason is an unreviewed one"
8543 );
8544 }
8545
8546 #[test]
8551 fn uninstall_leaves_configuration_sqlite_secrets_and_cache_exactly_as_they_were() {
8552 let host = Host::new();
8553 let operations = host.operations();
8554 operations
8555 .install(&host.request(StartMode::Boot))
8556 .expect("an install");
8557
8558 let config = host.paths.config_dir();
8562 std::fs::write(config.join("runner-manager.db"), b"sqlite fixture").expect("writable");
8563 std::fs::write(config.join("config.toml"), b"host_capacity = 2").expect("writable");
8564 std::fs::create_dir_all(host.paths.state_dir().join("packages/2.330.0")).expect("writable");
8565 std::fs::write(
8566 host.paths
8567 .state_dir()
8568 .join("packages/2.330.0/runner.tar.gz"),
8569 b"cached package",
8570 )
8571 .expect("writable");
8572 std::fs::create_dir_all(host.paths.state_dir().join("secrets")).expect("writable");
8573 std::fs::write(
8574 host.paths.state_dir().join("secrets/user-access-token"),
8575 b"a stand-in for the stored credential",
8576 )
8577 .expect("writable");
8578 std::fs::write(
8579 host.paths.logs_dir().join("runner-manager.log.2026-08-22"),
8580 b"diagnostics",
8581 )
8582 .expect("writable");
8583
8584 let roots: Vec<PathBuf> = host
8585 .paths
8586 .all()
8587 .iter()
8588 .map(|(_, path)| (*path).to_path_buf())
8589 .collect();
8590 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
8591 let before = snapshot(&roots);
8592
8593 assert!(
8596 before.len() >= 6,
8597 "the fixture must actually contain the files this test is about, got {before:#?}"
8598 );
8599 let record_path = InstallRecord::path(&host.paths);
8600 assert!(
8601 before.contains_key(&record_path),
8602 "the install record must be present before uninstall"
8603 );
8604
8605 let uninstalled = operations.uninstall().expect("an uninstall");
8606 assert!(uninstalled.removed_registration);
8607 assert!(uninstalled.removed_record);
8608
8609 let after = snapshot(&roots);
8610
8611 let mut expected = before.clone();
8613 expected.remove(&record_path);
8614 assert_eq!(
8615 after, expected,
8616 "uninstall must remove its own record and nothing else"
8617 );
8618 assert!(
8619 !record_path.exists(),
8620 "the record itself must go, or `uninstall` did nothing at all"
8621 );
8622 assert!(
8623 uninstalled
8624 .preserved
8625 .iter()
8626 .all(|path| roots.contains(&path.as_path())),
8627 "the preserved list must name the four directories: {uninstalled}"
8628 );
8629 }
8630
8631 #[test]
8632 fn uninstall_on_a_host_with_no_registration_is_not_a_failure() {
8633 let host = Host::new();
8634 let uninstalled = host.operations().uninstall().expect("a no-op uninstall");
8635 assert!(!uninstalled.removed_registration);
8636 assert!(!uninstalled.removed_record);
8637 }
8638
8639 #[test]
8640 fn uninstall_removes_a_registration_even_when_the_record_is_gone() {
8641 let host = Host::new();
8642 let operations = host.operations();
8643 operations
8644 .install(&host.request(StartMode::Boot))
8645 .expect("an install");
8646 std::fs::remove_file(InstallRecord::path(&host.paths)).expect("the record is lost");
8647
8648 let uninstalled = operations.uninstall().expect("an uninstall");
8649 assert!(
8650 uninstalled.removed_registration,
8651 "a lost record must not strand a registration"
8652 );
8653 assert!(host.controls.registrations().is_empty());
8654 }
8655
8656 #[test]
8661 fn switching_start_mode_reuses_the_recorded_path_and_re_resolves_nothing() {
8662 let host = Host::new();
8663 let operations = host.operations();
8664 operations
8665 .install(&host.request(StartMode::Boot))
8666 .expect("an install at boot");
8667
8668 std::fs::remove_file(&host.binary).expect("the installed binary goes away");
8672
8673 let change = operations
8674 .set_start_mode(StartMode::Login)
8675 .expect("a switch that does not reinstall the product");
8676 assert!(change.changed);
8677 assert_eq!(change.from, StartMode::Boot);
8678 assert_eq!(change.to, StartMode::Login);
8679 assert_eq!(change.store_scope, crate::secrets::SecretScope::User);
8680
8681 let record = InstallRecord::read(&host.paths)
8682 .expect("readable")
8683 .expect("a record");
8684 assert_eq!(record.start_mode, StartMode::Login);
8685 assert_eq!(
8686 record.binary, host.binary,
8687 "the recorded path must survive the switch untouched"
8688 );
8689
8690 let registrations = host.controls.registrations();
8691 assert_eq!(registrations.len(), 1, "{registrations:?}");
8692 assert_eq!(registrations[0].0, StartMode::Login);
8693 assert!(
8694 registrations[0]
8695 .2
8696 .command_line
8697 .contains(&host.binary.to_string_lossy().into_owned()),
8698 "{:?}",
8699 registrations[0].2
8700 );
8701 }
8702
8703 #[test]
8704 fn switching_start_mode_keeps_the_live_registration_when_target_install_fails() {
8705 let host = Host::new();
8706 let operations = host.operations();
8707 operations
8708 .install(&host.request(StartMode::Boot))
8709 .expect("an install at boot");
8710 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8711 host.controls
8712 .fail_next_install(StartMode::Login, "injected target failure");
8713
8714 let error = operations
8715 .set_start_mode(StartMode::Login)
8716 .expect_err("the target manager refuses the install");
8717
8718 assert!(matches!(error, ServiceError::Control { .. }), "{error}");
8719 assert_eq!(
8720 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8721 record_before
8722 );
8723 let registrations = host.controls.registrations();
8724 assert_eq!(registrations.len(), 1, "{registrations:?}");
8725 assert_eq!(registrations[0].0, StartMode::Boot);
8726 }
8727
8728 #[test]
8729 fn switching_start_mode_rolls_back_target_when_record_persistence_fails() {
8730 let host = Host::new();
8731 let operations = host.operations();
8732 operations
8733 .install(&host.request(StartMode::Boot))
8734 .expect("an install at boot");
8735 let record_before = std::fs::read(InstallRecord::path(&host.paths)).expect("the record");
8736 let config = host.paths.config_dir().to_path_buf();
8737 let hidden = config.with_file_name("config-hidden-by-fault");
8738 host.controls.hide_directory_after_install(
8739 StartMode::Login,
8740 config.clone(),
8741 hidden.clone(),
8742 );
8743
8744 let error = operations
8745 .set_start_mode(StartMode::Login)
8746 .expect_err("the injected filesystem fault prevents persistence");
8747
8748 std::fs::remove_file(&config).expect("remove the injected blocker");
8749 std::fs::rename(&hidden, &config).expect("restore the record directory");
8750 assert!(matches!(error, ServiceError::Record { .. }), "{error}");
8751 assert_eq!(
8752 std::fs::read(InstallRecord::path(&host.paths)).expect("the old record survives"),
8753 record_before
8754 );
8755 let registrations = host.controls.registrations();
8756 assert_eq!(registrations.len(), 1, "{registrations:?}");
8757 assert_eq!(registrations[0].0, StartMode::Boot);
8758 assert!(
8759 host.controls
8760 .calls()
8761 .iter()
8762 .any(|call| call == "uninstall runner-manager (login)"),
8763 "the target must be rolled back: {:?}",
8764 host.controls.calls()
8765 );
8766 }
8767
8768 #[test]
8769 fn switching_to_the_mode_already_in_force_registers_nothing_again() {
8770 let host = Host::new();
8771 let operations = host.operations();
8772 operations
8773 .install(&host.request(StartMode::Boot))
8774 .expect("an install");
8775 let before = host.controls.calls().len();
8776
8777 let change = operations
8778 .set_start_mode(StartMode::Boot)
8779 .expect("a no-op switch");
8780 assert!(!change.changed);
8781 assert_eq!(
8782 host.controls.calls().len(),
8783 before,
8784 "a no-op switch must not touch the service manager"
8785 );
8786 }
8787
8788 #[test]
8789 fn switching_start_mode_on_a_host_with_no_registration_is_refused() {
8790 let host = Host::new();
8791 let error = host
8792 .operations()
8793 .set_start_mode(StartMode::Login)
8794 .expect_err("there is nothing to switch");
8795 assert!(
8796 matches!(error, ServiceError::NotInstalled { .. }),
8797 "{error}"
8798 );
8799 }
8800
8801 #[test]
8806 fn status_reports_the_four_facts_journey_five_asks_for() {
8807 let host = Host::new();
8808 let operations = host.operations();
8809 operations
8810 .install(&host.request(StartMode::Boot))
8811 .expect("an install");
8812 let at = DateTime::parse_from_rfc3339("2026-08-22T09:00:00Z")
8813 .expect("a valid timestamp")
8814 .with_timezone(&Utc);
8815 record_github_contact(&host.paths, at).expect("a heartbeat");
8816
8817 let status = operations.status().expect("a status");
8818 assert_eq!(status.start_mode(), Some(StartMode::Boot));
8819 assert_eq!(
8820 status.binary().map(BinaryPath::recorded),
8821 Some(host.binary.as_path())
8822 );
8823 assert_eq!(status.log_file(), host.paths.logs_dir().join(LOG_FILE_STEM));
8824 assert_eq!(status.last_github_contact(), Some(at));
8825 assert!(status.is_installed());
8826 assert!(status.is_healthy(), "{status}");
8827
8828 let printed = status.to_string();
8829 for fragment in [
8830 "start mode",
8831 "diagnostic log",
8832 "last GitHub contact",
8833 "binary",
8834 ] {
8835 assert!(printed.contains(fragment), "{printed}");
8836 }
8837 }
8838
8839 #[cfg(unix)]
8851 #[test]
8852 fn status_reports_a_record_it_may_not_read_and_still_reports_the_registration() {
8853 use std::os::unix::fs::PermissionsExt as _;
8854
8855 if unsafe { libc::geteuid() } == 0 {
8859 return;
8860 }
8861
8862 let host = Host::new();
8863 let operations = host.operations();
8864 operations
8865 .install(&host.request(StartMode::Boot))
8866 .expect("an install");
8867 assert!(
8868 operations.status().expect("a status").is_healthy(),
8869 "the discriminator: healthy before the record is made unreadable"
8870 );
8871
8872 let record = InstallRecord::path(&host.paths);
8873 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o000))
8874 .expect("the mode is applied");
8875
8876 let status = operations
8877 .status()
8878 .expect("a record this account may not read is reported, not thrown");
8879 assert!(status.is_installed(), "{status}");
8880 assert!(!status.is_healthy(), "{status}");
8881
8882 let printed = status.to_string();
8883 assert!(
8884 printed.contains("this account may not read it"),
8885 "the operator is told which of the two states this is: {printed}"
8886 );
8887 assert!(
8888 !printed.contains("there is no install record"),
8889 "a record that is there and unreadable is not a record that is missing, and the \
8890 missing one's remedy starts with `service uninstall`: {printed}"
8891 );
8892
8893 std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o644))
8896 .expect("the mode is restored");
8897 }
8898
8899 #[test]
8900 fn status_reports_a_stale_binary_as_an_error_rather_than_appearing_healthy() {
8901 let host = Host::new();
8902 let operations = host.operations();
8903 operations
8904 .install(&host.request(StartMode::Boot))
8905 .expect("an install");
8906
8907 assert!(
8910 operations.status().expect("a status").is_healthy(),
8911 "the freshly installed host must be healthy"
8912 );
8913
8914 std::fs::remove_file(&host.binary).expect("the binary moves out from under the record");
8915
8916 let status = operations.status().expect("a status");
8917 assert!(!status.is_healthy(), "{status}");
8918 assert!(
8919 status
8920 .problems()
8921 .iter()
8922 .any(|problem| problem.subject == "binary"),
8923 "{status}"
8924 );
8925 assert!(status.to_string().contains("STALE"), "{status}");
8926 }
8927
8928 #[test]
8929 fn status_reports_a_registration_that_would_not_start_at_boot() {
8930 let host = Host::new();
8931 let operations = host.operations();
8932 operations
8933 .install(&host.request(StartMode::Boot))
8934 .expect("an install");
8935 assert!(operations.status().expect("a status").is_healthy());
8936
8937 host.controls.edit("runner-manager", |registration| {
8938 registration.starts_automatically = false;
8939 });
8940
8941 let status = operations.status().expect("a status");
8942 assert!(!status.is_healthy(), "{status}");
8943 assert!(
8944 status
8945 .problems()
8946 .iter()
8947 .any(|problem| problem.detail.contains("after a reboot")),
8948 "{status}"
8949 );
8950 }
8951
8952 #[test]
8953 fn install_starts_the_registration_and_a_later_stop_is_unhealthy() {
8954 let host = Host::new();
8955 let operations = host.operations();
8956 operations
8957 .install(&host.request(StartMode::Login))
8958 .expect("install and immediate start");
8959
8960 assert!(
8961 operations.status().expect("running status").is_running(),
8962 "install must not wait for the next login trigger"
8963 );
8964 operations.stop().expect("stop the registration");
8965 let status = operations.status().expect("stopped status");
8966 assert!(!status.is_healthy(), "{status}");
8967 assert!(
8968 status
8969 .problems()
8970 .iter()
8971 .any(|problem| problem.subject == "runtime"),
8972 "{status}"
8973 );
8974 let rendered = status.to_string();
8975 assert!(
8976 rendered.contains("runner-manager service install --start-at login"),
8977 "{rendered}"
8978 );
8979 assert!(
8980 !rendered.contains("runner-manager service start"),
8981 "{rendered}"
8982 );
8983 }
8984
8985 #[test]
8986 fn status_reports_a_restart_policy_something_else_edited() {
8987 let host = Host::new();
8988 let operations = host.operations();
8989 operations
8990 .install(&host.request(StartMode::Boot))
8991 .expect("an install");
8992 assert!(operations.status().expect("a status").is_healthy());
8993
8994 host.controls.edit("runner-manager", |registration| {
8995 registration.restart_delay = Some(Duration::from_secs(1));
8996 });
8997
8998 let status = operations.status().expect("a status");
8999 assert!(!status.is_healthy(), "{status}");
9000 assert!(
9001 status
9002 .problems()
9003 .iter()
9004 .any(|problem| problem.subject == "restart policy"),
9005 "{status}"
9006 );
9007 }
9008
9009 #[test]
9010 fn status_reports_a_registration_naming_a_binary_the_record_does_not() {
9011 let host = Host::new();
9012 let operations = host.operations();
9013 operations
9014 .install(&host.request(StartMode::Boot))
9015 .expect("an install");
9016 let other = host.binary.with_file_name("someone-elses.exe");
9017 std::fs::write(&other, b"x").expect("writable");
9018
9019 host.controls.edit("runner-manager", |registration| {
9020 registration.command_line = quote_argument(&other.to_string_lossy());
9021 });
9022
9023 let status = operations.status().expect("a status");
9024 assert!(!status.is_healthy(), "{status}");
9025 assert!(
9026 matches!(status.binary(), Some(BinaryPath::Diverged { .. })),
9027 "{status}"
9028 );
9029 }
9030
9031 #[test]
9032 fn status_reports_a_record_no_service_manager_knows_about() {
9033 let host = Host::new();
9034 let operations = host.operations();
9035 operations
9036 .install(&host.request(StartMode::Boot))
9037 .expect("an install");
9038 for mode in [StartMode::Boot, StartMode::Login] {
9040 host.controls
9041 .control(mode)
9042 .expect("a control")
9043 .uninstall(&ServiceIdentity::product())
9044 .expect("removed");
9045 }
9046
9047 let status = operations.status().expect("a status");
9048 assert!(!status.is_healthy(), "{status}");
9049 assert!(
9050 status
9051 .problems()
9052 .iter()
9053 .any(|problem| problem.subject == "registration"),
9054 "{status}"
9055 );
9056 }
9057
9058 #[test]
9059 fn status_on_a_host_with_nothing_installed_is_neither_healthy_nor_broken() {
9060 let host = Host::new();
9061 let status = host.operations().status().expect("a status");
9062 assert!(!status.is_installed());
9063 assert!(
9064 status.is_healthy(),
9065 "a host that never installed the service has no fault to report: {status}"
9066 );
9067 assert!(status.to_string().contains("installed"), "{status}");
9068 }
9069
9070 #[test]
9071 fn status_says_a_login_registration_does_not_resume_after_an_unattended_reboot() {
9072 let host = Host::new();
9073 let operations = host.operations();
9074 operations
9075 .install(&host.request(StartMode::Login))
9076 .expect("an install at login");
9077 let status = operations.status().expect("a status");
9078 assert!(
9079 status
9080 .notes()
9081 .iter()
9082 .any(|note| note.contains("does not run until the operator signs in")),
9083 "05-infrastructure.md requires `service status` to say so: {status}"
9084 );
9085 }
9086
9087 #[test]
9092 fn start_and_stop_reach_the_domain_that_holds_the_registration() {
9093 let host = Host::new();
9094 let operations = host.operations();
9095 operations
9096 .install(&host.request(StartMode::Login))
9097 .expect("an install at login");
9098 operations.start().expect("a start");
9099 assert!(operations.status().expect("a status").is_running());
9100 assert!(operations.stop().expect("a stop"));
9101 assert!(!operations.status().expect("a status").is_running());
9102 }
9103
9104 #[test]
9105 fn starting_a_host_with_no_registration_is_refused() {
9106 let host = Host::new();
9107 let error = host.operations().start().expect_err("nothing to start");
9108 assert!(
9109 matches!(error, ServiceError::NotInstalled { .. }),
9110 "{error}"
9111 );
9112 }
9113
9114 #[cfg(windows)]
9130 #[test]
9131 fn the_account_this_installer_registers_is_one_the_stores_own_dacl_admits() {
9132 use crate::secrets::{PlatformSecretStore, SecretScope, SecretStore as _};
9133
9134 let root = tempfile::tempdir().expect("a temporary directory");
9135 let store = PlatformSecretStore::rooted_at(SecretScope::Machine, root.path())
9136 .expect("a rooted machine-scoped store");
9137 store
9138 .store(&secrecy::SecretString::from("a stand-in for the token"))
9139 .expect("the store accepts a value");
9140 let protection = store.protection().expect("the DACL can be read back");
9141
9142 assert!(
9143 protection.description().contains(";;;SY)"),
9144 "the machine-scoped store must admit LocalSystem, or a boot-start service cannot \
9145 read the token. `d2` writes this DACL and it is not this task's to widen. Got: {}",
9146 protection.description()
9147 );
9148 assert_eq!(
9149 ServiceAccount::for_definition(DefinitionKind::WindowsService, StartMode::Boot),
9150 ServiceAccount::LocalSystem,
9151 "and that is the account this installer registers, which is why SY is what matters"
9152 );
9153 assert!(
9154 !protection.readable_by_other_local_users(),
9155 "the same DACL must still exclude ordinary local users: {}",
9156 protection.description()
9157 );
9158
9159 for rejected in [";;;LS)", ";;;NS)"] {
9163 assert!(
9164 !protection.description().contains(rejected),
9165 "if the store ever admitted {rejected}, the least-privilege analysis in \
9166 docs/service-account.md would need redoing: {}",
9167 protection.description()
9168 );
9169 }
9170 }
9171}