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}
85
86impl RootOwner {
87 #[must_use]
93 pub fn remediation(&self) -> String {
94 match self {
95 RootOwner::Host => "runner-manager host set-runtime-root --path <PATH>".to_string(),
96 RootOwner::Repository(repository) => format!(
97 "runner-manager repo set-workspace {repository} --mode persistent --path <PATH>"
98 ),
99 }
100 }
101}
102
103impl fmt::Display for RootOwner {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 RootOwner::Host => f.write_str("the host runner root"),
107 RootOwner::Repository(repository) => {
108 write!(f, "the persistent workspace root for {repository}")
109 }
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum Overlap {
126 Disjoint,
128 Same,
130 Inside,
132 Contains,
134}
135
136impl fmt::Display for Overlap {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.write_str(match self {
139 Overlap::Disjoint => "is unrelated to",
140 Overlap::Same => "is the same directory as",
141 Overlap::Inside => "is inside",
142 Overlap::Contains => "contains",
143 })
144 }
145}
146
147fn components_of(path: &str, platform: PathPlatform) -> Vec<&str> {
156 path.split(|c| platform.is_separator(c))
157 .filter(|component| !component.is_empty() && *component != ".")
158 .collect()
159}
160
161fn same_component(left: &str, right: &str, platform: PathPlatform) -> bool {
172 match platform {
173 PathPlatform::Windows => left
174 .chars()
175 .flat_map(char::to_lowercase)
176 .eq(right.chars().flat_map(char::to_lowercase)),
177 PathPlatform::Unix => left == right,
178 }
179}
180
181fn overlap_of(candidate: &str, other: &str, platform: PathPlatform) -> Overlap {
183 let left = components_of(candidate, platform);
184 let right = components_of(other, platform);
185 let shared = left
186 .iter()
187 .zip(right.iter())
188 .take_while(|(l, r)| same_component(l, r, platform))
189 .count();
190 if shared < left.len().min(right.len()) {
191 return Overlap::Disjoint;
192 }
193 match left.len().cmp(&right.len()) {
194 Ordering::Equal => Overlap::Same,
195 Ordering::Greater => Overlap::Inside,
196 Ordering::Less => Overlap::Contains,
197 }
198}
199
200#[derive(Debug, thiserror::Error)]
211pub enum RunnerRootError {
212 #[error(
213 "the operating system did not report a system directory, so the default runner \
214 root <system-drive>\\{WINDOWS_RUNNER_ROOT_NAME} cannot be resolved: {source}. \
215 Configure one explicitly with `{}`.",
216 RootOwner::Host.remediation()
217 )]
218 SystemDirectoryUnavailable {
219 #[source]
220 source: io::Error,
221 },
222
223 #[error(
224 "the system directory {got:?} is not a usable volume for the default runner \
225 root: {source}. Configure one explicitly with `{}`.",
226 RootOwner::Host.remediation()
227 )]
228 SystemDirectoryUnusable {
229 got: String,
230 #[source]
231 source: LocalPathError,
232 },
233
234 #[error(
235 "the application runtime directory {} cannot be used as the default runner \
236 root: {source}",
237 got.display()
238 )]
239 ApplicationRuntimeDirectoryUnusable {
240 got: PathBuf,
241 #[source]
242 source: LocalPathError,
243 },
244
245 #[error(
246 "{} cannot be represented as text, and a runner root is stored, printed and \
247 compared as text",
248 got.display()
249 )]
250 NonUnicode { got: PathBuf },
251
252 #[error(
253 "{got:?} is written in {platform} path syntax, but this host uses {}; a row \
254 written on another operating system is corrupt state here rather than a \
255 usable root",
256 PathPlatform::NATIVE
257 )]
258 ForeignPlatform { got: String, platform: PathPlatform },
259
260 #[error("cannot inspect {}: {source}", path.display())]
261 Inspect {
262 path: PathBuf,
263 #[source]
264 source: io::Error,
265 },
266
267 #[error(
268 "{} already exists and is not a directory; a runner root is a directory that \
269 attempt directories are created inside",
270 path.display()
271 )]
272 ExistingFile { path: PathBuf },
273
274 #[error(
275 "{} is a symbolic link, junction or other reparse point. A runner root is the \
276 base of a recursive cleanup, so it must be the real directory rather than a \
277 name that can be repointed at one; configure the target directly.",
278 path.display()
279 )]
280 Symlinked { path: PathBuf },
281
282 #[error(
283 "{} cannot be created because more than its last component is missing; the \
284 deepest directory that does exist is {}. Create the intermediate directories \
285 first, or configure a path one level below an existing directory.",
286 path.display(),
287 deepest_existing.display()
288 )]
289 MissingParents {
290 path: PathBuf,
291 deepest_existing: PathBuf,
292 },
293
294 #[error(
295 "{} exists but is not a directory, so nothing can be created inside it",
296 parent.display()
297 )]
298 ParentIsNotADirectory { parent: PathBuf },
299
300 #[error(
301 "{} exists but this account may not create entries in it. Grant this account \
302 write access, or configure a directory it owns with `{remediation}`.",
303 path.display()
304 )]
305 NotWritable { path: PathBuf, remediation: String },
306
307 #[error(
308 "the runner root {} cannot be used: this process runs as the superuser, so file \
309 permissions are not what refused {}, and that directory is on a volume macOS \
310 withholds through its privacy controls. Grant Full Disk Access to the program \
311 that runs the service -- System Settings > Privacy & Security > Full Disk \
312 Access -- and start the service again, or configure a directory on the startup \
313 disk with `{remediation}`. Note that the grant follows the binary and not the \
314 path: an upgrade that replaces the service binary revokes it, and it has to be \
315 granted again to the new one.",
316 requested.display(),
317 refused.display()
318 )]
319 DeniedByPrivacyPolicy {
320 requested: PathBuf,
322 refused: PathBuf,
324 remediation: String,
325 },
326
327 #[error(
328 "{} does not exist yet and this account may not create it: its parent {} \
329 refuses. Grant this account write access to that directory, or configure a \
330 directory it owns with `{remediation}`.",
331 leaf.display(),
332 parent.display()
333 )]
334 ParentNotWritable {
335 parent: PathBuf,
336 leaf: PathBuf,
337 remediation: String,
338 },
339
340 #[error(
341 "{} is on {filesystem}. Runner correctness and restart recovery may not depend \
342 on a remote share that can disappear or change identity while a job runs \
343 (D10); configure a directory on a local volume.",
344 path.display()
345 )]
346 RemoteFilesystem { path: PathBuf, filesystem: String },
347
348 #[error(
349 "this host cannot prove that {} is on a local filesystem (it reported \
350 {filesystem}). A runner root is accepted only when locality is provable, so \
351 this fails closed; configure a directory on a local volume.",
352 path.display()
353 )]
354 UnprovableFilesystem { path: PathBuf, filesystem: String },
355
356 #[error(
357 "{} resolves to {}, which is a filesystem root. A runner root must be a \
358 directory below a root, because everything inside it is removed on cleanup.",
359 path.display(),
360 canonical.display()
361 )]
362 ResolvesToFilesystemRoot { path: PathBuf, canonical: PathBuf },
363
364 #[error(
365 "{} {relation} {other_owner} ({}). {detail}",
366 candidate.display(),
367 other.display()
368 )]
369 Overlaps {
370 candidate: PathBuf,
371 relation: Overlap,
372 other: PathBuf,
373 other_owner: String,
374 detail: &'static str,
375 },
376
377 #[error(
378 "{} is derived from the runner root {} but resolves to {}, which is outside it",
379 child.display(),
380 root.display(),
381 resolved.display()
382 )]
383 Escapes {
384 root: PathBuf,
385 child: PathBuf,
386 resolved: PathBuf,
387 },
388
389 #[error("{source}")]
390 DerivedName {
391 #[source]
392 source: LocalPathError,
393 },
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum Locality {
403 Local,
405 Remote,
407 Unprovable,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct FilesystemIdentity {
417 pub locality: Locality,
419 pub name: String,
421}
422
423impl FilesystemIdentity {
424 #[must_use]
426 pub fn local(name: impl Into<String>) -> Self {
427 Self {
428 locality: Locality::Local,
429 name: name.into(),
430 }
431 }
432
433 #[must_use]
435 pub fn remote(name: impl Into<String>) -> Self {
436 Self {
437 locality: Locality::Remote,
438 name: name.into(),
439 }
440 }
441
442 #[must_use]
444 pub fn unprovable(name: impl Into<String>) -> Self {
445 Self {
446 locality: Locality::Unprovable,
447 name: name.into(),
448 }
449 }
450}
451
452pub trait FilesystemProbe {
462 fn identify(&self, directory: &Path) -> io::Result<FilesystemIdentity>;
467
468 fn is_writable(&self, directory: &Path) -> io::Result<bool>;
476
477 fn runs_as_superuser(&self) -> bool {
487 sys::runs_as_superuser()
488 }
489
490 fn is_on_privacy_gated_volume(&self, directory: &Path) -> bool {
496 sys::is_on_privacy_gated_volume(directory)
497 }
498
499 fn is_read_only(&self, directory: &Path) -> bool {
509 sys::is_read_only(directory)
510 }
511}
512
513#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
515pub struct HostFilesystem;
516
517impl FilesystemProbe for HostFilesystem {
518 fn identify(&self, directory: &Path) -> io::Result<FilesystemIdentity> {
519 sys::identify(directory)
520 }
521
522 fn is_writable(&self, directory: &Path) -> io::Result<bool> {
523 sys::is_writable(directory)
524 }
525}
526
527static HOST_FILESYSTEM: HostFilesystem = HostFilesystem;
529
530impl RunnerRootError {
531 #[must_use]
547 pub const fn kind(&self) -> &'static str {
548 match self {
549 Self::SystemDirectoryUnavailable { .. } => "system_directory_unavailable",
550 Self::SystemDirectoryUnusable { .. } => "system_directory_unusable",
551 Self::ApplicationRuntimeDirectoryUnusable { .. } => "runtime_directory_unusable",
552 Self::NonUnicode { .. } => "non_unicode",
553 Self::ForeignPlatform { .. } => "foreign_platform",
554 Self::Inspect { .. } => "not_inspectable",
555 Self::ExistingFile { .. } => "existing_file",
556 Self::Symlinked { .. } => "symlinked",
557 Self::MissingParents { .. } => "missing_parents",
558 Self::ParentIsNotADirectory { .. } => "parent_not_a_directory",
559 Self::NotWritable { .. } => "not_writable",
560 Self::DeniedByPrivacyPolicy { .. } => "denied_by_privacy_policy",
561 Self::ParentNotWritable { .. } => "parent_not_writable",
562 Self::RemoteFilesystem { .. } => "remote_filesystem",
563 Self::UnprovableFilesystem { .. } => "unprovable_filesystem",
564 Self::ResolvesToFilesystemRoot { .. } => "resolves_to_filesystem_root",
565 Self::Overlaps { .. } => "overlaps_application_data",
566 Self::Escapes { .. } => "escapes_root",
567 Self::DerivedName { .. } => "underivable_name",
568 }
569 }
570}
571
572#[must_use]
585pub fn is_on_privacy_gated_volume(path: &Path) -> bool {
586 sys::is_on_privacy_gated_volume(path)
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599pub enum PlatformDefault<'a> {
600 WindowsSystemDirectory(&'a str),
602 ApplicationRuntimeDirectory(&'a Path),
604}
605
606pub fn default_runner_root_from(
615 source: PlatformDefault<'_>,
616) -> Result<LocalAbsolutePath, RunnerRootError> {
617 match source {
618 PlatformDefault::WindowsSystemDirectory(raw) => {
619 let unusable = |source| RunnerRootError::SystemDirectoryUnusable {
620 got: raw.to_string(),
621 source,
622 };
623 let system =
629 LocalAbsolutePath::parse_for(raw, PathPlatform::Windows).map_err(unusable)?;
630 let volume: String = system.as_str().chars().take(3).collect();
631 LocalAbsolutePath::parse_for(
632 format!("{volume}{WINDOWS_RUNNER_ROOT_NAME}"),
633 PathPlatform::Windows,
634 )
635 .map_err(unusable)
636 }
637 PlatformDefault::ApplicationRuntimeDirectory(path) => {
638 let text = path.to_str().ok_or_else(|| RunnerRootError::NonUnicode {
639 got: path.to_path_buf(),
640 })?;
641 LocalAbsolutePath::new(text).map_err(|source| {
642 RunnerRootError::ApplicationRuntimeDirectoryUnusable {
643 got: path.to_path_buf(),
644 source,
645 }
646 })
647 }
648 }
649}
650
651#[cfg(windows)]
662pub fn default_runner_root(app_paths: &AppPaths) -> Result<LocalAbsolutePath, RunnerRootError> {
663 let _ = app_paths;
664 let system = sys::system_directory()
665 .map_err(|source| RunnerRootError::SystemDirectoryUnavailable { source })?;
666 default_runner_root_from(PlatformDefault::WindowsSystemDirectory(&system))
667}
668
669#[cfg(not(windows))]
682pub fn default_runner_root(app_paths: &AppPaths) -> Result<LocalAbsolutePath, RunnerRootError> {
683 default_runner_root_from(PlatformDefault::ApplicationRuntimeDirectory(
684 app_paths.runtime_dir(),
685 ))
686}
687
688#[derive(Debug)]
703struct Projection {
704 anchor: PathBuf,
706 anchor_as_written: PathBuf,
708 canonical: PathBuf,
710 missing: usize,
718}
719
720fn means_absent(error: &io::Error) -> bool {
729 matches!(
730 error.kind(),
731 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
732 )
733}
734
735fn project(path: &Path) -> Result<Projection, RunnerRootError> {
738 let mut missing: Vec<OsString> = Vec::new();
739 let mut cursor = path.to_path_buf();
740 loop {
741 match std::fs::symlink_metadata(&cursor) {
742 Ok(_) => break,
743 Err(error) if means_absent(&error) => {
744 let name = cursor.file_name().map(OsString::from);
745 let parent = cursor.parent().map(Path::to_path_buf);
746 let (Some(name), Some(parent)) = (name, parent) else {
747 return Err(RunnerRootError::Inspect {
750 path: cursor,
751 source: error,
752 });
753 };
754 missing.push(name);
755 cursor = parent;
756 }
757 Err(source) => {
758 return Err(RunnerRootError::Inspect {
759 path: cursor,
760 source,
761 });
762 }
763 }
764 }
765
766 let anchor = std::fs::canonicalize(&cursor)
767 .map(|canonical| plain(&canonical))
768 .map_err(|source| RunnerRootError::Inspect {
769 path: cursor.clone(),
770 source,
771 })?;
772
773 let mut canonical = anchor.clone();
774 for component in missing.iter().rev() {
775 canonical.push(component);
776 }
777 Ok(Projection {
778 anchor,
779 anchor_as_written: cursor,
780 canonical,
781 missing: missing.len(),
782 })
783}
784
785#[cfg(windows)]
792fn plain(path: &Path) -> PathBuf {
793 let text = path.to_string_lossy();
794 if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
795 return PathBuf::from(format!(r"\\{rest}"));
796 }
797 if let Some(rest) = text.strip_prefix(r"\\?\") {
798 return PathBuf::from(rest);
799 }
800 path.to_path_buf()
801}
802
803#[cfg(not(windows))]
805fn plain(path: &Path) -> PathBuf {
806 path.to_path_buf()
807}
808
809fn canonical_text(path: &Path) -> Option<String> {
818 project(path)
819 .ok()
820 .map(|projection| projection.canonical.to_string_lossy().into_owned())
821}
822
823fn is_filesystem_root(path: &Path) -> bool {
825 path.parent().is_none()
826}
827
828#[derive(Debug, Clone, PartialEq, Eq)]
840pub struct PreflightedRoot {
841 root: LocalAbsolutePath,
842 canonical: PathBuf,
843 exists: bool,
844 filesystem: FilesystemIdentity,
845}
846
847impl PreflightedRoot {
848 #[must_use]
850 pub const fn root(&self) -> &LocalAbsolutePath {
851 &self.root
852 }
853
854 #[must_use]
856 pub fn canonical(&self) -> &Path {
857 &self.canonical
858 }
859
860 #[must_use]
862 pub const fn exists(&self) -> bool {
863 self.exists
864 }
865
866 #[must_use]
868 pub fn leaf_to_create(&self) -> Option<&Path> {
869 (!self.exists).then(|| self.root.as_path())
870 }
871
872 #[must_use]
874 pub const fn filesystem(&self) -> &FilesystemIdentity {
875 &self.filesystem
876 }
877}
878
879pub struct RootPreflight<'a> {
900 app_paths: &'a AppPaths,
901 others: Vec<(RootOwner, LocalAbsolutePath)>,
902 probe: &'a dyn FilesystemProbe,
903}
904
905impl fmt::Debug for RootPreflight<'_> {
906 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
907 f.debug_struct("RootPreflight")
908 .field("app_paths", &self.app_paths)
909 .field("others", &self.others)
910 .finish_non_exhaustive()
911 }
912}
913
914impl<'a> RootPreflight<'a> {
915 #[must_use]
917 pub fn new(app_paths: &'a AppPaths) -> Self {
918 Self::with_probe(app_paths, &HOST_FILESYSTEM)
919 }
920
921 #[must_use]
923 pub fn with_probe(app_paths: &'a AppPaths, probe: &'a dyn FilesystemProbe) -> Self {
924 Self {
925 app_paths,
926 others: Vec::new(),
927 probe,
928 }
929 }
930
931 #[must_use]
939 pub fn against(mut self, owner: RootOwner, root: LocalAbsolutePath) -> Self {
940 self.others.push((owner, root));
941 self
942 }
943
944 fn protected(&self) -> [(&'static str, &Path); 3] {
953 [
954 (
955 "the application configuration directory",
956 self.app_paths.config_dir(),
957 ),
958 (
959 "the application state directory",
960 self.app_paths.state_dir(),
961 ),
962 ("the application log directory", self.app_paths.logs_dir()),
963 ]
964 }
965
966 fn reject_overlap(
972 &self,
973 owner: &RootOwner,
974 candidate: &str,
975 canonical: bool,
976 ) -> Result<(), RunnerRootError> {
977 let native = PathPlatform::NATIVE;
978 let text_of = |path: &Path| -> Option<String> {
979 if canonical {
980 canonical_text(path)
981 } else {
982 Some(path.to_string_lossy().into_owned())
983 }
984 };
985
986 let inside_runtime = || {
999 text_of(self.app_paths.runtime_dir()).is_some_and(|runtime| {
1000 matches!(
1001 overlap_of(candidate, &runtime, native),
1002 Overlap::Same | Overlap::Inside
1003 )
1004 })
1005 };
1006
1007 for (label, path) in self.protected() {
1008 let Some(other) = text_of(path) else {
1009 continue;
1010 };
1011 let relation = overlap_of(candidate, &other, native);
1012 if relation == Overlap::Disjoint || (relation == Overlap::Inside && inside_runtime()) {
1013 continue;
1014 }
1015 return Err(RunnerRootError::Overlaps {
1016 candidate: PathBuf::from(candidate),
1017 relation,
1018 other: PathBuf::from(other),
1019 other_owner: label.to_string(),
1020 detail: "Runner workspaces are removed recursively and application data must \
1021 survive that; configure a directory outside the application data tree.",
1022 });
1023 }
1024
1025 for (other_owner, root) in &self.others {
1026 if other_owner == owner {
1027 continue;
1028 }
1029 let Some(other) = text_of(root.as_path()) else {
1030 continue;
1031 };
1032 let relation = overlap_of(candidate, &other, native);
1033 if relation == Overlap::Disjoint {
1034 continue;
1035 }
1036 return Err(RunnerRootError::Overlaps {
1037 candidate: PathBuf::from(candidate),
1038 relation,
1039 other: PathBuf::from(other),
1040 other_owner: other_owner.to_string(),
1041 detail: "Two runner roots that contain one another can delete each other's \
1042 workspaces; configure directories that do not overlap.",
1043 });
1044 }
1045 Ok(())
1046 }
1047
1048 pub fn check(
1058 &self,
1059 owner: &RootOwner,
1060 root: &LocalAbsolutePath,
1061 ) -> Result<PreflightedRoot, RunnerRootError> {
1062 if root.platform() != PathPlatform::NATIVE {
1063 return Err(RunnerRootError::ForeignPlatform {
1064 got: root.as_str().to_string(),
1065 platform: root.platform(),
1066 });
1067 }
1068 let candidate = root.as_path();
1069
1070 self.reject_overlap(owner, root.as_str(), false)?;
1075
1076 match std::fs::symlink_metadata(candidate) {
1082 Ok(metadata) if metadata.file_type().is_symlink() => {
1083 return Err(RunnerRootError::Symlinked {
1084 path: candidate.to_path_buf(),
1085 });
1086 }
1087 Ok(metadata) if !metadata.is_dir() => {
1088 return Err(RunnerRootError::ExistingFile {
1089 path: candidate.to_path_buf(),
1090 });
1091 }
1092 Ok(_) => {}
1093 Err(error) if means_absent(&error) => {}
1096 Err(source) => {
1097 return Err(RunnerRootError::Inspect {
1098 path: candidate.to_path_buf(),
1099 source,
1100 });
1101 }
1102 }
1103
1104 let projection = project(candidate)?;
1105 match projection.missing {
1106 0 => {}
1107 1 => {
1108 if !projection.anchor.is_dir() {
1109 return Err(RunnerRootError::ParentIsNotADirectory {
1110 parent: projection.anchor_as_written.clone(),
1111 });
1112 }
1113 }
1114 _ => {
1115 return Err(RunnerRootError::MissingParents {
1116 path: candidate.to_path_buf(),
1117 deepest_existing: projection.anchor_as_written.clone(),
1118 });
1119 }
1120 }
1121
1122 if is_filesystem_root(&projection.canonical) {
1123 return Err(RunnerRootError::ResolvesToFilesystemRoot {
1124 path: candidate.to_path_buf(),
1125 canonical: projection.canonical.clone(),
1126 });
1127 }
1128
1129 let filesystem =
1130 self.probe
1131 .identify(&projection.anchor)
1132 .map_err(|source| RunnerRootError::Inspect {
1133 path: projection.anchor.clone(),
1134 source,
1135 })?;
1136 match filesystem.locality {
1137 Locality::Local => {}
1138 Locality::Remote => {
1139 return Err(RunnerRootError::RemoteFilesystem {
1140 path: projection.canonical.clone(),
1141 filesystem: filesystem.name,
1142 });
1143 }
1144 Locality::Unprovable => {
1145 return Err(RunnerRootError::UnprovableFilesystem {
1146 path: projection.canonical.clone(),
1147 filesystem: filesystem.name,
1148 });
1149 }
1150 }
1151
1152 let writable = self
1153 .probe
1154 .is_writable(&projection.anchor)
1155 .map_err(|source| RunnerRootError::Inspect {
1156 path: projection.anchor.clone(),
1157 source,
1158 })?;
1159 if !writable {
1160 if cfg!(target_os = "macos")
1176 && self.probe.runs_as_superuser()
1177 && self.probe.is_on_privacy_gated_volume(&projection.anchor)
1178 && !self.probe.is_read_only(&projection.anchor)
1179 {
1180 return Err(RunnerRootError::DeniedByPrivacyPolicy {
1181 requested: candidate.to_path_buf(),
1185 refused: projection.anchor_as_written.clone(),
1186 remediation: owner.remediation(),
1187 });
1188 }
1189 return Err(if projection.missing == 0 {
1193 RunnerRootError::NotWritable {
1194 path: projection.anchor_as_written.clone(),
1195 remediation: owner.remediation(),
1196 }
1197 } else {
1198 RunnerRootError::ParentNotWritable {
1199 parent: projection.anchor_as_written.clone(),
1200 leaf: candidate.to_path_buf(),
1201 remediation: owner.remediation(),
1202 }
1203 });
1204 }
1205
1206 self.reject_overlap(owner, &projection.canonical.to_string_lossy(), true)?;
1210
1211 Ok(PreflightedRoot {
1212 root: root.clone(),
1213 canonical: projection.canonical,
1214 exists: projection.missing == 0,
1215 filesystem,
1216 })
1217 }
1218}
1219
1220pub fn derive_child(
1236 root: &LocalAbsolutePath,
1237 name: &str,
1238) -> Result<LocalAbsolutePath, RunnerRootError> {
1239 root.join_child(name)
1240 .map_err(|source| RunnerRootError::DerivedName { source })
1241}
1242
1243pub fn verify_containment(
1258 root: &LocalAbsolutePath,
1259 child: &LocalAbsolutePath,
1260) -> Result<(), RunnerRootError> {
1261 for value in [root, child] {
1266 if value.platform() != PathPlatform::NATIVE {
1267 return Err(RunnerRootError::ForeignPlatform {
1268 got: value.as_str().to_string(),
1269 platform: value.platform(),
1270 });
1271 }
1272 }
1273 let escapes = |resolved: PathBuf| RunnerRootError::Escapes {
1274 root: root.as_path().to_path_buf(),
1275 child: child.as_path().to_path_buf(),
1276 resolved,
1277 };
1278 if overlap_of(child.as_str(), root.as_str(), root.platform()) != Overlap::Inside {
1279 return Err(escapes(child.as_path().to_path_buf()));
1280 }
1281 let root_projection = project(root.as_path())?;
1282 let child_projection = project(child.as_path())?;
1283 let relation = overlap_of(
1284 &child_projection.canonical.to_string_lossy(),
1285 &root_projection.canonical.to_string_lossy(),
1286 root.platform(),
1287 );
1288 if relation != Overlap::Inside {
1289 return Err(escapes(child_projection.canonical));
1290 }
1291 Ok(())
1292}
1293
1294#[cfg(windows)]
1306mod sys {
1307 use std::io;
1308 use std::os::windows::ffi::OsStrExt;
1309 use std::path::Path;
1310
1311 use windows::Win32::Foundation::{CloseHandle, ERROR_ACCESS_DENIED};
1312 use windows::Win32::Storage::FileSystem::{
1313 CreateFileW, FILE_ADD_SUBDIRECTORY, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE,
1314 FILE_SHARE_READ, FILE_SHARE_WRITE, GetDriveTypeW, GetVolumePathNameW, OPEN_EXISTING,
1315 };
1316 use windows::Win32::System::SystemInformation::GetSystemDirectoryW;
1317 use windows::Win32::System::WindowsProgramming::{
1318 DRIVE_CDROM, DRIVE_FIXED, DRIVE_NO_ROOT_DIR, DRIVE_RAMDISK, DRIVE_REMOTE, DRIVE_REMOVABLE,
1319 DRIVE_UNKNOWN,
1320 };
1321 use windows::core::PCWSTR;
1322
1323 use super::FilesystemIdentity;
1324
1325 const fn hresult_from_win32(code: u32) -> i32 {
1327 if code == 0 {
1328 0
1329 } else {
1330 ((code & 0x0000_ffff) | 0x8007_0000) as i32
1331 }
1332 }
1333
1334 fn io_error(error: &windows::core::Error) -> io::Error {
1342 let code = error.code().0;
1343 #[allow(clippy::cast_sign_loss)]
1344 let unsigned = code as u32;
1345 if unsigned & 0xffff_0000 == 0x8007_0000 {
1346 #[allow(clippy::cast_possible_wrap)]
1347 return io::Error::from_raw_os_error((unsigned & 0x0000_ffff) as i32);
1348 }
1349 io::Error::from_raw_os_error(code)
1350 }
1351
1352 fn to_wide(path: &Path) -> Vec<u16> {
1353 path.as_os_str()
1354 .encode_wide()
1355 .chain(std::iter::once(0))
1356 .collect()
1357 }
1358
1359 pub(super) fn system_directory() -> io::Result<String> {
1366 let mut buffer = [0u16; 512];
1370 let written = unsafe { GetSystemDirectoryW(Some(&mut buffer)) } as usize;
1374 if written == 0 {
1375 return Err(io::Error::last_os_error());
1376 }
1377 if written > buffer.len() {
1378 return Err(io::Error::other(format!(
1379 "the system directory needs {written} UTF-16 code units, which is more \
1380 than a system path is expected to occupy"
1381 )));
1382 }
1383 String::from_utf16(&buffer[..written]).map_err(io::Error::other)
1384 }
1385
1386 fn volume_path(directory: &Path) -> io::Result<Vec<u16>> {
1392 let file = to_wide(directory);
1393 let mut buffer = [0u16; 512];
1394 unsafe { GetVolumePathNameW(PCWSTR(file.as_ptr()), &mut buffer) }
1397 .map_err(|error| io_error(&error))?;
1398 let length = buffer
1399 .iter()
1400 .position(|unit| *unit == 0)
1401 .unwrap_or(buffer.len());
1402 let mut mount = buffer[..length].to_vec();
1403 mount.push(0);
1404 Ok(mount)
1405 }
1406
1407 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1408 let mount = volume_path(directory)?;
1409 let kind = unsafe { GetDriveTypeW(PCWSTR(mount.as_ptr())) };
1411 Ok(match kind {
1412 DRIVE_FIXED => FilesystemIdentity::local("a fixed local volume"),
1413 DRIVE_REMOVABLE => FilesystemIdentity::local("a removable local volume"),
1414 DRIVE_RAMDISK => FilesystemIdentity::local("a RAM disk"),
1415 DRIVE_CDROM => FilesystemIdentity::local("an optical drive"),
1416 DRIVE_REMOTE => FilesystemIdentity::remote("a network drive"),
1417 DRIVE_NO_ROOT_DIR => FilesystemIdentity::unprovable("no mounted volume"),
1418 DRIVE_UNKNOWN => FilesystemIdentity::unprovable("an unknown drive type"),
1419 other => FilesystemIdentity::unprovable(format!("drive type {other}")),
1420 })
1421 }
1422
1423 pub(super) const fn is_on_privacy_gated_volume(_path: &Path) -> bool {
1427 false
1428 }
1429
1430 pub(super) const fn runs_as_superuser() -> bool {
1434 false
1435 }
1436
1437 pub(super) const fn is_read_only(_path: &Path) -> bool {
1440 false
1441 }
1442
1443 pub(super) fn is_writable(directory: &Path) -> io::Result<bool> {
1463 let wide = to_wide(directory);
1464 let access = FILE_ADD_SUBDIRECTORY.0;
1465 let opened = unsafe {
1470 CreateFileW(
1471 PCWSTR(wide.as_ptr()),
1472 access,
1473 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1474 None,
1475 OPEN_EXISTING,
1476 FILE_FLAG_BACKUP_SEMANTICS,
1477 None,
1478 )
1479 };
1480 match opened {
1481 Ok(handle) => {
1482 unsafe {
1485 let _ = CloseHandle(handle);
1486 }
1487 Ok(true)
1488 }
1489 Err(error) if error.code().0 == hresult_from_win32(ERROR_ACCESS_DENIED.0) => Ok(false),
1490 Err(error) => Err(io_error(&error)),
1491 }
1492 }
1493}
1494
1495#[cfg(unix)]
1496mod sys {
1497 use std::ffi::CString;
1498 use std::io;
1499 use std::os::unix::ffi::OsStrExt;
1500 use std::path::Path;
1501
1502 use super::FilesystemIdentity;
1503
1504 fn c_path(path: &Path) -> io::Result<CString> {
1505 CString::new(path.as_os_str().as_bytes()).map_err(|_| {
1506 io::Error::other("a path containing a NUL cannot be given to the operating system")
1507 })
1508 }
1509
1510 #[cfg(target_os = "macos")]
1525 pub(super) fn is_on_privacy_gated_volume(path: &Path) -> bool {
1526 let Ok(path) = c_path(path) else {
1527 return false;
1528 };
1529 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1530 if unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) } != 0 {
1533 return false;
1534 }
1535 let mount = unsafe { std::ffi::CStr::from_ptr(buffer.f_mntonname.as_ptr()) };
1538 mount.to_bytes().starts_with(b"/Volumes/")
1539 }
1540
1541 #[cfg(not(target_os = "macos"))]
1544 pub(super) const fn is_on_privacy_gated_volume(_path: &Path) -> bool {
1545 false
1546 }
1547
1548 #[cfg(not(target_os = "macos"))]
1551 pub(super) const fn is_read_only(_path: &Path) -> bool {
1552 false
1553 }
1554
1555 #[cfg(target_os = "macos")]
1567 pub(super) fn is_read_only(path: &Path) -> bool {
1568 let Ok(path) = c_path(path) else {
1569 return false;
1570 };
1571 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1572 if unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) } != 0 {
1575 return false;
1576 }
1577 buffer.f_flags & u32::try_from(libc::MNT_RDONLY).unwrap_or(0) != 0
1578 }
1579
1580 pub(super) fn runs_as_superuser() -> bool {
1586 unsafe { libc::geteuid() == 0 }
1589 }
1590
1591 pub(super) fn is_writable(directory: &Path) -> io::Result<bool> {
1599 let path = c_path(directory)?;
1600 let result = unsafe { libc::access(path.as_ptr(), libc::W_OK | libc::X_OK) };
1603 if result == 0 {
1604 return Ok(true);
1605 }
1606 let error = io::Error::last_os_error();
1607 match error.raw_os_error() {
1608 Some(libc::EACCES | libc::EPERM | libc::EROFS) => Ok(false),
1610 _ => Err(error),
1611 }
1612 }
1613
1614 #[cfg(target_os = "linux")]
1623 const LOCAL_MAGICS: &[(u32, &str)] = &[
1624 (0x0000_ef53, "ext2/ext3/ext4"),
1625 (0x9123_683e, "btrfs"),
1626 (0x5846_5342, "xfs"),
1627 (0x0102_1994, "tmpfs"),
1628 (0x794c_7630, "overlayfs"),
1629 (0x2fc1_2fc1, "zfs"),
1630 (0xf2f5_2010, "f2fs"),
1631 (0x0000_4d44, "vfat"),
1632 (0x2011_bab0, "exfat"),
1633 (0x5346_544e, "ntfs"),
1634 (0x8584_58f6, "ramfs"),
1635 (0x0000_9660, "iso9660"),
1636 (0x7371_7368, "squashfs"),
1637 (0x3153_464a, "jfs"),
1638 (0x5265_4973, "reiserfs"),
1639 (0xca45_1a4e, "bcachefs"),
1640 (0x0000_4244, "hfs"),
1641 (0x0000_482b, "hfsplus"),
1642 ];
1643
1644 #[cfg(target_os = "linux")]
1646 const REMOTE_MAGICS: &[(u32, &str)] = &[
1647 (0x0000_6969, "nfs"),
1648 (0xff53_4d42, "cifs"),
1649 (0xfe53_4d42, "smb2"),
1650 (0x0000_517b, "smb"),
1651 (0x7375_7245, "coda"),
1652 (0x0000_564c, "ncpfs"),
1653 (0x5346_414f, "afs"),
1654 (0x6b41_4653, "afs"),
1655 (0x0bd0_0bd0, "lustre"),
1656 (0x00c3_6400, "ceph"),
1657 (0x0102_1997, "9p"),
1658 (0x0116_1970, "gfs2"),
1659 (0x7461_636f, "ocfs2"),
1660 ];
1661
1662 #[cfg(target_os = "linux")]
1663 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1664 let path = c_path(directory)?;
1665 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1666 let result = unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) };
1669 if result != 0 {
1670 return Err(io::Error::last_os_error());
1671 }
1672 let magic = buffer.f_type as u32;
1675 if let Some((_, name)) = LOCAL_MAGICS.iter().find(|(value, _)| *value == magic) {
1676 return Ok(FilesystemIdentity::local(*name));
1677 }
1678 if let Some((_, name)) = REMOTE_MAGICS.iter().find(|(value, _)| *value == magic) {
1679 return Ok(FilesystemIdentity::remote(*name));
1680 }
1681 Ok(FilesystemIdentity::unprovable(format!(
1682 "filesystem type 0x{magic:08x}"
1683 )))
1684 }
1685
1686 #[cfg(target_os = "macos")]
1690 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1691 let path = c_path(directory)?;
1692 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1693 let result = unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) };
1696 if result != 0 {
1697 return Err(io::Error::last_os_error());
1698 }
1699 let name = unsafe { std::ffi::CStr::from_ptr(buffer.f_fstypename.as_ptr()) }
1702 .to_string_lossy()
1703 .into_owned();
1704 #[allow(clippy::cast_sign_loss)]
1705 let local = buffer.f_flags & (libc::MNT_LOCAL as u32) != 0;
1706 Ok(if local {
1707 FilesystemIdentity::local(name)
1708 } else {
1709 FilesystemIdentity::remote(name)
1710 })
1711 }
1712
1713 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1716 pub(super) fn identify(_directory: &Path) -> io::Result<FilesystemIdentity> {
1717 Ok(FilesystemIdentity::unprovable(
1718 "an operating system this build cannot interrogate",
1719 ))
1720 }
1721}
1722
1723#[cfg(test)]
1724mod tests {
1725 use super::*;
1726
1727 use PathPlatform::{Unix, Windows};
1728
1729 #[derive(Debug)]
1738 struct StubFilesystem {
1739 identity: FilesystemIdentity,
1740 writable: bool,
1741 superuser: bool,
1742 gated_volume: bool,
1743 read_only: bool,
1744 }
1745
1746 impl StubFilesystem {
1747 fn saying(identity: FilesystemIdentity) -> Self {
1748 Self {
1749 identity,
1750 writable: true,
1751 superuser: false,
1752 gated_volume: false,
1753 read_only: false,
1754 }
1755 }
1756
1757 fn unwritable() -> Self {
1758 Self {
1759 identity: FilesystemIdentity::local("a test volume"),
1760 writable: false,
1761 superuser: false,
1762 gated_volume: false,
1763 read_only: false,
1764 }
1765 }
1766
1767 #[cfg(target_os = "macos")]
1775 fn unwritable_to_the_superuser() -> Self {
1776 Self {
1777 superuser: true,
1778 gated_volume: true,
1779 ..Self::unwritable()
1780 }
1781 }
1782
1783 #[cfg(target_os = "macos")]
1786 fn unwritable_to_the_superuser_on_the_startup_disk() -> Self {
1787 Self {
1788 superuser: true,
1789 ..Self::unwritable()
1790 }
1791 }
1792
1793 #[cfg(target_os = "macos")]
1796 fn read_only_gated_volume() -> Self {
1797 Self {
1798 superuser: true,
1799 gated_volume: true,
1800 read_only: true,
1801 ..Self::unwritable()
1802 }
1803 }
1804 }
1805
1806 impl FilesystemProbe for StubFilesystem {
1807 fn identify(&self, _directory: &Path) -> io::Result<FilesystemIdentity> {
1808 Ok(self.identity.clone())
1809 }
1810
1811 fn is_writable(&self, _directory: &Path) -> io::Result<bool> {
1812 Ok(self.writable)
1813 }
1814
1815 fn runs_as_superuser(&self) -> bool {
1816 self.superuser
1817 }
1818
1819 fn is_on_privacy_gated_volume(&self, _directory: &Path) -> bool {
1820 self.gated_volume
1821 }
1822
1823 fn is_read_only(&self, _directory: &Path) -> bool {
1824 self.read_only
1825 }
1826 }
1827
1828 fn native(path: &Path) -> LocalAbsolutePath {
1830 LocalAbsolutePath::new(path.to_str().expect("the fixture path is unicode"))
1831 .expect("the fixture path is a storable local path")
1832 }
1833
1834 fn foreign() -> LocalAbsolutePath {
1836 if cfg!(windows) {
1837 LocalAbsolutePath::parse_for("/srv/rman", Unix)
1838 } else {
1839 LocalAbsolutePath::parse_for("C:\\rman", Windows)
1840 }
1841 .expect("the fixture is valid for the other platform")
1842 }
1843
1844 #[cfg(windows)]
1850 fn link_dir(target: &Path, link: &Path) -> bool {
1851 std::process::Command::new("cmd")
1852 .arg("/C")
1853 .arg("mklink")
1854 .arg("/J")
1855 .arg(link)
1856 .arg(target)
1857 .output()
1858 .is_ok_and(|output| output.status.success())
1859 }
1860
1861 #[cfg(unix)]
1862 fn link_dir(target: &Path, link: &Path) -> bool {
1863 std::os::unix::fs::symlink(target, link).is_ok()
1864 }
1865
1866 struct Fixture {
1869 root: tempfile::TempDir,
1870 paths: AppPaths,
1871 workspaces: PathBuf,
1872 }
1873
1874 impl Fixture {
1875 fn check(&self, path: &Path) -> Result<PreflightedRoot, RunnerRootError> {
1882 RootPreflight::new(&self.paths).check(&RootOwner::Host, &native(path))
1883 }
1884 }
1885
1886 fn fixture() -> Fixture {
1887 let root = tempfile::tempdir().expect("a temporary directory");
1888 let paths = AppPaths::rooted_at(root.path());
1889 paths.create_all().expect("the layout is created");
1890 let workspaces = root.path().join("workspaces");
1891 std::fs::create_dir(&workspaces).expect("the workspace parent is created");
1892 Fixture {
1893 root,
1894 paths,
1895 workspaces,
1896 }
1897 }
1898
1899 fn snapshot(root: &Path) -> Vec<String> {
1901 fn walk(path: &Path, into: &mut Vec<String>) {
1902 let Ok(entries) = std::fs::read_dir(path) else {
1903 return;
1904 };
1905 for entry in entries.flatten() {
1906 let metadata = entry
1907 .metadata()
1908 .expect("an entry that was just listed can be inspected");
1909 #[cfg(unix)]
1910 let permissions = {
1911 use std::os::unix::fs::PermissionsExt;
1912 format!("{:04o}", metadata.permissions().mode() & 0o7777)
1913 };
1914 #[cfg(not(unix))]
1915 let permissions = format!("readonly={}", metadata.permissions().readonly());
1916 into.push(format!(
1917 "{} dir={} len={} {permissions}",
1918 entry.path().display(),
1919 metadata.is_dir(),
1920 metadata.len()
1921 ));
1922 if metadata.is_dir() {
1923 walk(&entry.path(), into);
1924 }
1925 }
1926 }
1927 let mut entries = Vec::new();
1928 walk(root, &mut entries);
1929 entries.sort();
1930 entries
1931 }
1932
1933 #[test]
1936 fn the_windows_default_is_the_system_drive_plus_rman() {
1937 let cases = [
1941 ("C:\\Windows\\system32", "C:\\rman"),
1942 ("E:\\Windows\\system32", "E:\\rman"),
1943 ("c:/windows/system32", "C:\\rman"),
1944 ("Z:\\WINDOWS\\SYSTEM32", "Z:\\rman"),
1945 ("D:\\Windows", "D:\\rman"),
1946 ];
1947 for (system_directory, expected) in cases {
1948 let resolved =
1949 default_runner_root_from(PlatformDefault::WindowsSystemDirectory(system_directory))
1950 .expect("a drive path resolves");
1951 assert_eq!(
1952 resolved.as_str(),
1953 expected,
1954 "system directory {system_directory:?}"
1955 );
1956 assert_eq!(resolved.platform(), Windows);
1957 }
1958 }
1959
1960 #[test]
1961 fn a_system_directory_that_is_not_a_local_drive_fails_with_the_remediation() {
1962 for system_directory in [
1963 "\\\\nas\\share\\system32",
1964 "\\\\?\\C:\\Windows\\system32",
1965 "C:\\",
1966 "windows\\system32",
1967 "",
1968 ] {
1969 let error =
1970 default_runner_root_from(PlatformDefault::WindowsSystemDirectory(system_directory))
1971 .expect_err("an unusable system directory must not resolve");
1972 let message = error.to_string();
1973 assert!(
1974 message.contains("host set-runtime-root"),
1975 "the message must name the command that fixes it: {message}"
1976 );
1977 }
1978 }
1979
1980 #[test]
1981 fn the_application_runtime_directory_arm_changes_nothing() {
1982 let root = tempfile::tempdir().expect("a temporary directory");
1986 let paths = AppPaths::rooted_at(root.path());
1987 let resolved = default_runner_root_from(PlatformDefault::ApplicationRuntimeDirectory(
1988 paths.runtime_dir(),
1989 ))
1990 .expect("a resolved runtime directory is storable");
1991 assert_eq!(resolved.as_path(), paths.runtime_dir());
1992 }
1993
1994 #[cfg(not(windows))]
1995 #[test]
1996 fn the_macos_and_linux_defaults_are_the_existing_runtime_directory() {
1997 let discovered = AppPaths::discover().expect("a home directory exists on every CI leg");
1998 assert_eq!(
1999 default_runner_root(&discovered)
2000 .expect("the discovered layout resolves")
2001 .as_path(),
2002 discovered.runtime_dir(),
2003 "moving the Unix defaults would relocate live workspaces for no reason"
2004 );
2005
2006 let root = tempfile::tempdir().expect("a temporary directory");
2007 let rooted = AppPaths::rooted_at(root.path());
2008 assert_eq!(
2009 default_runner_root(&rooted)
2010 .expect("an explicit root resolves")
2011 .as_path(),
2012 rooted.runtime_dir()
2013 );
2014 }
2015
2016 #[cfg(windows)]
2017 #[test]
2018 fn the_windows_default_is_this_machines_system_drive() {
2019 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2020 let resolved = default_runner_root(&paths).expect("this host has a system directory");
2021 let text = resolved.as_str();
2022 assert_eq!(
2023 &text[1..],
2024 format!(":\\{WINDOWS_RUNNER_ROOT_NAME}"),
2025 "the default is <system-drive> plus {WINDOWS_RUNNER_ROOT_NAME}, got {text}"
2026 );
2027 assert!(text.starts_with(|c: char| c.is_ascii_uppercase()));
2028 }
2029
2030 #[cfg(windows)]
2031 #[test]
2032 #[serial_test::serial(environment)]
2033 fn the_windows_default_ignores_a_rewritten_system_drive_variable() {
2034 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2038 let before = default_runner_root(&paths).expect("this host has a system directory");
2039
2040 let restore_drive = std::env::var_os("SystemDrive");
2041 let restore_root = std::env::var_os("SystemRoot");
2042 unsafe {
2045 std::env::set_var("SystemDrive", "Q:");
2046 std::env::set_var("SystemRoot", "Q:\\Windows");
2047 }
2048 let after = default_runner_root(&paths);
2049 unsafe {
2051 match restore_drive {
2052 Some(value) => std::env::set_var("SystemDrive", value),
2053 None => std::env::remove_var("SystemDrive"),
2054 }
2055 match restore_root {
2056 Some(value) => std::env::set_var("SystemRoot", value),
2057 None => std::env::remove_var("SystemRoot"),
2058 }
2059 }
2060
2061 let after = after.expect("the kernel still answers");
2062 assert_eq!(after, before);
2063 assert_ne!(after.as_str(), "Q:\\rman");
2064 }
2065
2066 #[test]
2069 fn overlap_is_decided_component_by_component_on_both_platforms() {
2070 let cases = [
2071 (Unix, "/srv/rman", "/srv/rman", Overlap::Same),
2072 (Unix, "/srv/rman/s1", "/srv/rman", Overlap::Inside),
2073 (Unix, "/srv", "/srv/rman", Overlap::Contains),
2074 (Unix, "/srv/rman", "/srv/other", Overlap::Disjoint),
2075 (Unix, "/srv/rman-old", "/srv/rman", Overlap::Disjoint),
2077 (Unix, "/", "/srv/rman", Overlap::Contains),
2078 (Unix, "/srv/Rman", "/srv/rman", Overlap::Disjoint),
2081 (Windows, "C:\\rman", "C:\\rman", Overlap::Same),
2082 (Windows, "C:\\RMAN", "c:\\rman", Overlap::Same),
2083 (Windows, "C:\\rman\\s1", "C:\\rman", Overlap::Inside),
2084 (Windows, "C:\\", "C:\\rman", Overlap::Contains),
2085 (Windows, "D:\\rman", "C:\\rman", Overlap::Disjoint),
2088 (Windows, "C:\\rman-old", "C:\\rman", Overlap::Disjoint),
2089 ];
2090 for (platform, candidate, other, expected) in cases {
2091 assert_eq!(
2092 overlap_of(candidate, other, platform),
2093 expected,
2094 "{platform}: {candidate:?} vs {other:?}"
2095 );
2096 }
2097 }
2098
2099 #[test]
2100 fn a_filesystem_root_never_reaches_the_preflight() {
2101 for (raw, platform) in [("/", Unix), ("C:\\", Windows), ("c:/", Windows)] {
2105 assert!(
2106 LocalAbsolutePath::parse_for(raw, platform).is_err(),
2107 "{raw:?} must not be storable"
2108 );
2109 }
2110 let (root, below) = if cfg!(windows) {
2111 ("C:\\", "C:\\rman")
2112 } else {
2113 ("/", "/srv")
2114 };
2115 assert!(is_filesystem_root(Path::new(root)));
2116 assert!(!is_filesystem_root(Path::new(below)));
2117 }
2118
2119 #[test]
2122 fn an_existing_writable_local_directory_is_accepted() {
2123 let fixture = fixture();
2124 let root = fixture.workspaces.join("rman");
2125 std::fs::create_dir(&root).expect("the root is created");
2126
2127 let checked = fixture
2128 .check(&root)
2129 .expect("a plain writable directory on this machine is usable");
2130
2131 assert!(checked.exists());
2132 assert_eq!(checked.leaf_to_create(), None);
2133 assert_eq!(checked.filesystem().locality, Locality::Local);
2134 assert_eq!(
2135 checked.canonical(),
2136 plain(&std::fs::canonicalize(&root).expect("it exists"))
2137 );
2138 }
2139
2140 #[test]
2141 fn a_missing_leaf_below_a_writable_parent_is_accepted_and_not_created() {
2142 let fixture = fixture();
2143 let root = fixture.workspaces.join("rman");
2144
2145 let checked = fixture.check(&root).expect("a creatable leaf is usable");
2146
2147 assert!(!checked.exists());
2148 assert_eq!(checked.leaf_to_create(), Some(root.as_path()));
2149 assert!(
2150 !root.exists(),
2151 "creation is the caller's explicit step, never the preflight's"
2152 );
2153 }
2154
2155 #[test]
2156 fn more_than_one_missing_level_is_refused_with_the_deepest_directory() {
2157 let fixture = fixture();
2158 let root = fixture.workspaces.join("a").join("b");
2159
2160 let error = fixture
2161 .check(&root)
2162 .expect_err("only the leaf may be missing");
2163 let RunnerRootError::MissingParents {
2164 deepest_existing, ..
2165 } = &error
2166 else {
2167 panic!("expected MissingParents, got {error}");
2168 };
2169 assert_eq!(deepest_existing, &fixture.workspaces);
2170 }
2171
2172 #[test]
2173 fn an_existing_file_is_refused() {
2174 let fixture = fixture();
2175 let root = fixture.workspaces.join("rman");
2176 std::fs::write(&root, b"not a directory").expect("the file is created");
2177
2178 let error = fixture
2179 .check(&root)
2180 .expect_err("a file is not a runner root");
2181 assert!(
2182 matches!(error, RunnerRootError::ExistingFile { .. }),
2183 "got {error}"
2184 );
2185 }
2186
2187 #[test]
2188 fn a_file_where_the_parent_should_be_is_refused() {
2189 let fixture = fixture();
2190 let file = fixture.workspaces.join("notes.txt");
2191 std::fs::write(&file, b"notes").expect("the file is created");
2192 let root = file.join("rman");
2193
2194 let error = fixture
2195 .check(&root)
2196 .expect_err("nothing can be created inside a file");
2197 assert!(
2198 matches!(error, RunnerRootError::ParentIsNotADirectory { .. }),
2199 "got {error}"
2200 );
2201 }
2202
2203 #[test]
2204 fn a_linked_root_is_refused_rather_than_followed() {
2205 let fixture = fixture();
2206 let target = fixture.workspaces.join("real");
2207 std::fs::create_dir(&target).expect("the target is created");
2208 let root = fixture.workspaces.join("rman");
2209 if !link_dir(&target, &root) {
2210 return;
2211 }
2212
2213 let error = fixture
2214 .check(&root)
2215 .expect_err("a runner root is the base of a recursive cleanup");
2216 assert!(
2217 matches!(error, RunnerRootError::Symlinked { .. }),
2218 "got {error}"
2219 );
2220 }
2221
2222 #[test]
2223 fn a_link_whose_target_is_gone_is_still_reported_as_a_link() {
2224 let fixture = fixture();
2229 let root = fixture.workspaces.join("rman");
2230 if !link_dir(&fixture.workspaces.join("gone"), &root) {
2231 return;
2232 }
2233
2234 let error = fixture
2235 .check(&root)
2236 .expect_err("a dangling link is not a runner root");
2237 assert!(
2238 matches!(error, RunnerRootError::Symlinked { .. }),
2239 "got {error}"
2240 );
2241 }
2242
2243 #[test]
2244 fn a_link_in_the_path_cannot_smuggle_a_root_into_application_data() {
2245 let fixture = fixture();
2246 let bridge = fixture.workspaces.join("bridge");
2247 if !link_dir(fixture.paths.state_dir(), &bridge) {
2248 return;
2249 }
2250 let root = bridge.join("rman");
2253
2254 let error = fixture
2255 .check(&root)
2256 .expect_err("the canonical check must see through the link");
2257 let RunnerRootError::Overlaps { relation, .. } = &error else {
2258 panic!("expected Overlaps, got {error}");
2259 };
2260 assert_eq!(*relation, Overlap::Inside);
2261 }
2262
2263 #[test]
2264 fn a_root_that_collides_with_application_data_is_refused() {
2265 let fixture = fixture();
2266 let preflight = RootPreflight::new(&fixture.paths);
2267 let cases = [
2268 (fixture.paths.state_dir().to_path_buf(), Overlap::Same),
2270 (fixture.paths.logs_dir().join("rman"), Overlap::Inside),
2272 (fixture.root.path().to_path_buf(), Overlap::Contains),
2274 ];
2275 for (candidate, expected) in cases {
2276 let error = preflight
2277 .check(&RootOwner::Host, &native(&candidate))
2278 .expect_err("application data may not share a tree with runner workspaces");
2279 let RunnerRootError::Overlaps { relation, .. } = &error else {
2280 panic!("{} gave {error}", candidate.display());
2281 };
2282 assert_eq!(*relation, expected, "{}", candidate.display());
2283 }
2284 }
2285
2286 #[test]
2287 fn the_macos_shaped_layout_still_accepts_its_own_runtime_directory() {
2288 let root = tempfile::tempdir().expect("a temporary directory");
2293 let base = root.path();
2294 let paths = AppPaths::from_directories(
2295 base,
2296 base.join("state"),
2297 base.join("runtime"),
2298 base.join("logs"),
2299 );
2300 paths.create_all().expect("the layout is created");
2301 let preflight = RootPreflight::new(&paths);
2302
2303 preflight
2304 .check(&RootOwner::Host, &native(paths.runtime_dir()))
2305 .expect("the platform default must pass its own preflight");
2306 preflight
2307 .check(
2308 &RootOwner::Host,
2309 &native(&paths.runtime_dir().join("nested")),
2310 )
2311 .expect("a directory below the runtime directory is still the runner area");
2312
2313 for refused in [base.to_path_buf(), base.join("state"), base.join("beside")] {
2314 let error = preflight
2315 .check(&RootOwner::Host, &native(&refused))
2316 .expect_err("only the runtime subtree is exempt");
2317 assert!(
2318 matches!(error, RunnerRootError::Overlaps { .. }),
2319 "{} gave {error}",
2320 refused.display()
2321 );
2322 }
2323 }
2324
2325 #[test]
2326 fn two_roots_may_not_contain_one_another() {
2327 let fixture = fixture();
2328 let host = fixture.workspaces.join("host");
2329 let repository = host.join("acme");
2330 let other = fixture.workspaces.join("other");
2331 std::fs::create_dir_all(&repository).expect("both roots are created");
2332 std::fs::create_dir(&other).expect("the third root is created");
2333
2334 let owner = RootOwner::Repository("acme/widgets".to_string());
2335 let preflight = RootPreflight::new(&fixture.paths)
2336 .against(RootOwner::Host, native(&host))
2337 .against(
2338 RootOwner::Repository("acme/gadgets".to_string()),
2339 native(&other),
2340 );
2341
2342 let error = preflight
2343 .check(&owner, &native(&repository))
2344 .expect_err("a repository root inside the host root is refused");
2345 let RunnerRootError::Overlaps {
2346 relation,
2347 other_owner,
2348 ..
2349 } = &error
2350 else {
2351 panic!("expected Overlaps, got {error}");
2352 };
2353 assert_eq!(*relation, Overlap::Inside);
2354 assert_eq!(other_owner, &RootOwner::Host.to_string());
2355
2356 let error = preflight
2357 .check(&owner, &native(&other))
2358 .expect_err("two repositories may not share a root");
2359 assert!(
2360 matches!(error, RunnerRootError::Overlaps { .. }),
2361 "got {error}"
2362 );
2363 }
2364
2365 #[test]
2366 fn a_root_does_not_overlap_itself_when_it_is_revalidated() {
2367 let fixture = fixture();
2368 let root = fixture.workspaces.join("acme");
2369 std::fs::create_dir(&root).expect("the root is created");
2370 let owner = RootOwner::Repository("acme/widgets".to_string());
2371
2372 RootPreflight::new(&fixture.paths)
2373 .against(owner.clone(), native(&root))
2374 .check(&owner, &native(&root))
2375 .expect("re-checking a stored setting must not report it against itself");
2376 }
2377
2378 #[test]
2379 fn a_row_written_on_another_operating_system_fails_closed() {
2380 let fixture = fixture();
2381 let error = RootPreflight::new(&fixture.paths)
2382 .check(&RootOwner::Host, &foreign())
2383 .expect_err("a foreign path is corrupt state on this host");
2384 assert!(
2385 matches!(error, RunnerRootError::ForeignPlatform { .. }),
2386 "got {error}"
2387 );
2388 }
2389
2390 #[test]
2393 fn a_remote_filesystem_is_refused() {
2394 let fixture = fixture();
2395 let root = fixture.workspaces.join("rman");
2396 std::fs::create_dir(&root).expect("the root is created");
2397 let probe = StubFilesystem::saying(FilesystemIdentity::remote("nfs"));
2398
2399 let error = RootPreflight::with_probe(&fixture.paths, &probe)
2400 .check(&RootOwner::Host, &native(&root))
2401 .expect_err("a network share may not hold runner workspaces");
2402 assert!(
2403 matches!(error, RunnerRootError::RemoteFilesystem { .. }),
2404 "got {error}"
2405 );
2406 }
2407
2408 #[test]
2409 fn a_filesystem_this_host_cannot_classify_fails_closed() {
2410 let fixture = fixture();
2411 let root = fixture.workspaces.join("rman");
2412 std::fs::create_dir(&root).expect("the root is created");
2413 let probe =
2414 StubFilesystem::saying(FilesystemIdentity::unprovable("filesystem type 0x00001234"));
2415
2416 let error = RootPreflight::with_probe(&fixture.paths, &probe)
2417 .check(&RootOwner::Host, &native(&root))
2418 .expect_err("unprovable locality is a refusal, not a shrug");
2419 assert!(
2420 matches!(error, RunnerRootError::UnprovableFilesystem { .. }),
2421 "got {error}"
2422 );
2423 }
2424
2425 #[cfg(target_os = "macos")]
2433 #[test]
2434 fn a_refusal_the_superuser_received_names_the_privacy_control_not_the_permissions() {
2435 let fixture = fixture();
2436 let existing = fixture.workspaces.join("rman");
2437 std::fs::create_dir(&existing).expect("the root is created");
2438 let probe = StubFilesystem::unwritable_to_the_superuser();
2439 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2440
2441 let error = preflight
2442 .check(&RootOwner::Host, &native(&existing))
2443 .expect_err("a root the service cannot write is unusable");
2444
2445 assert!(
2446 matches!(error, RunnerRootError::DeniedByPrivacyPolicy { .. }),
2447 "a superuser cannot be refused by file permissions, so this is the \
2448 privacy layer: {error}"
2449 );
2450 let rendered = error.to_string();
2451 assert!(
2452 rendered.contains("Full Disk Access"),
2453 "the refusal must name the control that grants it: {rendered}"
2454 );
2455 assert!(
2456 rendered.contains(&RootOwner::Host.remediation()),
2457 "the refusal must still show the command that moves the root: {rendered}"
2458 );
2459 }
2460
2461 #[cfg(target_os = "macos")]
2466 #[test]
2467 fn a_superuser_refused_on_the_startup_disk_is_not_blamed_on_the_privacy_layer() {
2468 let fixture = fixture();
2469 let existing = fixture.workspaces.join("rman");
2470 std::fs::create_dir(&existing).expect("the root is created");
2471 let probe = StubFilesystem::unwritable_to_the_superuser_on_the_startup_disk();
2472 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2473
2474 let error = preflight
2475 .check(&RootOwner::Host, &native(&existing))
2476 .expect_err("an unwritable root is unusable");
2477
2478 assert!(
2479 matches!(error, RunnerRootError::NotWritable { .. }),
2480 "the privacy layer gates no directory on the startup disk: {error}"
2481 );
2482 }
2483
2484 #[cfg(target_os = "macos")]
2490 #[test]
2491 fn a_read_only_volume_is_not_blamed_on_the_privacy_layer() {
2492 let fixture = fixture();
2493 let existing = fixture.workspaces.join("rman");
2494 std::fs::create_dir(&existing).expect("the root is created");
2495 let probe = StubFilesystem::read_only_gated_volume();
2496 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2497
2498 let error = preflight
2499 .check(&RootOwner::Host, &native(&existing))
2500 .expect_err("a read-only root is unusable");
2501
2502 assert!(
2503 matches!(error, RunnerRootError::NotWritable { .. }),
2504 "consent cannot make a read-only mount writable: {error}"
2505 );
2506 }
2507
2508 #[cfg(target_os = "macos")]
2512 #[test]
2513 fn a_privacy_refusal_names_the_root_that_was_asked_for_and_the_one_that_refused() {
2514 let fixture = fixture();
2515 let leaf = fixture.workspaces.join("not-created-yet");
2516 let probe = StubFilesystem::unwritable_to_the_superuser();
2517 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2518
2519 let error = preflight
2520 .check(&RootOwner::Host, &native(&leaf))
2521 .expect_err("a root the service cannot create is unusable");
2522
2523 let rendered = error.to_string();
2524 assert!(
2525 rendered.contains("not-created-yet"),
2526 "the root the operator asked for is missing from the refusal: {rendered}"
2527 );
2528 assert!(
2529 rendered.contains(&fixture.workspaces.display().to_string()),
2530 "the directory that actually refused is missing from the refusal: {rendered}"
2531 );
2532 }
2533
2534 #[test]
2537 fn a_refusal_an_ordinary_account_received_still_names_file_permissions() {
2538 let fixture = fixture();
2539 let existing = fixture.workspaces.join("rman");
2540 std::fs::create_dir(&existing).expect("the root is created");
2541 let probe = StubFilesystem::unwritable();
2542 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2543
2544 let error = preflight
2545 .check(&RootOwner::Host, &native(&existing))
2546 .expect_err("an unwritable root is unusable");
2547
2548 assert!(
2549 matches!(error, RunnerRootError::NotWritable { .. }),
2550 "got {error}"
2551 );
2552 }
2553
2554 #[test]
2555 fn an_unwritable_directory_and_an_unwritable_parent_are_reported_apart() {
2556 let fixture = fixture();
2557 let existing = fixture.workspaces.join("rman");
2558 std::fs::create_dir(&existing).expect("the root is created");
2559 let missing = fixture.workspaces.join("other");
2560 let probe = StubFilesystem::unwritable();
2561 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2562
2563 let error = preflight
2564 .check(&RootOwner::Host, &native(&existing))
2565 .expect_err("an unwritable root is unusable");
2566 assert!(
2567 matches!(error, RunnerRootError::NotWritable { .. }),
2568 "got {error}"
2569 );
2570 assert!(
2571 error.to_string().contains(&RootOwner::Host.remediation()),
2572 "the refusal must show the command that fixes it: {error}"
2573 );
2574
2575 let error = preflight
2576 .check(&RootOwner::Host, &native(&missing))
2577 .expect_err("an unwritable parent cannot hold a new leaf");
2578 let RunnerRootError::ParentNotWritable { parent, leaf, .. } = &error else {
2579 panic!("expected ParentNotWritable, got {error}");
2580 };
2581 assert_eq!(parent, &fixture.workspaces);
2582 assert_eq!(leaf, &missing);
2583 assert!(
2584 error.to_string().contains(&RootOwner::Host.remediation()),
2585 "the refusal must show the command that fixes it: {error}"
2586 );
2587
2588 let repository = RootOwner::Repository("acme/widgets".to_string());
2589 let error = preflight
2590 .check(&repository, &native(&missing))
2591 .expect_err("an unwritable parent cannot hold a new leaf");
2592 assert!(
2593 error.to_string().contains(&repository.remediation()),
2594 "a repository root must name its own command: {error}"
2595 );
2596 }
2597
2598 #[test]
2599 fn nothing_is_created_removed_or_repermissioned_by_any_verdict() {
2600 let fixture = fixture();
2604 let existing = fixture.workspaces.join("rman");
2605 std::fs::create_dir(&existing).expect("the root is created");
2606 let file = fixture.workspaces.join("notes.txt");
2607 std::fs::write(&file, b"operator data").expect("the file is created");
2608
2609 let before = snapshot(fixture.root.path());
2610
2611 let stub = StubFilesystem::unwritable();
2612 let host_preflight = RootPreflight::new(&fixture.paths);
2613 let stub_preflight = RootPreflight::with_probe(&fixture.paths, &stub);
2614 for preflight in [&host_preflight, &stub_preflight] {
2615 for candidate in [
2616 existing.clone(),
2617 fixture.workspaces.join("missing"),
2618 fixture.workspaces.join("a").join("b"),
2619 file.clone(),
2620 file.join("leaf"),
2621 fixture.paths.state_dir().to_path_buf(),
2622 ] {
2623 let _ = preflight.check(&RootOwner::Host, &native(&candidate));
2624 }
2625 }
2626
2627 assert_eq!(
2628 before,
2629 snapshot(fixture.root.path()),
2630 "the preflight changed the filesystem"
2631 );
2632 }
2633
2634 #[test]
2637 fn this_machines_temporary_directory_is_local_and_writable() {
2638 let root = tempfile::tempdir().expect("a temporary directory");
2639 let canonical = plain(&std::fs::canonicalize(root.path()).expect("it exists"));
2640
2641 let identity = HostFilesystem
2642 .identify(&canonical)
2643 .expect("the platform answers");
2644 assert_eq!(
2645 identity.locality,
2646 Locality::Local,
2647 "the suite's own temporary directory reported {identity:?}; a CI leg whose \
2648 temporary filesystem is unknown to this table would refuse every runner root"
2649 );
2650 assert!(
2651 HostFilesystem
2652 .is_writable(&canonical)
2653 .expect("the platform answers"),
2654 "a directory this process just created must be writable"
2655 );
2656 }
2657
2658 #[cfg(windows)]
2659 #[test]
2660 fn the_system_drive_root_is_writable_when_a_directory_can_be_created_in_it() {
2661 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2668 let default = default_runner_root(&paths).expect("this host has a system directory");
2669 let parent = default
2670 .as_path()
2671 .parent()
2672 .expect("the default root is one level below the system drive")
2673 .to_path_buf();
2674
2675 let probe = parent.join(format!("rman-preflight-probe-{}", std::process::id()));
2676 if std::fs::create_dir(&probe).is_err() {
2677 return;
2678 }
2679 let writable = HostFilesystem.is_writable(&parent);
2680 std::fs::remove_dir(&probe).expect("the probe is removed");
2681
2682 assert!(
2683 writable.expect("the platform answers"),
2684 "{} accepts a new directory, so the preflight must not refuse the default \
2685 runner root as unwritable",
2686 parent.display()
2687 );
2688 }
2689
2690 #[cfg(unix)]
2691 #[test]
2692 fn a_directory_this_account_cannot_write_is_reported_as_such() {
2693 use std::os::unix::fs::PermissionsExt;
2694
2695 let root = tempfile::tempdir().expect("a temporary directory");
2696 let locked = root.path().join("locked");
2697 std::fs::create_dir(&locked).expect("the directory is created");
2698 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555))
2699 .expect("the directory is made unwritable");
2700
2701 let probe = locked.join("probe");
2704 let is_root = std::fs::create_dir(&probe).is_ok();
2705 if is_root {
2706 std::fs::remove_dir(&probe).expect("the probe is removed");
2707 }
2708 let writable = HostFilesystem.is_writable(&locked);
2709 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
2710 .expect("the directory is restored");
2711 if is_root {
2712 return;
2713 }
2714 assert!(
2715 !writable.expect("the platform answers"),
2716 "a 0555 directory must not be reported writable"
2717 );
2718 }
2719
2720 #[test]
2723 fn a_derived_child_is_one_component_below_the_root() {
2724 let root = LocalAbsolutePath::parse_for("/srv/rman", Unix).expect("a valid root");
2725 assert_eq!(
2726 derive_child(&root, "s1").expect("a valid slot").as_str(),
2727 "/srv/rman/s1"
2728 );
2729 let root = LocalAbsolutePath::parse_for("C:\\rman", Windows).expect("a valid root");
2730 assert_eq!(
2731 derive_child(&root, "0123456789ab")
2732 .expect("a valid attempt")
2733 .as_str(),
2734 "C:\\rman\\0123456789ab"
2735 );
2736 for name in ["..", "a/b", "", "."] {
2737 assert!(
2738 derive_child(&root, name).is_err(),
2739 "{name:?} must not be a derived child"
2740 );
2741 }
2742 }
2743
2744 #[test]
2745 fn containment_is_proven_lexically_and_after_resolution() {
2746 let fixture = fixture();
2747 let directory = fixture.workspaces.join("rman");
2748 std::fs::create_dir(&directory).expect("the root is created");
2749 let root = native(&directory);
2750
2751 let slot = derive_child(&root, "s1").expect("a valid slot");
2752 verify_containment(&root, &slot)
2753 .expect("a slot that does not exist yet is still contained");
2754 std::fs::create_dir(slot.as_path()).expect("the slot is created");
2755 verify_containment(&root, &slot).expect("an existing slot is contained");
2756
2757 let sibling = native(&fixture.workspaces.join("elsewhere"));
2758 assert!(
2759 matches!(
2760 verify_containment(&root, &sibling),
2761 Err(RunnerRootError::Escapes { .. })
2762 ),
2763 "a sibling is not contained"
2764 );
2765 assert!(
2766 matches!(
2767 verify_containment(&root, &root),
2768 Err(RunnerRootError::Escapes { .. })
2769 ),
2770 "the root is not strictly inside itself"
2771 );
2772 }
2773
2774 #[test]
2775 fn a_link_inside_the_root_that_points_outside_it_is_not_contained() {
2776 let fixture = fixture();
2777 let root = fixture.workspaces.join("rman");
2778 std::fs::create_dir(&root).expect("the root is created");
2779 let outside = fixture.workspaces.join("outside");
2780 std::fs::create_dir(&outside).expect("the escape target is created");
2781 let escape = root.join("s1");
2782 if !link_dir(&outside, &escape) {
2783 return;
2784 }
2785
2786 let error = verify_containment(&native(&root), &native(&escape))
2787 .expect_err("cleanup may not follow a link out of the root it was given");
2788 assert!(
2789 matches!(error, RunnerRootError::Escapes { .. }),
2790 "got {error}"
2791 );
2792 }
2793
2794 #[test]
2797 fn each_owner_names_the_command_that_changes_it() {
2798 assert_eq!(
2799 RootOwner::Host.remediation(),
2800 "runner-manager host set-runtime-root --path <PATH>"
2801 );
2802 assert_eq!(
2803 RootOwner::Repository("acme/widgets".to_string()).remediation(),
2804 "runner-manager repo set-workspace acme/widgets --mode persistent --path <PATH>"
2805 );
2806 assert!(RootOwner::Host.to_string().contains("host runner root"));
2807 assert!(
2808 RootOwner::Repository("acme/widgets".to_string())
2809 .to_string()
2810 .contains("acme/widgets")
2811 );
2812 }
2813
2814 #[test]
2815 fn a_refusal_names_the_paths_and_says_what_to_do_about_it() {
2816 let fixture = fixture();
2820 let root = fixture.workspaces.join("rman");
2821 std::fs::create_dir(&root).expect("the root is created");
2822
2823 let probe = StubFilesystem::saying(FilesystemIdentity::remote("nfs"));
2824 let message = RootPreflight::with_probe(&fixture.paths, &probe)
2825 .check(&RootOwner::Host, &native(&root))
2826 .expect_err("a network share is refused")
2827 .to_string();
2828 assert!(message.contains("nfs"), "{message}");
2829 assert!(message.contains("local volume"), "{message}");
2830
2831 let message = fixture
2832 .check(fixture.paths.state_dir())
2833 .expect_err("application data is protected")
2834 .to_string();
2835 assert!(
2836 message.contains(&fixture.paths.state_dir().display().to_string()),
2837 "the directory that refused must be named: {message}"
2838 );
2839 assert!(
2840 message.contains("the application state directory"),
2841 "the operator must be told what it collided with: {message}"
2842 );
2843 assert!(
2844 message.contains("outside the application data tree"),
2845 "the message must say what to do instead: {message}"
2846 );
2847 }
2848}