1use std::cmp::Ordering;
50use std::ffi::OsString;
51use std::fmt;
52use std::io;
53use std::path::{Path, PathBuf};
54
55use runner_manager_domain::path::{LocalAbsolutePath, LocalPathError, PathPlatform};
56
57use crate::paths::AppPaths;
58
59pub const WINDOWS_RUNNER_ROOT_NAME: &str = "rman";
67
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
79pub enum RootOwner {
80 Host,
82 Repository(String),
84 DependencyCache,
86}
87
88impl RootOwner {
89 #[must_use]
95 pub fn remediation(&self) -> String {
96 match self {
97 RootOwner::Host => "runner-manager host set-runtime-root --path <PATH>".to_string(),
98 RootOwner::Repository(repository) => format!(
99 "runner-manager repo set-workspace {repository} --mode persistent --path <PATH>"
100 ),
101 RootOwner::DependencyCache => {
102 "runner-manager host cache set-root --path <PATH>".to_string()
103 }
104 }
105 }
106}
107
108impl fmt::Display for RootOwner {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 RootOwner::Host => f.write_str("the host runner root"),
112 RootOwner::Repository(repository) => {
113 write!(f, "the persistent workspace root for {repository}")
114 }
115 RootOwner::DependencyCache => f.write_str("the dependency-cache root"),
116 }
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Overlap {
132 Disjoint,
134 Same,
136 Inside,
138 Contains,
140}
141
142impl fmt::Display for Overlap {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 f.write_str(match self {
145 Overlap::Disjoint => "is unrelated to",
146 Overlap::Same => "is the same directory as",
147 Overlap::Inside => "is inside",
148 Overlap::Contains => "contains",
149 })
150 }
151}
152
153fn components_of(path: &str, platform: PathPlatform) -> Vec<&str> {
162 path.split(|c| platform.is_separator(c))
163 .filter(|component| !component.is_empty() && *component != ".")
164 .collect()
165}
166
167fn same_component(left: &str, right: &str, platform: PathPlatform) -> bool {
178 match platform {
179 PathPlatform::Windows => left
180 .chars()
181 .flat_map(char::to_lowercase)
182 .eq(right.chars().flat_map(char::to_lowercase)),
183 PathPlatform::Unix => left == right,
184 }
185}
186
187fn overlap_of(candidate: &str, other: &str, platform: PathPlatform) -> Overlap {
189 let left = components_of(candidate, platform);
190 let right = components_of(other, platform);
191 let shared = left
192 .iter()
193 .zip(right.iter())
194 .take_while(|(l, r)| same_component(l, r, platform))
195 .count();
196 if shared < left.len().min(right.len()) {
197 return Overlap::Disjoint;
198 }
199 match left.len().cmp(&right.len()) {
200 Ordering::Equal => Overlap::Same,
201 Ordering::Greater => Overlap::Inside,
202 Ordering::Less => Overlap::Contains,
203 }
204}
205
206#[derive(Debug, thiserror::Error)]
217pub enum RunnerRootError {
218 #[error(
219 "the operating system did not report a system directory, so the default runner \
220 root <system-drive>\\{WINDOWS_RUNNER_ROOT_NAME} cannot be resolved: {source}. \
221 Configure one explicitly with `{}`.",
222 RootOwner::Host.remediation()
223 )]
224 SystemDirectoryUnavailable {
225 #[source]
226 source: io::Error,
227 },
228
229 #[error(
230 "the system directory {got:?} is not a usable volume for the default runner \
231 root: {source}. Configure one explicitly with `{}`.",
232 RootOwner::Host.remediation()
233 )]
234 SystemDirectoryUnusable {
235 got: String,
236 #[source]
237 source: LocalPathError,
238 },
239
240 #[error(
241 "the application runtime directory {} cannot be used as the default runner \
242 root: {source}",
243 got.display()
244 )]
245 ApplicationRuntimeDirectoryUnusable {
246 got: PathBuf,
247 #[source]
248 source: LocalPathError,
249 },
250
251 #[error(
252 "{} cannot be represented as text, and a runner root is stored, printed and \
253 compared as text",
254 got.display()
255 )]
256 NonUnicode { got: PathBuf },
257
258 #[error(
259 "{got:?} is written in {platform} path syntax, but this host uses {}; a row \
260 written on another operating system is corrupt state here rather than a \
261 usable root",
262 PathPlatform::NATIVE
263 )]
264 ForeignPlatform { got: String, platform: PathPlatform },
265
266 #[error("cannot inspect {}: {source}", path.display())]
267 Inspect {
268 path: PathBuf,
269 #[source]
270 source: io::Error,
271 },
272
273 #[error(
274 "{} already exists and is not a directory; a runner root is a directory that \
275 attempt directories are created inside",
276 path.display()
277 )]
278 ExistingFile { path: PathBuf },
279
280 #[error(
281 "{} is a symbolic link, junction or other reparse point. A runner root is the \
282 base of a recursive cleanup, so it must be the real directory rather than a \
283 name that can be repointed at one; configure the target directly.",
284 path.display()
285 )]
286 Symlinked { path: PathBuf },
287
288 #[error(
289 "{} cannot be created because more than its last component is missing; the \
290 deepest directory that does exist is {}. Create the intermediate directories \
291 first, or configure a path one level below an existing directory.",
292 path.display(),
293 deepest_existing.display()
294 )]
295 MissingParents {
296 path: PathBuf,
297 deepest_existing: PathBuf,
298 },
299
300 #[error(
301 "{} exists but is not a directory, so nothing can be created inside it",
302 parent.display()
303 )]
304 ParentIsNotADirectory { parent: PathBuf },
305
306 #[error(
307 "{} exists but this account may not create entries in it. Grant this account \
308 write access, or configure a directory it owns with `{remediation}`.",
309 path.display()
310 )]
311 NotWritable { path: PathBuf, remediation: String },
312
313 #[error(
314 "the runner root {} cannot be used: this process runs as the superuser, so file \
315 permissions are not what refused {}, and that directory is on a volume macOS \
316 withholds through its privacy controls. Grant Full Disk Access to the program \
317 that runs the service -- System Settings > Privacy & Security > Full Disk \
318 Access -- and start the service again, or configure a directory on the startup \
319 disk with `{remediation}`. Note that the grant follows the binary and not the \
320 path: an upgrade that replaces the service binary revokes it, and it has to be \
321 granted again to the new one.",
322 requested.display(),
323 refused.display()
324 )]
325 DeniedByPrivacyPolicy {
326 requested: PathBuf,
328 refused: PathBuf,
330 remediation: String,
331 },
332
333 #[error(
334 "{} does not exist yet and this account may not create it: its parent {} \
335 refuses. Grant this account write access to that directory, or configure a \
336 directory it owns with `{remediation}`.",
337 leaf.display(),
338 parent.display()
339 )]
340 ParentNotWritable {
341 parent: PathBuf,
342 leaf: PathBuf,
343 remediation: String,
344 },
345
346 #[error(
347 "{} is on {filesystem}. Runner correctness and restart recovery may not depend \
348 on a remote share that can disappear or change identity while a job runs \
349 (D10); configure a directory on a local volume.",
350 path.display()
351 )]
352 RemoteFilesystem { path: PathBuf, filesystem: String },
353
354 #[error(
355 "this host cannot prove that {} is on a local filesystem (it reported \
356 {filesystem}). A runner root is accepted only when locality is provable, so \
357 this fails closed; configure a directory on a local volume.",
358 path.display()
359 )]
360 UnprovableFilesystem { path: PathBuf, filesystem: String },
361
362 #[error(
363 "{} resolves to {}, which is a filesystem root. A runner root must be a \
364 directory below a root, because everything inside it is removed on cleanup.",
365 path.display(),
366 canonical.display()
367 )]
368 ResolvesToFilesystemRoot { path: PathBuf, canonical: PathBuf },
369
370 #[error(
371 "{} {relation} {other_owner} ({}). {detail}",
372 candidate.display(),
373 other.display()
374 )]
375 Overlaps {
376 candidate: PathBuf,
377 relation: Overlap,
378 other: PathBuf,
379 other_owner: String,
380 detail: &'static str,
381 },
382
383 #[error(
384 "{} is derived from the runner root {} but resolves to {}, which is outside it",
385 child.display(),
386 root.display(),
387 resolved.display()
388 )]
389 Escapes {
390 root: PathBuf,
391 child: PathBuf,
392 resolved: PathBuf,
393 },
394
395 #[error("{source}")]
396 DerivedName {
397 #[source]
398 source: LocalPathError,
399 },
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum Locality {
409 Local,
411 Remote,
413 Unprovable,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct FilesystemIdentity {
423 pub locality: Locality,
425 pub name: String,
427}
428
429impl FilesystemIdentity {
430 #[must_use]
432 pub fn local(name: impl Into<String>) -> Self {
433 Self {
434 locality: Locality::Local,
435 name: name.into(),
436 }
437 }
438
439 #[must_use]
441 pub fn remote(name: impl Into<String>) -> Self {
442 Self {
443 locality: Locality::Remote,
444 name: name.into(),
445 }
446 }
447
448 #[must_use]
450 pub fn unprovable(name: impl Into<String>) -> Self {
451 Self {
452 locality: Locality::Unprovable,
453 name: name.into(),
454 }
455 }
456}
457
458pub trait FilesystemProbe {
468 fn identify(&self, directory: &Path) -> io::Result<FilesystemIdentity>;
473
474 fn is_writable(&self, directory: &Path) -> io::Result<bool>;
482
483 fn runs_as_superuser(&self) -> bool {
493 sys::runs_as_superuser()
494 }
495
496 fn is_on_privacy_gated_volume(&self, directory: &Path) -> bool {
502 sys::is_on_privacy_gated_volume(directory)
503 }
504
505 fn is_read_only(&self, directory: &Path) -> bool {
515 sys::is_read_only(directory)
516 }
517}
518
519#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
521pub struct HostFilesystem;
522
523impl FilesystemProbe for HostFilesystem {
524 fn identify(&self, directory: &Path) -> io::Result<FilesystemIdentity> {
525 sys::identify(directory)
526 }
527
528 fn is_writable(&self, directory: &Path) -> io::Result<bool> {
529 sys::is_writable(directory)
530 }
531}
532
533static HOST_FILESYSTEM: HostFilesystem = HostFilesystem;
535
536impl RunnerRootError {
537 #[must_use]
553 pub const fn kind(&self) -> &'static str {
554 match self {
555 Self::SystemDirectoryUnavailable { .. } => "system_directory_unavailable",
556 Self::SystemDirectoryUnusable { .. } => "system_directory_unusable",
557 Self::ApplicationRuntimeDirectoryUnusable { .. } => "runtime_directory_unusable",
558 Self::NonUnicode { .. } => "non_unicode",
559 Self::ForeignPlatform { .. } => "foreign_platform",
560 Self::Inspect { .. } => "not_inspectable",
561 Self::ExistingFile { .. } => "existing_file",
562 Self::Symlinked { .. } => "symlinked",
563 Self::MissingParents { .. } => "missing_parents",
564 Self::ParentIsNotADirectory { .. } => "parent_not_a_directory",
565 Self::NotWritable { .. } => "not_writable",
566 Self::DeniedByPrivacyPolicy { .. } => "denied_by_privacy_policy",
567 Self::ParentNotWritable { .. } => "parent_not_writable",
568 Self::RemoteFilesystem { .. } => "remote_filesystem",
569 Self::UnprovableFilesystem { .. } => "unprovable_filesystem",
570 Self::ResolvesToFilesystemRoot { .. } => "resolves_to_filesystem_root",
571 Self::Overlaps { .. } => "overlaps_application_data",
572 Self::Escapes { .. } => "escapes_root",
573 Self::DerivedName { .. } => "underivable_name",
574 }
575 }
576}
577
578#[must_use]
591pub fn is_on_privacy_gated_volume(path: &Path) -> bool {
592 sys::is_on_privacy_gated_volume(path)
593}
594
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605pub enum PlatformDefault<'a> {
606 WindowsSystemDirectory(&'a str),
608 ApplicationRuntimeDirectory(&'a Path),
610}
611
612pub fn default_runner_root_from(
621 source: PlatformDefault<'_>,
622) -> Result<LocalAbsolutePath, RunnerRootError> {
623 match source {
624 PlatformDefault::WindowsSystemDirectory(raw) => {
625 let unusable = |source| RunnerRootError::SystemDirectoryUnusable {
626 got: raw.to_string(),
627 source,
628 };
629 let system =
635 LocalAbsolutePath::parse_for(raw, PathPlatform::Windows).map_err(unusable)?;
636 let volume: String = system.as_str().chars().take(3).collect();
637 LocalAbsolutePath::parse_for(
638 format!("{volume}{WINDOWS_RUNNER_ROOT_NAME}"),
639 PathPlatform::Windows,
640 )
641 .map_err(unusable)
642 }
643 PlatformDefault::ApplicationRuntimeDirectory(path) => {
644 let text = path.to_str().ok_or_else(|| RunnerRootError::NonUnicode {
645 got: path.to_path_buf(),
646 })?;
647 LocalAbsolutePath::new(text).map_err(|source| {
648 RunnerRootError::ApplicationRuntimeDirectoryUnusable {
649 got: path.to_path_buf(),
650 source,
651 }
652 })
653 }
654 }
655}
656
657#[cfg(windows)]
668pub fn default_runner_root(app_paths: &AppPaths) -> Result<LocalAbsolutePath, RunnerRootError> {
669 let _ = app_paths;
670 let system = sys::system_directory()
671 .map_err(|source| RunnerRootError::SystemDirectoryUnavailable { source })?;
672 default_runner_root_from(PlatformDefault::WindowsSystemDirectory(&system))
673}
674
675#[cfg(windows)]
678pub(crate) use sys::system_directory as windows_system_directory;
679
680#[cfg(not(windows))]
693pub fn default_runner_root(app_paths: &AppPaths) -> Result<LocalAbsolutePath, RunnerRootError> {
694 default_runner_root_from(PlatformDefault::ApplicationRuntimeDirectory(
695 app_paths.runtime_dir(),
696 ))
697}
698
699#[derive(Debug)]
714struct Projection {
715 anchor: PathBuf,
717 anchor_as_written: PathBuf,
719 canonical: PathBuf,
721 missing: usize,
729}
730
731fn means_absent(error: &io::Error) -> bool {
740 matches!(
741 error.kind(),
742 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
743 )
744}
745
746fn project(path: &Path) -> Result<Projection, RunnerRootError> {
749 let mut missing: Vec<OsString> = Vec::new();
750 let mut cursor = path.to_path_buf();
751 loop {
752 match std::fs::symlink_metadata(&cursor) {
753 Ok(_) => break,
754 Err(error) if means_absent(&error) => {
755 let name = cursor.file_name().map(OsString::from);
756 let parent = cursor.parent().map(Path::to_path_buf);
757 let (Some(name), Some(parent)) = (name, parent) else {
758 return Err(RunnerRootError::Inspect {
761 path: cursor,
762 source: error,
763 });
764 };
765 missing.push(name);
766 cursor = parent;
767 }
768 Err(source) => {
769 return Err(RunnerRootError::Inspect {
770 path: cursor,
771 source,
772 });
773 }
774 }
775 }
776
777 let anchor = std::fs::canonicalize(&cursor)
778 .map(|canonical| plain(&canonical))
779 .map_err(|source| RunnerRootError::Inspect {
780 path: cursor.clone(),
781 source,
782 })?;
783
784 let mut canonical = anchor.clone();
785 for component in missing.iter().rev() {
786 canonical.push(component);
787 }
788 Ok(Projection {
789 anchor,
790 anchor_as_written: cursor,
791 canonical,
792 missing: missing.len(),
793 })
794}
795
796#[cfg(windows)]
803fn plain(path: &Path) -> PathBuf {
804 let text = path.to_string_lossy();
805 if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
806 return PathBuf::from(format!(r"\\{rest}"));
807 }
808 if let Some(rest) = text.strip_prefix(r"\\?\") {
809 return PathBuf::from(rest);
810 }
811 path.to_path_buf()
812}
813
814#[cfg(not(windows))]
816fn plain(path: &Path) -> PathBuf {
817 path.to_path_buf()
818}
819
820fn canonical_text(path: &Path) -> Option<String> {
829 project(path)
830 .ok()
831 .map(|projection| projection.canonical.to_string_lossy().into_owned())
832}
833
834fn is_filesystem_root(path: &Path) -> bool {
836 path.parent().is_none()
837}
838
839#[derive(Debug, Clone, PartialEq, Eq)]
851pub struct PreflightedRoot {
852 root: LocalAbsolutePath,
853 canonical: PathBuf,
854 exists: bool,
855 filesystem: FilesystemIdentity,
856}
857
858impl PreflightedRoot {
859 #[must_use]
861 pub const fn root(&self) -> &LocalAbsolutePath {
862 &self.root
863 }
864
865 #[must_use]
867 pub fn canonical(&self) -> &Path {
868 &self.canonical
869 }
870
871 #[must_use]
873 pub const fn exists(&self) -> bool {
874 self.exists
875 }
876
877 #[must_use]
879 pub fn leaf_to_create(&self) -> Option<&Path> {
880 (!self.exists).then(|| self.root.as_path())
881 }
882
883 #[must_use]
885 pub const fn filesystem(&self) -> &FilesystemIdentity {
886 &self.filesystem
887 }
888}
889
890pub struct RootPreflight<'a> {
911 app_paths: &'a AppPaths,
912 others: Vec<(RootOwner, LocalAbsolutePath)>,
913 probe: &'a dyn FilesystemProbe,
914}
915
916impl fmt::Debug for RootPreflight<'_> {
917 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
918 f.debug_struct("RootPreflight")
919 .field("app_paths", &self.app_paths)
920 .field("others", &self.others)
921 .finish_non_exhaustive()
922 }
923}
924
925impl<'a> RootPreflight<'a> {
926 #[must_use]
928 pub fn new(app_paths: &'a AppPaths) -> Self {
929 Self::with_probe(app_paths, &HOST_FILESYSTEM)
930 }
931
932 #[must_use]
934 pub fn with_probe(app_paths: &'a AppPaths, probe: &'a dyn FilesystemProbe) -> Self {
935 Self {
936 app_paths,
937 others: Vec::new(),
938 probe,
939 }
940 }
941
942 #[must_use]
950 pub fn against(mut self, owner: RootOwner, root: LocalAbsolutePath) -> Self {
951 self.others.push((owner, root));
952 self
953 }
954
955 fn protected(&self) -> [(&'static str, &Path); 3] {
964 [
965 (
966 "the application configuration directory",
967 self.app_paths.config_dir(),
968 ),
969 (
970 "the application state directory",
971 self.app_paths.state_dir(),
972 ),
973 ("the application log directory", self.app_paths.logs_dir()),
974 ]
975 }
976
977 fn reject_overlap(
983 &self,
984 owner: &RootOwner,
985 candidate: &str,
986 canonical: bool,
987 ) -> Result<(), RunnerRootError> {
988 let native = PathPlatform::NATIVE;
989 let text_of = |path: &Path| -> Option<String> {
990 if canonical {
991 canonical_text(path)
992 } else {
993 Some(path.to_string_lossy().into_owned())
994 }
995 };
996
997 let inside_runtime = || {
1010 text_of(self.app_paths.runtime_dir()).is_some_and(|runtime| {
1011 matches!(
1012 overlap_of(candidate, &runtime, native),
1013 Overlap::Same | Overlap::Inside
1014 )
1015 })
1016 };
1017
1018 for (label, path) in self.protected() {
1019 let Some(other) = text_of(path) else {
1020 continue;
1021 };
1022 let relation = overlap_of(candidate, &other, native);
1023 if relation == Overlap::Disjoint || (relation == Overlap::Inside && inside_runtime()) {
1024 continue;
1025 }
1026 return Err(RunnerRootError::Overlaps {
1027 candidate: PathBuf::from(candidate),
1028 relation,
1029 other: PathBuf::from(other),
1030 other_owner: label.to_string(),
1031 detail: "Runner workspaces are removed recursively and application data must \
1032 survive that; configure a directory outside the application data tree.",
1033 });
1034 }
1035
1036 for (other_owner, root) in &self.others {
1037 if other_owner == owner {
1038 continue;
1039 }
1040 let Some(other) = text_of(root.as_path()) else {
1041 continue;
1042 };
1043 let relation = overlap_of(candidate, &other, native);
1044 if relation == Overlap::Disjoint {
1045 continue;
1046 }
1047 return Err(RunnerRootError::Overlaps {
1048 candidate: PathBuf::from(candidate),
1049 relation,
1050 other: PathBuf::from(other),
1051 other_owner: other_owner.to_string(),
1052 detail: "Two runner roots that contain one another can delete each other's \
1053 workspaces; configure directories that do not overlap.",
1054 });
1055 }
1056 Ok(())
1057 }
1058
1059 pub fn check(
1069 &self,
1070 owner: &RootOwner,
1071 root: &LocalAbsolutePath,
1072 ) -> Result<PreflightedRoot, RunnerRootError> {
1073 if root.platform() != PathPlatform::NATIVE {
1074 return Err(RunnerRootError::ForeignPlatform {
1075 got: root.as_str().to_string(),
1076 platform: root.platform(),
1077 });
1078 }
1079 let candidate = root.as_path();
1080
1081 self.reject_overlap(owner, root.as_str(), false)?;
1086
1087 match std::fs::symlink_metadata(candidate) {
1093 Ok(metadata) if metadata.file_type().is_symlink() => {
1094 return Err(RunnerRootError::Symlinked {
1095 path: candidate.to_path_buf(),
1096 });
1097 }
1098 Ok(metadata) if !metadata.is_dir() => {
1099 return Err(RunnerRootError::ExistingFile {
1100 path: candidate.to_path_buf(),
1101 });
1102 }
1103 Ok(_) => {}
1104 Err(error) if means_absent(&error) => {}
1107 Err(source) => {
1108 return Err(RunnerRootError::Inspect {
1109 path: candidate.to_path_buf(),
1110 source,
1111 });
1112 }
1113 }
1114
1115 let projection = project(candidate)?;
1116 match projection.missing {
1117 0 => {}
1118 1 => {
1119 if !projection.anchor.is_dir() {
1120 return Err(RunnerRootError::ParentIsNotADirectory {
1121 parent: projection.anchor_as_written.clone(),
1122 });
1123 }
1124 }
1125 _ => {
1126 return Err(RunnerRootError::MissingParents {
1127 path: candidate.to_path_buf(),
1128 deepest_existing: projection.anchor_as_written.clone(),
1129 });
1130 }
1131 }
1132
1133 if is_filesystem_root(&projection.canonical) {
1134 return Err(RunnerRootError::ResolvesToFilesystemRoot {
1135 path: candidate.to_path_buf(),
1136 canonical: projection.canonical.clone(),
1137 });
1138 }
1139
1140 let filesystem =
1141 self.probe
1142 .identify(&projection.anchor)
1143 .map_err(|source| RunnerRootError::Inspect {
1144 path: projection.anchor.clone(),
1145 source,
1146 })?;
1147 match filesystem.locality {
1148 Locality::Local => {}
1149 Locality::Remote => {
1150 return Err(RunnerRootError::RemoteFilesystem {
1151 path: projection.canonical.clone(),
1152 filesystem: filesystem.name,
1153 });
1154 }
1155 Locality::Unprovable => {
1156 return Err(RunnerRootError::UnprovableFilesystem {
1157 path: projection.canonical.clone(),
1158 filesystem: filesystem.name,
1159 });
1160 }
1161 }
1162
1163 let writable = self
1164 .probe
1165 .is_writable(&projection.anchor)
1166 .map_err(|source| RunnerRootError::Inspect {
1167 path: projection.anchor.clone(),
1168 source,
1169 })?;
1170 if !writable {
1171 if cfg!(target_os = "macos")
1187 && self.probe.runs_as_superuser()
1188 && self.probe.is_on_privacy_gated_volume(&projection.anchor)
1189 && !self.probe.is_read_only(&projection.anchor)
1190 {
1191 return Err(RunnerRootError::DeniedByPrivacyPolicy {
1192 requested: candidate.to_path_buf(),
1196 refused: projection.anchor_as_written.clone(),
1197 remediation: owner.remediation(),
1198 });
1199 }
1200 return Err(if projection.missing == 0 {
1204 RunnerRootError::NotWritable {
1205 path: projection.anchor_as_written.clone(),
1206 remediation: owner.remediation(),
1207 }
1208 } else {
1209 RunnerRootError::ParentNotWritable {
1210 parent: projection.anchor_as_written.clone(),
1211 leaf: candidate.to_path_buf(),
1212 remediation: owner.remediation(),
1213 }
1214 });
1215 }
1216
1217 self.reject_overlap(owner, &projection.canonical.to_string_lossy(), true)?;
1221
1222 Ok(PreflightedRoot {
1223 root: root.clone(),
1224 canonical: projection.canonical,
1225 exists: projection.missing == 0,
1226 filesystem,
1227 })
1228 }
1229}
1230
1231pub fn derive_child(
1247 root: &LocalAbsolutePath,
1248 name: &str,
1249) -> Result<LocalAbsolutePath, RunnerRootError> {
1250 root.join_child(name)
1251 .map_err(|source| RunnerRootError::DerivedName { source })
1252}
1253
1254pub fn verify_containment(
1269 root: &LocalAbsolutePath,
1270 child: &LocalAbsolutePath,
1271) -> Result<(), RunnerRootError> {
1272 for value in [root, child] {
1277 if value.platform() != PathPlatform::NATIVE {
1278 return Err(RunnerRootError::ForeignPlatform {
1279 got: value.as_str().to_string(),
1280 platform: value.platform(),
1281 });
1282 }
1283 }
1284 let escapes = |resolved: PathBuf| RunnerRootError::Escapes {
1285 root: root.as_path().to_path_buf(),
1286 child: child.as_path().to_path_buf(),
1287 resolved,
1288 };
1289 if overlap_of(child.as_str(), root.as_str(), root.platform()) != Overlap::Inside {
1290 return Err(escapes(child.as_path().to_path_buf()));
1291 }
1292 let root_projection = project(root.as_path())?;
1293 let child_projection = project(child.as_path())?;
1294 let relation = overlap_of(
1295 &child_projection.canonical.to_string_lossy(),
1296 &root_projection.canonical.to_string_lossy(),
1297 root.platform(),
1298 );
1299 if relation != Overlap::Inside {
1300 return Err(escapes(child_projection.canonical));
1301 }
1302 Ok(())
1303}
1304
1305#[cfg(windows)]
1317mod sys {
1318 use std::io;
1319 use std::os::windows::ffi::OsStrExt;
1320 use std::path::Path;
1321
1322 use windows::Win32::Foundation::{CloseHandle, ERROR_ACCESS_DENIED};
1323 use windows::Win32::Storage::FileSystem::{
1324 CreateFileW, FILE_ADD_SUBDIRECTORY, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE,
1325 FILE_SHARE_READ, FILE_SHARE_WRITE, GetDriveTypeW, GetVolumePathNameW, OPEN_EXISTING,
1326 };
1327 use windows::Win32::System::SystemInformation::GetSystemDirectoryW;
1328 use windows::Win32::System::WindowsProgramming::{
1329 DRIVE_CDROM, DRIVE_FIXED, DRIVE_NO_ROOT_DIR, DRIVE_RAMDISK, DRIVE_REMOTE, DRIVE_REMOVABLE,
1330 DRIVE_UNKNOWN,
1331 };
1332 use windows::core::PCWSTR;
1333
1334 use super::FilesystemIdentity;
1335
1336 const fn hresult_from_win32(code: u32) -> i32 {
1338 if code == 0 {
1339 0
1340 } else {
1341 ((code & 0x0000_ffff) | 0x8007_0000) as i32
1342 }
1343 }
1344
1345 fn io_error(error: &windows::core::Error) -> io::Error {
1353 let code = error.code().0;
1354 #[allow(clippy::cast_sign_loss)]
1355 let unsigned = code as u32;
1356 if unsigned & 0xffff_0000 == 0x8007_0000 {
1357 #[allow(clippy::cast_possible_wrap)]
1358 return io::Error::from_raw_os_error((unsigned & 0x0000_ffff) as i32);
1359 }
1360 io::Error::from_raw_os_error(code)
1361 }
1362
1363 fn to_wide(path: &Path) -> Vec<u16> {
1364 path.as_os_str()
1365 .encode_wide()
1366 .chain(std::iter::once(0))
1367 .collect()
1368 }
1369
1370 pub(crate) fn system_directory() -> io::Result<String> {
1377 let mut buffer = [0u16; 512];
1381 let written = unsafe { GetSystemDirectoryW(Some(&mut buffer)) } as usize;
1385 if written == 0 {
1386 return Err(io::Error::last_os_error());
1387 }
1388 if written > buffer.len() {
1389 return Err(io::Error::other(format!(
1390 "the system directory needs {written} UTF-16 code units, which is more \
1391 than a system path is expected to occupy"
1392 )));
1393 }
1394 String::from_utf16(&buffer[..written]).map_err(io::Error::other)
1395 }
1396
1397 fn volume_path(directory: &Path) -> io::Result<Vec<u16>> {
1403 let file = to_wide(directory);
1404 let mut buffer = [0u16; 512];
1405 unsafe { GetVolumePathNameW(PCWSTR(file.as_ptr()), &mut buffer) }
1408 .map_err(|error| io_error(&error))?;
1409 let length = buffer
1410 .iter()
1411 .position(|unit| *unit == 0)
1412 .unwrap_or(buffer.len());
1413 let mut mount = buffer[..length].to_vec();
1414 mount.push(0);
1415 Ok(mount)
1416 }
1417
1418 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1419 let mount = volume_path(directory)?;
1420 let kind = unsafe { GetDriveTypeW(PCWSTR(mount.as_ptr())) };
1422 Ok(match kind {
1423 DRIVE_FIXED => FilesystemIdentity::local("a fixed local volume"),
1424 DRIVE_REMOVABLE => FilesystemIdentity::local("a removable local volume"),
1425 DRIVE_RAMDISK => FilesystemIdentity::local("a RAM disk"),
1426 DRIVE_CDROM => FilesystemIdentity::local("an optical drive"),
1427 DRIVE_REMOTE => FilesystemIdentity::remote("a network drive"),
1428 DRIVE_NO_ROOT_DIR => FilesystemIdentity::unprovable("no mounted volume"),
1429 DRIVE_UNKNOWN => FilesystemIdentity::unprovable("an unknown drive type"),
1430 other => FilesystemIdentity::unprovable(format!("drive type {other}")),
1431 })
1432 }
1433
1434 pub(super) const fn is_on_privacy_gated_volume(_path: &Path) -> bool {
1438 false
1439 }
1440
1441 pub(super) const fn runs_as_superuser() -> bool {
1445 false
1446 }
1447
1448 pub(super) const fn is_read_only(_path: &Path) -> bool {
1451 false
1452 }
1453
1454 pub(super) fn is_writable(directory: &Path) -> io::Result<bool> {
1474 let wide = to_wide(directory);
1475 let access = FILE_ADD_SUBDIRECTORY.0;
1476 let opened = unsafe {
1481 CreateFileW(
1482 PCWSTR(wide.as_ptr()),
1483 access,
1484 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1485 None,
1486 OPEN_EXISTING,
1487 FILE_FLAG_BACKUP_SEMANTICS,
1488 None,
1489 )
1490 };
1491 match opened {
1492 Ok(handle) => {
1493 unsafe {
1496 let _ = CloseHandle(handle);
1497 }
1498 Ok(true)
1499 }
1500 Err(error) if error.code().0 == hresult_from_win32(ERROR_ACCESS_DENIED.0) => Ok(false),
1501 Err(error) => Err(io_error(&error)),
1502 }
1503 }
1504}
1505
1506#[cfg(unix)]
1507mod sys {
1508 use std::ffi::CString;
1509 use std::io;
1510 use std::os::unix::ffi::OsStrExt;
1511 use std::path::Path;
1512
1513 use super::FilesystemIdentity;
1514
1515 fn c_path(path: &Path) -> io::Result<CString> {
1516 CString::new(path.as_os_str().as_bytes()).map_err(|_| {
1517 io::Error::other("a path containing a NUL cannot be given to the operating system")
1518 })
1519 }
1520
1521 #[cfg(target_os = "macos")]
1536 pub(super) fn is_on_privacy_gated_volume(path: &Path) -> bool {
1537 let Ok(path) = c_path(path) else {
1538 return false;
1539 };
1540 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1541 if unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) } != 0 {
1544 return false;
1545 }
1546 let mount = unsafe { std::ffi::CStr::from_ptr(buffer.f_mntonname.as_ptr()) };
1549 mount.to_bytes().starts_with(b"/Volumes/")
1550 }
1551
1552 #[cfg(not(target_os = "macos"))]
1555 pub(super) const fn is_on_privacy_gated_volume(_path: &Path) -> bool {
1556 false
1557 }
1558
1559 #[cfg(not(target_os = "macos"))]
1562 pub(super) const fn is_read_only(_path: &Path) -> bool {
1563 false
1564 }
1565
1566 #[cfg(target_os = "macos")]
1578 pub(super) fn is_read_only(path: &Path) -> bool {
1579 let Ok(path) = c_path(path) else {
1580 return false;
1581 };
1582 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1583 if unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) } != 0 {
1586 return false;
1587 }
1588 buffer.f_flags & u32::try_from(libc::MNT_RDONLY).unwrap_or(0) != 0
1589 }
1590
1591 pub(super) fn runs_as_superuser() -> bool {
1597 unsafe { libc::geteuid() == 0 }
1600 }
1601
1602 pub(super) fn is_writable(directory: &Path) -> io::Result<bool> {
1610 let path = c_path(directory)?;
1611 let result = unsafe { libc::access(path.as_ptr(), libc::W_OK | libc::X_OK) };
1614 if result == 0 {
1615 return Ok(true);
1616 }
1617 let error = io::Error::last_os_error();
1618 match error.raw_os_error() {
1619 Some(libc::EACCES | libc::EPERM | libc::EROFS) => Ok(false),
1621 _ => Err(error),
1622 }
1623 }
1624
1625 #[cfg(target_os = "linux")]
1634 const LOCAL_MAGICS: &[(u32, &str)] = &[
1635 (0x0000_ef53, "ext2/ext3/ext4"),
1636 (0x9123_683e, "btrfs"),
1637 (0x5846_5342, "xfs"),
1638 (0x0102_1994, "tmpfs"),
1639 (0x794c_7630, "overlayfs"),
1640 (0x2fc1_2fc1, "zfs"),
1641 (0xf2f5_2010, "f2fs"),
1642 (0x0000_4d44, "vfat"),
1643 (0x2011_bab0, "exfat"),
1644 (0x5346_544e, "ntfs"),
1645 (0x8584_58f6, "ramfs"),
1646 (0x0000_9660, "iso9660"),
1647 (0x7371_7368, "squashfs"),
1648 (0x3153_464a, "jfs"),
1649 (0x5265_4973, "reiserfs"),
1650 (0xca45_1a4e, "bcachefs"),
1651 (0x0000_4244, "hfs"),
1652 (0x0000_482b, "hfsplus"),
1653 ];
1654
1655 #[cfg(target_os = "linux")]
1657 const REMOTE_MAGICS: &[(u32, &str)] = &[
1658 (0x0000_6969, "nfs"),
1659 (0xff53_4d42, "cifs"),
1660 (0xfe53_4d42, "smb2"),
1661 (0x0000_517b, "smb"),
1662 (0x7375_7245, "coda"),
1663 (0x0000_564c, "ncpfs"),
1664 (0x5346_414f, "afs"),
1665 (0x6b41_4653, "afs"),
1666 (0x0bd0_0bd0, "lustre"),
1667 (0x00c3_6400, "ceph"),
1668 (0x0102_1997, "9p"),
1669 (0x0116_1970, "gfs2"),
1670 (0x7461_636f, "ocfs2"),
1671 ];
1672
1673 #[cfg(target_os = "linux")]
1674 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1675 let path = c_path(directory)?;
1676 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1677 let result = unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) };
1680 if result != 0 {
1681 return Err(io::Error::last_os_error());
1682 }
1683 let magic = buffer.f_type as u32;
1686 if let Some((_, name)) = LOCAL_MAGICS.iter().find(|(value, _)| *value == magic) {
1687 return Ok(FilesystemIdentity::local(*name));
1688 }
1689 if let Some((_, name)) = REMOTE_MAGICS.iter().find(|(value, _)| *value == magic) {
1690 return Ok(FilesystemIdentity::remote(*name));
1691 }
1692 Ok(FilesystemIdentity::unprovable(format!(
1693 "filesystem type 0x{magic:08x}"
1694 )))
1695 }
1696
1697 #[cfg(target_os = "macos")]
1701 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1702 let path = c_path(directory)?;
1703 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1704 let result = unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) };
1707 if result != 0 {
1708 return Err(io::Error::last_os_error());
1709 }
1710 let name = unsafe { std::ffi::CStr::from_ptr(buffer.f_fstypename.as_ptr()) }
1713 .to_string_lossy()
1714 .into_owned();
1715 #[allow(clippy::cast_sign_loss)]
1716 let local = buffer.f_flags & (libc::MNT_LOCAL as u32) != 0;
1717 Ok(if local {
1718 FilesystemIdentity::local(name)
1719 } else {
1720 FilesystemIdentity::remote(name)
1721 })
1722 }
1723
1724 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1727 pub(super) fn identify(_directory: &Path) -> io::Result<FilesystemIdentity> {
1728 Ok(FilesystemIdentity::unprovable(
1729 "an operating system this build cannot interrogate",
1730 ))
1731 }
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736 use super::*;
1737
1738 use PathPlatform::{Unix, Windows};
1739
1740 #[derive(Debug)]
1749 struct StubFilesystem {
1750 identity: FilesystemIdentity,
1751 writable: bool,
1752 superuser: bool,
1753 gated_volume: bool,
1754 read_only: bool,
1755 }
1756
1757 impl StubFilesystem {
1758 fn saying(identity: FilesystemIdentity) -> Self {
1759 Self {
1760 identity,
1761 writable: true,
1762 superuser: false,
1763 gated_volume: false,
1764 read_only: false,
1765 }
1766 }
1767
1768 fn unwritable() -> Self {
1769 Self {
1770 identity: FilesystemIdentity::local("a test volume"),
1771 writable: false,
1772 superuser: false,
1773 gated_volume: false,
1774 read_only: false,
1775 }
1776 }
1777
1778 #[cfg(target_os = "macos")]
1786 fn unwritable_to_the_superuser() -> Self {
1787 Self {
1788 superuser: true,
1789 gated_volume: true,
1790 ..Self::unwritable()
1791 }
1792 }
1793
1794 #[cfg(target_os = "macos")]
1797 fn unwritable_to_the_superuser_on_the_startup_disk() -> Self {
1798 Self {
1799 superuser: true,
1800 ..Self::unwritable()
1801 }
1802 }
1803
1804 #[cfg(target_os = "macos")]
1807 fn read_only_gated_volume() -> Self {
1808 Self {
1809 superuser: true,
1810 gated_volume: true,
1811 read_only: true,
1812 ..Self::unwritable()
1813 }
1814 }
1815 }
1816
1817 impl FilesystemProbe for StubFilesystem {
1818 fn identify(&self, _directory: &Path) -> io::Result<FilesystemIdentity> {
1819 Ok(self.identity.clone())
1820 }
1821
1822 fn is_writable(&self, _directory: &Path) -> io::Result<bool> {
1823 Ok(self.writable)
1824 }
1825
1826 fn runs_as_superuser(&self) -> bool {
1827 self.superuser
1828 }
1829
1830 fn is_on_privacy_gated_volume(&self, _directory: &Path) -> bool {
1831 self.gated_volume
1832 }
1833
1834 fn is_read_only(&self, _directory: &Path) -> bool {
1835 self.read_only
1836 }
1837 }
1838
1839 fn native(path: &Path) -> LocalAbsolutePath {
1841 LocalAbsolutePath::new(path.to_str().expect("the fixture path is unicode"))
1842 .expect("the fixture path is a storable local path")
1843 }
1844
1845 fn foreign() -> LocalAbsolutePath {
1847 if cfg!(windows) {
1848 LocalAbsolutePath::parse_for("/srv/rman", Unix)
1849 } else {
1850 LocalAbsolutePath::parse_for("C:\\rman", Windows)
1851 }
1852 .expect("the fixture is valid for the other platform")
1853 }
1854
1855 #[cfg(windows)]
1861 fn link_dir(target: &Path, link: &Path) -> bool {
1862 std::process::Command::new("cmd")
1863 .arg("/C")
1864 .arg("mklink")
1865 .arg("/J")
1866 .arg(link)
1867 .arg(target)
1868 .output()
1869 .is_ok_and(|output| output.status.success())
1870 }
1871
1872 #[cfg(unix)]
1873 fn link_dir(target: &Path, link: &Path) -> bool {
1874 std::os::unix::fs::symlink(target, link).is_ok()
1875 }
1876
1877 struct Fixture {
1880 root: tempfile::TempDir,
1881 paths: AppPaths,
1882 workspaces: PathBuf,
1883 }
1884
1885 impl Fixture {
1886 fn check(&self, path: &Path) -> Result<PreflightedRoot, RunnerRootError> {
1893 RootPreflight::new(&self.paths).check(&RootOwner::Host, &native(path))
1894 }
1895 }
1896
1897 fn fixture() -> Fixture {
1898 let root = tempfile::tempdir().expect("a temporary directory");
1899 let paths = AppPaths::rooted_at(root.path());
1900 paths.create_all().expect("the layout is created");
1901 let workspaces = root.path().join("workspaces");
1902 std::fs::create_dir(&workspaces).expect("the workspace parent is created");
1903 Fixture {
1904 root,
1905 paths,
1906 workspaces,
1907 }
1908 }
1909
1910 fn snapshot(root: &Path) -> Vec<String> {
1912 fn walk(path: &Path, into: &mut Vec<String>) {
1913 let Ok(entries) = std::fs::read_dir(path) else {
1914 return;
1915 };
1916 for entry in entries.flatten() {
1917 let metadata = entry
1918 .metadata()
1919 .expect("an entry that was just listed can be inspected");
1920 #[cfg(unix)]
1921 let permissions = {
1922 use std::os::unix::fs::PermissionsExt;
1923 format!("{:04o}", metadata.permissions().mode() & 0o7777)
1924 };
1925 #[cfg(not(unix))]
1926 let permissions = format!("readonly={}", metadata.permissions().readonly());
1927 into.push(format!(
1928 "{} dir={} len={} {permissions}",
1929 entry.path().display(),
1930 metadata.is_dir(),
1931 metadata.len()
1932 ));
1933 if metadata.is_dir() {
1934 walk(&entry.path(), into);
1935 }
1936 }
1937 }
1938 let mut entries = Vec::new();
1939 walk(root, &mut entries);
1940 entries.sort();
1941 entries
1942 }
1943
1944 #[test]
1947 fn the_windows_default_is_the_system_drive_plus_rman() {
1948 let cases = [
1952 ("C:\\Windows\\system32", "C:\\rman"),
1953 ("E:\\Windows\\system32", "E:\\rman"),
1954 ("c:/windows/system32", "C:\\rman"),
1955 ("Z:\\WINDOWS\\SYSTEM32", "Z:\\rman"),
1956 ("D:\\Windows", "D:\\rman"),
1957 ];
1958 for (system_directory, expected) in cases {
1959 let resolved =
1960 default_runner_root_from(PlatformDefault::WindowsSystemDirectory(system_directory))
1961 .expect("a drive path resolves");
1962 assert_eq!(
1963 resolved.as_str(),
1964 expected,
1965 "system directory {system_directory:?}"
1966 );
1967 assert_eq!(resolved.platform(), Windows);
1968 }
1969 }
1970
1971 #[test]
1972 fn a_system_directory_that_is_not_a_local_drive_fails_with_the_remediation() {
1973 for system_directory in [
1974 "\\\\nas\\share\\system32",
1975 "\\\\?\\C:\\Windows\\system32",
1976 "C:\\",
1977 "windows\\system32",
1978 "",
1979 ] {
1980 let error =
1981 default_runner_root_from(PlatformDefault::WindowsSystemDirectory(system_directory))
1982 .expect_err("an unusable system directory must not resolve");
1983 let message = error.to_string();
1984 assert!(
1985 message.contains("host set-runtime-root"),
1986 "the message must name the command that fixes it: {message}"
1987 );
1988 }
1989 }
1990
1991 #[test]
1992 fn the_application_runtime_directory_arm_changes_nothing() {
1993 let root = tempfile::tempdir().expect("a temporary directory");
1997 let paths = AppPaths::rooted_at(root.path());
1998 let resolved = default_runner_root_from(PlatformDefault::ApplicationRuntimeDirectory(
1999 paths.runtime_dir(),
2000 ))
2001 .expect("a resolved runtime directory is storable");
2002 assert_eq!(resolved.as_path(), paths.runtime_dir());
2003 }
2004
2005 #[cfg(not(windows))]
2006 #[test]
2007 fn the_macos_and_linux_defaults_are_the_existing_runtime_directory() {
2008 let discovered = AppPaths::discover().expect("a home directory exists on every CI leg");
2009 assert_eq!(
2010 default_runner_root(&discovered)
2011 .expect("the discovered layout resolves")
2012 .as_path(),
2013 discovered.runtime_dir(),
2014 "moving the Unix defaults would relocate live workspaces for no reason"
2015 );
2016
2017 let root = tempfile::tempdir().expect("a temporary directory");
2018 let rooted = AppPaths::rooted_at(root.path());
2019 assert_eq!(
2020 default_runner_root(&rooted)
2021 .expect("an explicit root resolves")
2022 .as_path(),
2023 rooted.runtime_dir()
2024 );
2025 }
2026
2027 #[cfg(windows)]
2028 #[test]
2029 fn the_windows_default_is_this_machines_system_drive() {
2030 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2031 let resolved = default_runner_root(&paths).expect("this host has a system directory");
2032 let text = resolved.as_str();
2033 assert_eq!(
2034 &text[1..],
2035 format!(":\\{WINDOWS_RUNNER_ROOT_NAME}"),
2036 "the default is <system-drive> plus {WINDOWS_RUNNER_ROOT_NAME}, got {text}"
2037 );
2038 assert!(text.starts_with(|c: char| c.is_ascii_uppercase()));
2039 }
2040
2041 #[cfg(windows)]
2042 #[test]
2043 #[serial_test::serial(environment)]
2044 fn the_windows_default_ignores_a_rewritten_system_drive_variable() {
2045 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2049 let before = default_runner_root(&paths).expect("this host has a system directory");
2050
2051 let restore_drive = std::env::var_os("SystemDrive");
2052 let restore_root = std::env::var_os("SystemRoot");
2053 unsafe {
2056 std::env::set_var("SystemDrive", "Q:");
2057 std::env::set_var("SystemRoot", "Q:\\Windows");
2058 }
2059 let after = default_runner_root(&paths);
2060 unsafe {
2062 match restore_drive {
2063 Some(value) => std::env::set_var("SystemDrive", value),
2064 None => std::env::remove_var("SystemDrive"),
2065 }
2066 match restore_root {
2067 Some(value) => std::env::set_var("SystemRoot", value),
2068 None => std::env::remove_var("SystemRoot"),
2069 }
2070 }
2071
2072 let after = after.expect("the kernel still answers");
2073 assert_eq!(after, before);
2074 assert_ne!(after.as_str(), "Q:\\rman");
2075 }
2076
2077 #[test]
2080 fn overlap_is_decided_component_by_component_on_both_platforms() {
2081 let cases = [
2082 (Unix, "/srv/rman", "/srv/rman", Overlap::Same),
2083 (Unix, "/srv/rman/s1", "/srv/rman", Overlap::Inside),
2084 (Unix, "/srv", "/srv/rman", Overlap::Contains),
2085 (Unix, "/srv/rman", "/srv/other", Overlap::Disjoint),
2086 (Unix, "/srv/rman-old", "/srv/rman", Overlap::Disjoint),
2088 (Unix, "/", "/srv/rman", Overlap::Contains),
2089 (Unix, "/srv/Rman", "/srv/rman", Overlap::Disjoint),
2092 (Windows, "C:\\rman", "C:\\rman", Overlap::Same),
2093 (Windows, "C:\\RMAN", "c:\\rman", Overlap::Same),
2094 (Windows, "C:\\rman\\s1", "C:\\rman", Overlap::Inside),
2095 (Windows, "C:\\", "C:\\rman", Overlap::Contains),
2096 (Windows, "D:\\rman", "C:\\rman", Overlap::Disjoint),
2099 (Windows, "C:\\rman-old", "C:\\rman", Overlap::Disjoint),
2100 ];
2101 for (platform, candidate, other, expected) in cases {
2102 assert_eq!(
2103 overlap_of(candidate, other, platform),
2104 expected,
2105 "{platform}: {candidate:?} vs {other:?}"
2106 );
2107 }
2108 }
2109
2110 #[test]
2111 fn a_filesystem_root_never_reaches_the_preflight() {
2112 for (raw, platform) in [("/", Unix), ("C:\\", Windows), ("c:/", Windows)] {
2116 assert!(
2117 LocalAbsolutePath::parse_for(raw, platform).is_err(),
2118 "{raw:?} must not be storable"
2119 );
2120 }
2121 let (root, below) = if cfg!(windows) {
2122 ("C:\\", "C:\\rman")
2123 } else {
2124 ("/", "/srv")
2125 };
2126 assert!(is_filesystem_root(Path::new(root)));
2127 assert!(!is_filesystem_root(Path::new(below)));
2128 }
2129
2130 #[test]
2133 fn an_existing_writable_local_directory_is_accepted() {
2134 let fixture = fixture();
2135 let root = fixture.workspaces.join("rman");
2136 std::fs::create_dir(&root).expect("the root is created");
2137
2138 let checked = fixture
2139 .check(&root)
2140 .expect("a plain writable directory on this machine is usable");
2141
2142 assert!(checked.exists());
2143 assert_eq!(checked.leaf_to_create(), None);
2144 assert_eq!(checked.filesystem().locality, Locality::Local);
2145 assert_eq!(
2146 checked.canonical(),
2147 plain(&std::fs::canonicalize(&root).expect("it exists"))
2148 );
2149 }
2150
2151 #[test]
2152 fn a_missing_leaf_below_a_writable_parent_is_accepted_and_not_created() {
2153 let fixture = fixture();
2154 let root = fixture.workspaces.join("rman");
2155
2156 let checked = fixture.check(&root).expect("a creatable leaf is usable");
2157
2158 assert!(!checked.exists());
2159 assert_eq!(checked.leaf_to_create(), Some(root.as_path()));
2160 assert!(
2161 !root.exists(),
2162 "creation is the caller's explicit step, never the preflight's"
2163 );
2164 }
2165
2166 #[test]
2167 fn more_than_one_missing_level_is_refused_with_the_deepest_directory() {
2168 let fixture = fixture();
2169 let root = fixture.workspaces.join("a").join("b");
2170
2171 let error = fixture
2172 .check(&root)
2173 .expect_err("only the leaf may be missing");
2174 let RunnerRootError::MissingParents {
2175 deepest_existing, ..
2176 } = &error
2177 else {
2178 panic!("expected MissingParents, got {error}");
2179 };
2180 assert_eq!(deepest_existing, &fixture.workspaces);
2181 }
2182
2183 #[test]
2184 fn an_existing_file_is_refused() {
2185 let fixture = fixture();
2186 let root = fixture.workspaces.join("rman");
2187 std::fs::write(&root, b"not a directory").expect("the file is created");
2188
2189 let error = fixture
2190 .check(&root)
2191 .expect_err("a file is not a runner root");
2192 assert!(
2193 matches!(error, RunnerRootError::ExistingFile { .. }),
2194 "got {error}"
2195 );
2196 }
2197
2198 #[test]
2199 fn a_file_where_the_parent_should_be_is_refused() {
2200 let fixture = fixture();
2201 let file = fixture.workspaces.join("notes.txt");
2202 std::fs::write(&file, b"notes").expect("the file is created");
2203 let root = file.join("rman");
2204
2205 let error = fixture
2206 .check(&root)
2207 .expect_err("nothing can be created inside a file");
2208 assert!(
2209 matches!(error, RunnerRootError::ParentIsNotADirectory { .. }),
2210 "got {error}"
2211 );
2212 }
2213
2214 #[test]
2215 fn a_linked_root_is_refused_rather_than_followed() {
2216 let fixture = fixture();
2217 let target = fixture.workspaces.join("real");
2218 std::fs::create_dir(&target).expect("the target is created");
2219 let root = fixture.workspaces.join("rman");
2220 if !link_dir(&target, &root) {
2221 return;
2222 }
2223
2224 let error = fixture
2225 .check(&root)
2226 .expect_err("a runner root is the base of a recursive cleanup");
2227 assert!(
2228 matches!(error, RunnerRootError::Symlinked { .. }),
2229 "got {error}"
2230 );
2231 }
2232
2233 #[test]
2234 fn a_link_whose_target_is_gone_is_still_reported_as_a_link() {
2235 let fixture = fixture();
2240 let root = fixture.workspaces.join("rman");
2241 if !link_dir(&fixture.workspaces.join("gone"), &root) {
2242 return;
2243 }
2244
2245 let error = fixture
2246 .check(&root)
2247 .expect_err("a dangling link is not a runner root");
2248 assert!(
2249 matches!(error, RunnerRootError::Symlinked { .. }),
2250 "got {error}"
2251 );
2252 }
2253
2254 #[test]
2255 fn a_link_in_the_path_cannot_smuggle_a_root_into_application_data() {
2256 let fixture = fixture();
2257 let bridge = fixture.workspaces.join("bridge");
2258 if !link_dir(fixture.paths.state_dir(), &bridge) {
2259 return;
2260 }
2261 let root = bridge.join("rman");
2264
2265 let error = fixture
2266 .check(&root)
2267 .expect_err("the canonical check must see through the link");
2268 let RunnerRootError::Overlaps { relation, .. } = &error else {
2269 panic!("expected Overlaps, got {error}");
2270 };
2271 assert_eq!(*relation, Overlap::Inside);
2272 }
2273
2274 #[test]
2275 fn a_root_that_collides_with_application_data_is_refused() {
2276 let fixture = fixture();
2277 let preflight = RootPreflight::new(&fixture.paths);
2278 let cases = [
2279 (fixture.paths.state_dir().to_path_buf(), Overlap::Same),
2281 (fixture.paths.logs_dir().join("rman"), Overlap::Inside),
2283 (fixture.root.path().to_path_buf(), Overlap::Contains),
2285 ];
2286 for (candidate, expected) in cases {
2287 let error = preflight
2288 .check(&RootOwner::Host, &native(&candidate))
2289 .expect_err("application data may not share a tree with runner workspaces");
2290 let RunnerRootError::Overlaps { relation, .. } = &error else {
2291 panic!("{} gave {error}", candidate.display());
2292 };
2293 assert_eq!(*relation, expected, "{}", candidate.display());
2294 }
2295 }
2296
2297 #[test]
2298 fn the_macos_shaped_layout_still_accepts_its_own_runtime_directory() {
2299 let root = tempfile::tempdir().expect("a temporary directory");
2304 let base = root.path();
2305 let paths = AppPaths::from_directories(
2306 base,
2307 base.join("state"),
2308 base.join("runtime"),
2309 base.join("logs"),
2310 );
2311 paths.create_all().expect("the layout is created");
2312 let preflight = RootPreflight::new(&paths);
2313
2314 preflight
2315 .check(&RootOwner::Host, &native(paths.runtime_dir()))
2316 .expect("the platform default must pass its own preflight");
2317 preflight
2318 .check(
2319 &RootOwner::Host,
2320 &native(&paths.runtime_dir().join("nested")),
2321 )
2322 .expect("a directory below the runtime directory is still the runner area");
2323
2324 for refused in [base.to_path_buf(), base.join("state"), base.join("beside")] {
2325 let error = preflight
2326 .check(&RootOwner::Host, &native(&refused))
2327 .expect_err("only the runtime subtree is exempt");
2328 assert!(
2329 matches!(error, RunnerRootError::Overlaps { .. }),
2330 "{} gave {error}",
2331 refused.display()
2332 );
2333 }
2334 }
2335
2336 #[test]
2337 fn two_roots_may_not_contain_one_another() {
2338 let fixture = fixture();
2339 let host = fixture.workspaces.join("host");
2340 let repository = host.join("acme");
2341 let other = fixture.workspaces.join("other");
2342 std::fs::create_dir_all(&repository).expect("both roots are created");
2343 std::fs::create_dir(&other).expect("the third root is created");
2344
2345 let owner = RootOwner::Repository("acme/widgets".to_string());
2346 let preflight = RootPreflight::new(&fixture.paths)
2347 .against(RootOwner::Host, native(&host))
2348 .against(
2349 RootOwner::Repository("acme/gadgets".to_string()),
2350 native(&other),
2351 );
2352
2353 let error = preflight
2354 .check(&owner, &native(&repository))
2355 .expect_err("a repository root inside the host root is refused");
2356 let RunnerRootError::Overlaps {
2357 relation,
2358 other_owner,
2359 ..
2360 } = &error
2361 else {
2362 panic!("expected Overlaps, got {error}");
2363 };
2364 assert_eq!(*relation, Overlap::Inside);
2365 assert_eq!(other_owner, &RootOwner::Host.to_string());
2366
2367 let error = preflight
2368 .check(&owner, &native(&other))
2369 .expect_err("two repositories may not share a root");
2370 assert!(
2371 matches!(error, RunnerRootError::Overlaps { .. }),
2372 "got {error}"
2373 );
2374 }
2375
2376 #[test]
2377 fn a_root_does_not_overlap_itself_when_it_is_revalidated() {
2378 let fixture = fixture();
2379 let root = fixture.workspaces.join("acme");
2380 std::fs::create_dir(&root).expect("the root is created");
2381 let owner = RootOwner::Repository("acme/widgets".to_string());
2382
2383 RootPreflight::new(&fixture.paths)
2384 .against(owner.clone(), native(&root))
2385 .check(&owner, &native(&root))
2386 .expect("re-checking a stored setting must not report it against itself");
2387 }
2388
2389 #[test]
2390 fn a_row_written_on_another_operating_system_fails_closed() {
2391 let fixture = fixture();
2392 let error = RootPreflight::new(&fixture.paths)
2393 .check(&RootOwner::Host, &foreign())
2394 .expect_err("a foreign path is corrupt state on this host");
2395 assert!(
2396 matches!(error, RunnerRootError::ForeignPlatform { .. }),
2397 "got {error}"
2398 );
2399 }
2400
2401 #[test]
2404 fn a_remote_filesystem_is_refused() {
2405 let fixture = fixture();
2406 let root = fixture.workspaces.join("rman");
2407 std::fs::create_dir(&root).expect("the root is created");
2408 let probe = StubFilesystem::saying(FilesystemIdentity::remote("nfs"));
2409
2410 let error = RootPreflight::with_probe(&fixture.paths, &probe)
2411 .check(&RootOwner::Host, &native(&root))
2412 .expect_err("a network share may not hold runner workspaces");
2413 assert!(
2414 matches!(error, RunnerRootError::RemoteFilesystem { .. }),
2415 "got {error}"
2416 );
2417 }
2418
2419 #[test]
2420 fn a_filesystem_this_host_cannot_classify_fails_closed() {
2421 let fixture = fixture();
2422 let root = fixture.workspaces.join("rman");
2423 std::fs::create_dir(&root).expect("the root is created");
2424 let probe =
2425 StubFilesystem::saying(FilesystemIdentity::unprovable("filesystem type 0x00001234"));
2426
2427 let error = RootPreflight::with_probe(&fixture.paths, &probe)
2428 .check(&RootOwner::Host, &native(&root))
2429 .expect_err("unprovable locality is a refusal, not a shrug");
2430 assert!(
2431 matches!(error, RunnerRootError::UnprovableFilesystem { .. }),
2432 "got {error}"
2433 );
2434 }
2435
2436 #[cfg(target_os = "macos")]
2444 #[test]
2445 fn a_refusal_the_superuser_received_names_the_privacy_control_not_the_permissions() {
2446 let fixture = fixture();
2447 let existing = fixture.workspaces.join("rman");
2448 std::fs::create_dir(&existing).expect("the root is created");
2449 let probe = StubFilesystem::unwritable_to_the_superuser();
2450 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2451
2452 let error = preflight
2453 .check(&RootOwner::Host, &native(&existing))
2454 .expect_err("a root the service cannot write is unusable");
2455
2456 assert!(
2457 matches!(error, RunnerRootError::DeniedByPrivacyPolicy { .. }),
2458 "a superuser cannot be refused by file permissions, so this is the \
2459 privacy layer: {error}"
2460 );
2461 let rendered = error.to_string();
2462 assert!(
2463 rendered.contains("Full Disk Access"),
2464 "the refusal must name the control that grants it: {rendered}"
2465 );
2466 assert!(
2467 rendered.contains(&RootOwner::Host.remediation()),
2468 "the refusal must still show the command that moves the root: {rendered}"
2469 );
2470 }
2471
2472 #[cfg(target_os = "macos")]
2477 #[test]
2478 fn a_superuser_refused_on_the_startup_disk_is_not_blamed_on_the_privacy_layer() {
2479 let fixture = fixture();
2480 let existing = fixture.workspaces.join("rman");
2481 std::fs::create_dir(&existing).expect("the root is created");
2482 let probe = StubFilesystem::unwritable_to_the_superuser_on_the_startup_disk();
2483 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2484
2485 let error = preflight
2486 .check(&RootOwner::Host, &native(&existing))
2487 .expect_err("an unwritable root is unusable");
2488
2489 assert!(
2490 matches!(error, RunnerRootError::NotWritable { .. }),
2491 "the privacy layer gates no directory on the startup disk: {error}"
2492 );
2493 }
2494
2495 #[cfg(target_os = "macos")]
2501 #[test]
2502 fn a_read_only_volume_is_not_blamed_on_the_privacy_layer() {
2503 let fixture = fixture();
2504 let existing = fixture.workspaces.join("rman");
2505 std::fs::create_dir(&existing).expect("the root is created");
2506 let probe = StubFilesystem::read_only_gated_volume();
2507 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2508
2509 let error = preflight
2510 .check(&RootOwner::Host, &native(&existing))
2511 .expect_err("a read-only root is unusable");
2512
2513 assert!(
2514 matches!(error, RunnerRootError::NotWritable { .. }),
2515 "consent cannot make a read-only mount writable: {error}"
2516 );
2517 }
2518
2519 #[cfg(target_os = "macos")]
2523 #[test]
2524 fn a_privacy_refusal_names_the_root_that_was_asked_for_and_the_one_that_refused() {
2525 let fixture = fixture();
2526 let leaf = fixture.workspaces.join("not-created-yet");
2527 let probe = StubFilesystem::unwritable_to_the_superuser();
2528 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2529
2530 let error = preflight
2531 .check(&RootOwner::Host, &native(&leaf))
2532 .expect_err("a root the service cannot create is unusable");
2533
2534 let rendered = error.to_string();
2535 assert!(
2536 rendered.contains("not-created-yet"),
2537 "the root the operator asked for is missing from the refusal: {rendered}"
2538 );
2539 assert!(
2540 rendered.contains(&fixture.workspaces.display().to_string()),
2541 "the directory that actually refused is missing from the refusal: {rendered}"
2542 );
2543 }
2544
2545 #[test]
2548 fn a_refusal_an_ordinary_account_received_still_names_file_permissions() {
2549 let fixture = fixture();
2550 let existing = fixture.workspaces.join("rman");
2551 std::fs::create_dir(&existing).expect("the root is created");
2552 let probe = StubFilesystem::unwritable();
2553 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2554
2555 let error = preflight
2556 .check(&RootOwner::Host, &native(&existing))
2557 .expect_err("an unwritable root is unusable");
2558
2559 assert!(
2560 matches!(error, RunnerRootError::NotWritable { .. }),
2561 "got {error}"
2562 );
2563 }
2564
2565 #[test]
2566 fn an_unwritable_directory_and_an_unwritable_parent_are_reported_apart() {
2567 let fixture = fixture();
2568 let existing = fixture.workspaces.join("rman");
2569 std::fs::create_dir(&existing).expect("the root is created");
2570 let missing = fixture.workspaces.join("other");
2571 let probe = StubFilesystem::unwritable();
2572 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2573
2574 let error = preflight
2575 .check(&RootOwner::Host, &native(&existing))
2576 .expect_err("an unwritable root is unusable");
2577 assert!(
2578 matches!(error, RunnerRootError::NotWritable { .. }),
2579 "got {error}"
2580 );
2581 assert!(
2582 error.to_string().contains(&RootOwner::Host.remediation()),
2583 "the refusal must show the command that fixes it: {error}"
2584 );
2585
2586 let error = preflight
2587 .check(&RootOwner::Host, &native(&missing))
2588 .expect_err("an unwritable parent cannot hold a new leaf");
2589 let RunnerRootError::ParentNotWritable { parent, leaf, .. } = &error else {
2590 panic!("expected ParentNotWritable, got {error}");
2591 };
2592 assert_eq!(parent, &fixture.workspaces);
2593 assert_eq!(leaf, &missing);
2594 assert!(
2595 error.to_string().contains(&RootOwner::Host.remediation()),
2596 "the refusal must show the command that fixes it: {error}"
2597 );
2598
2599 let repository = RootOwner::Repository("acme/widgets".to_string());
2600 let error = preflight
2601 .check(&repository, &native(&missing))
2602 .expect_err("an unwritable parent cannot hold a new leaf");
2603 assert!(
2604 error.to_string().contains(&repository.remediation()),
2605 "a repository root must name its own command: {error}"
2606 );
2607 }
2608
2609 #[test]
2610 fn nothing_is_created_removed_or_repermissioned_by_any_verdict() {
2611 let fixture = fixture();
2615 let existing = fixture.workspaces.join("rman");
2616 std::fs::create_dir(&existing).expect("the root is created");
2617 let file = fixture.workspaces.join("notes.txt");
2618 std::fs::write(&file, b"operator data").expect("the file is created");
2619
2620 let before = snapshot(fixture.root.path());
2621
2622 let stub = StubFilesystem::unwritable();
2623 let host_preflight = RootPreflight::new(&fixture.paths);
2624 let stub_preflight = RootPreflight::with_probe(&fixture.paths, &stub);
2625 for preflight in [&host_preflight, &stub_preflight] {
2626 for candidate in [
2627 existing.clone(),
2628 fixture.workspaces.join("missing"),
2629 fixture.workspaces.join("a").join("b"),
2630 file.clone(),
2631 file.join("leaf"),
2632 fixture.paths.state_dir().to_path_buf(),
2633 ] {
2634 let _ = preflight.check(&RootOwner::Host, &native(&candidate));
2635 }
2636 }
2637
2638 assert_eq!(
2639 before,
2640 snapshot(fixture.root.path()),
2641 "the preflight changed the filesystem"
2642 );
2643 }
2644
2645 #[test]
2648 fn this_machines_temporary_directory_is_local_and_writable() {
2649 let root = tempfile::tempdir().expect("a temporary directory");
2650 let canonical = plain(&std::fs::canonicalize(root.path()).expect("it exists"));
2651
2652 let identity = HostFilesystem
2653 .identify(&canonical)
2654 .expect("the platform answers");
2655 assert_eq!(
2656 identity.locality,
2657 Locality::Local,
2658 "the suite's own temporary directory reported {identity:?}; a CI leg whose \
2659 temporary filesystem is unknown to this table would refuse every runner root"
2660 );
2661 assert!(
2662 HostFilesystem
2663 .is_writable(&canonical)
2664 .expect("the platform answers"),
2665 "a directory this process just created must be writable"
2666 );
2667 }
2668
2669 #[cfg(windows)]
2670 #[test]
2671 fn the_system_drive_root_is_writable_when_a_directory_can_be_created_in_it() {
2672 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2679 let default = default_runner_root(&paths).expect("this host has a system directory");
2680 let parent = default
2681 .as_path()
2682 .parent()
2683 .expect("the default root is one level below the system drive")
2684 .to_path_buf();
2685
2686 let probe = parent.join(format!("rman-preflight-probe-{}", std::process::id()));
2687 if std::fs::create_dir(&probe).is_err() {
2688 return;
2689 }
2690 let writable = HostFilesystem.is_writable(&parent);
2691 std::fs::remove_dir(&probe).expect("the probe is removed");
2692
2693 assert!(
2694 writable.expect("the platform answers"),
2695 "{} accepts a new directory, so the preflight must not refuse the default \
2696 runner root as unwritable",
2697 parent.display()
2698 );
2699 }
2700
2701 #[cfg(unix)]
2702 #[test]
2703 fn a_directory_this_account_cannot_write_is_reported_as_such() {
2704 use std::os::unix::fs::PermissionsExt;
2705
2706 let root = tempfile::tempdir().expect("a temporary directory");
2707 let locked = root.path().join("locked");
2708 std::fs::create_dir(&locked).expect("the directory is created");
2709 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555))
2710 .expect("the directory is made unwritable");
2711
2712 let probe = locked.join("probe");
2715 let is_root = std::fs::create_dir(&probe).is_ok();
2716 if is_root {
2717 std::fs::remove_dir(&probe).expect("the probe is removed");
2718 }
2719 let writable = HostFilesystem.is_writable(&locked);
2720 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
2721 .expect("the directory is restored");
2722 if is_root {
2723 return;
2724 }
2725 assert!(
2726 !writable.expect("the platform answers"),
2727 "a 0555 directory must not be reported writable"
2728 );
2729 }
2730
2731 #[test]
2734 fn a_derived_child_is_one_component_below_the_root() {
2735 let root = LocalAbsolutePath::parse_for("/srv/rman", Unix).expect("a valid root");
2736 assert_eq!(
2737 derive_child(&root, "s1").expect("a valid slot").as_str(),
2738 "/srv/rman/s1"
2739 );
2740 let root = LocalAbsolutePath::parse_for("C:\\rman", Windows).expect("a valid root");
2741 assert_eq!(
2742 derive_child(&root, "0123456789ab")
2743 .expect("a valid attempt")
2744 .as_str(),
2745 "C:\\rman\\0123456789ab"
2746 );
2747 for name in ["..", "a/b", "", "."] {
2748 assert!(
2749 derive_child(&root, name).is_err(),
2750 "{name:?} must not be a derived child"
2751 );
2752 }
2753 }
2754
2755 #[test]
2756 fn containment_is_proven_lexically_and_after_resolution() {
2757 let fixture = fixture();
2758 let directory = fixture.workspaces.join("rman");
2759 std::fs::create_dir(&directory).expect("the root is created");
2760 let root = native(&directory);
2761
2762 let slot = derive_child(&root, "s1").expect("a valid slot");
2763 verify_containment(&root, &slot)
2764 .expect("a slot that does not exist yet is still contained");
2765 std::fs::create_dir(slot.as_path()).expect("the slot is created");
2766 verify_containment(&root, &slot).expect("an existing slot is contained");
2767
2768 let sibling = native(&fixture.workspaces.join("elsewhere"));
2769 assert!(
2770 matches!(
2771 verify_containment(&root, &sibling),
2772 Err(RunnerRootError::Escapes { .. })
2773 ),
2774 "a sibling is not contained"
2775 );
2776 assert!(
2777 matches!(
2778 verify_containment(&root, &root),
2779 Err(RunnerRootError::Escapes { .. })
2780 ),
2781 "the root is not strictly inside itself"
2782 );
2783 }
2784
2785 #[test]
2786 fn a_link_inside_the_root_that_points_outside_it_is_not_contained() {
2787 let fixture = fixture();
2788 let root = fixture.workspaces.join("rman");
2789 std::fs::create_dir(&root).expect("the root is created");
2790 let outside = fixture.workspaces.join("outside");
2791 std::fs::create_dir(&outside).expect("the escape target is created");
2792 let escape = root.join("s1");
2793 if !link_dir(&outside, &escape) {
2794 return;
2795 }
2796
2797 let error = verify_containment(&native(&root), &native(&escape))
2798 .expect_err("cleanup may not follow a link out of the root it was given");
2799 assert!(
2800 matches!(error, RunnerRootError::Escapes { .. }),
2801 "got {error}"
2802 );
2803 }
2804
2805 #[test]
2808 fn each_owner_names_the_command_that_changes_it() {
2809 assert_eq!(
2810 RootOwner::Host.remediation(),
2811 "runner-manager host set-runtime-root --path <PATH>"
2812 );
2813 assert_eq!(
2814 RootOwner::Repository("acme/widgets".to_string()).remediation(),
2815 "runner-manager repo set-workspace acme/widgets --mode persistent --path <PATH>"
2816 );
2817 assert!(RootOwner::Host.to_string().contains("host runner root"));
2818 assert!(
2819 RootOwner::Repository("acme/widgets".to_string())
2820 .to_string()
2821 .contains("acme/widgets")
2822 );
2823 }
2824
2825 #[test]
2826 fn a_refusal_names_the_paths_and_says_what_to_do_about_it() {
2827 let fixture = fixture();
2831 let root = fixture.workspaces.join("rman");
2832 std::fs::create_dir(&root).expect("the root is created");
2833
2834 let probe = StubFilesystem::saying(FilesystemIdentity::remote("nfs"));
2835 let message = RootPreflight::with_probe(&fixture.paths, &probe)
2836 .check(&RootOwner::Host, &native(&root))
2837 .expect_err("a network share is refused")
2838 .to_string();
2839 assert!(message.contains("nfs"), "{message}");
2840 assert!(message.contains("local volume"), "{message}");
2841
2842 let message = fixture
2843 .check(fixture.paths.state_dir())
2844 .expect_err("application data is protected")
2845 .to_string();
2846 assert!(
2847 message.contains(&fixture.paths.state_dir().display().to_string()),
2848 "the directory that refused must be named: {message}"
2849 );
2850 assert!(
2851 message.contains("the application state directory"),
2852 "the operator must be told what it collided with: {message}"
2853 );
2854 assert!(
2855 message.contains("outside the application data tree"),
2856 "the message must say what to do instead: {message}"
2857 );
2858 }
2859}