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(windows)]
672pub(crate) use sys::system_directory as windows_system_directory;
673
674#[cfg(not(windows))]
687pub fn default_runner_root(app_paths: &AppPaths) -> Result<LocalAbsolutePath, RunnerRootError> {
688 default_runner_root_from(PlatformDefault::ApplicationRuntimeDirectory(
689 app_paths.runtime_dir(),
690 ))
691}
692
693#[derive(Debug)]
708struct Projection {
709 anchor: PathBuf,
711 anchor_as_written: PathBuf,
713 canonical: PathBuf,
715 missing: usize,
723}
724
725fn means_absent(error: &io::Error) -> bool {
734 matches!(
735 error.kind(),
736 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
737 )
738}
739
740fn project(path: &Path) -> Result<Projection, RunnerRootError> {
743 let mut missing: Vec<OsString> = Vec::new();
744 let mut cursor = path.to_path_buf();
745 loop {
746 match std::fs::symlink_metadata(&cursor) {
747 Ok(_) => break,
748 Err(error) if means_absent(&error) => {
749 let name = cursor.file_name().map(OsString::from);
750 let parent = cursor.parent().map(Path::to_path_buf);
751 let (Some(name), Some(parent)) = (name, parent) else {
752 return Err(RunnerRootError::Inspect {
755 path: cursor,
756 source: error,
757 });
758 };
759 missing.push(name);
760 cursor = parent;
761 }
762 Err(source) => {
763 return Err(RunnerRootError::Inspect {
764 path: cursor,
765 source,
766 });
767 }
768 }
769 }
770
771 let anchor = std::fs::canonicalize(&cursor)
772 .map(|canonical| plain(&canonical))
773 .map_err(|source| RunnerRootError::Inspect {
774 path: cursor.clone(),
775 source,
776 })?;
777
778 let mut canonical = anchor.clone();
779 for component in missing.iter().rev() {
780 canonical.push(component);
781 }
782 Ok(Projection {
783 anchor,
784 anchor_as_written: cursor,
785 canonical,
786 missing: missing.len(),
787 })
788}
789
790#[cfg(windows)]
797fn plain(path: &Path) -> PathBuf {
798 let text = path.to_string_lossy();
799 if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
800 return PathBuf::from(format!(r"\\{rest}"));
801 }
802 if let Some(rest) = text.strip_prefix(r"\\?\") {
803 return PathBuf::from(rest);
804 }
805 path.to_path_buf()
806}
807
808#[cfg(not(windows))]
810fn plain(path: &Path) -> PathBuf {
811 path.to_path_buf()
812}
813
814fn canonical_text(path: &Path) -> Option<String> {
823 project(path)
824 .ok()
825 .map(|projection| projection.canonical.to_string_lossy().into_owned())
826}
827
828fn is_filesystem_root(path: &Path) -> bool {
830 path.parent().is_none()
831}
832
833#[derive(Debug, Clone, PartialEq, Eq)]
845pub struct PreflightedRoot {
846 root: LocalAbsolutePath,
847 canonical: PathBuf,
848 exists: bool,
849 filesystem: FilesystemIdentity,
850}
851
852impl PreflightedRoot {
853 #[must_use]
855 pub const fn root(&self) -> &LocalAbsolutePath {
856 &self.root
857 }
858
859 #[must_use]
861 pub fn canonical(&self) -> &Path {
862 &self.canonical
863 }
864
865 #[must_use]
867 pub const fn exists(&self) -> bool {
868 self.exists
869 }
870
871 #[must_use]
873 pub fn leaf_to_create(&self) -> Option<&Path> {
874 (!self.exists).then(|| self.root.as_path())
875 }
876
877 #[must_use]
879 pub const fn filesystem(&self) -> &FilesystemIdentity {
880 &self.filesystem
881 }
882}
883
884pub struct RootPreflight<'a> {
905 app_paths: &'a AppPaths,
906 others: Vec<(RootOwner, LocalAbsolutePath)>,
907 probe: &'a dyn FilesystemProbe,
908}
909
910impl fmt::Debug for RootPreflight<'_> {
911 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
912 f.debug_struct("RootPreflight")
913 .field("app_paths", &self.app_paths)
914 .field("others", &self.others)
915 .finish_non_exhaustive()
916 }
917}
918
919impl<'a> RootPreflight<'a> {
920 #[must_use]
922 pub fn new(app_paths: &'a AppPaths) -> Self {
923 Self::with_probe(app_paths, &HOST_FILESYSTEM)
924 }
925
926 #[must_use]
928 pub fn with_probe(app_paths: &'a AppPaths, probe: &'a dyn FilesystemProbe) -> Self {
929 Self {
930 app_paths,
931 others: Vec::new(),
932 probe,
933 }
934 }
935
936 #[must_use]
944 pub fn against(mut self, owner: RootOwner, root: LocalAbsolutePath) -> Self {
945 self.others.push((owner, root));
946 self
947 }
948
949 fn protected(&self) -> [(&'static str, &Path); 3] {
958 [
959 (
960 "the application configuration directory",
961 self.app_paths.config_dir(),
962 ),
963 (
964 "the application state directory",
965 self.app_paths.state_dir(),
966 ),
967 ("the application log directory", self.app_paths.logs_dir()),
968 ]
969 }
970
971 fn reject_overlap(
977 &self,
978 owner: &RootOwner,
979 candidate: &str,
980 canonical: bool,
981 ) -> Result<(), RunnerRootError> {
982 let native = PathPlatform::NATIVE;
983 let text_of = |path: &Path| -> Option<String> {
984 if canonical {
985 canonical_text(path)
986 } else {
987 Some(path.to_string_lossy().into_owned())
988 }
989 };
990
991 let inside_runtime = || {
1004 text_of(self.app_paths.runtime_dir()).is_some_and(|runtime| {
1005 matches!(
1006 overlap_of(candidate, &runtime, native),
1007 Overlap::Same | Overlap::Inside
1008 )
1009 })
1010 };
1011
1012 for (label, path) in self.protected() {
1013 let Some(other) = text_of(path) else {
1014 continue;
1015 };
1016 let relation = overlap_of(candidate, &other, native);
1017 if relation == Overlap::Disjoint || (relation == Overlap::Inside && inside_runtime()) {
1018 continue;
1019 }
1020 return Err(RunnerRootError::Overlaps {
1021 candidate: PathBuf::from(candidate),
1022 relation,
1023 other: PathBuf::from(other),
1024 other_owner: label.to_string(),
1025 detail: "Runner workspaces are removed recursively and application data must \
1026 survive that; configure a directory outside the application data tree.",
1027 });
1028 }
1029
1030 for (other_owner, root) in &self.others {
1031 if other_owner == owner {
1032 continue;
1033 }
1034 let Some(other) = text_of(root.as_path()) else {
1035 continue;
1036 };
1037 let relation = overlap_of(candidate, &other, native);
1038 if relation == Overlap::Disjoint {
1039 continue;
1040 }
1041 return Err(RunnerRootError::Overlaps {
1042 candidate: PathBuf::from(candidate),
1043 relation,
1044 other: PathBuf::from(other),
1045 other_owner: other_owner.to_string(),
1046 detail: "Two runner roots that contain one another can delete each other's \
1047 workspaces; configure directories that do not overlap.",
1048 });
1049 }
1050 Ok(())
1051 }
1052
1053 pub fn check(
1063 &self,
1064 owner: &RootOwner,
1065 root: &LocalAbsolutePath,
1066 ) -> Result<PreflightedRoot, RunnerRootError> {
1067 if root.platform() != PathPlatform::NATIVE {
1068 return Err(RunnerRootError::ForeignPlatform {
1069 got: root.as_str().to_string(),
1070 platform: root.platform(),
1071 });
1072 }
1073 let candidate = root.as_path();
1074
1075 self.reject_overlap(owner, root.as_str(), false)?;
1080
1081 match std::fs::symlink_metadata(candidate) {
1087 Ok(metadata) if metadata.file_type().is_symlink() => {
1088 return Err(RunnerRootError::Symlinked {
1089 path: candidate.to_path_buf(),
1090 });
1091 }
1092 Ok(metadata) if !metadata.is_dir() => {
1093 return Err(RunnerRootError::ExistingFile {
1094 path: candidate.to_path_buf(),
1095 });
1096 }
1097 Ok(_) => {}
1098 Err(error) if means_absent(&error) => {}
1101 Err(source) => {
1102 return Err(RunnerRootError::Inspect {
1103 path: candidate.to_path_buf(),
1104 source,
1105 });
1106 }
1107 }
1108
1109 let projection = project(candidate)?;
1110 match projection.missing {
1111 0 => {}
1112 1 => {
1113 if !projection.anchor.is_dir() {
1114 return Err(RunnerRootError::ParentIsNotADirectory {
1115 parent: projection.anchor_as_written.clone(),
1116 });
1117 }
1118 }
1119 _ => {
1120 return Err(RunnerRootError::MissingParents {
1121 path: candidate.to_path_buf(),
1122 deepest_existing: projection.anchor_as_written.clone(),
1123 });
1124 }
1125 }
1126
1127 if is_filesystem_root(&projection.canonical) {
1128 return Err(RunnerRootError::ResolvesToFilesystemRoot {
1129 path: candidate.to_path_buf(),
1130 canonical: projection.canonical.clone(),
1131 });
1132 }
1133
1134 let filesystem =
1135 self.probe
1136 .identify(&projection.anchor)
1137 .map_err(|source| RunnerRootError::Inspect {
1138 path: projection.anchor.clone(),
1139 source,
1140 })?;
1141 match filesystem.locality {
1142 Locality::Local => {}
1143 Locality::Remote => {
1144 return Err(RunnerRootError::RemoteFilesystem {
1145 path: projection.canonical.clone(),
1146 filesystem: filesystem.name,
1147 });
1148 }
1149 Locality::Unprovable => {
1150 return Err(RunnerRootError::UnprovableFilesystem {
1151 path: projection.canonical.clone(),
1152 filesystem: filesystem.name,
1153 });
1154 }
1155 }
1156
1157 let writable = self
1158 .probe
1159 .is_writable(&projection.anchor)
1160 .map_err(|source| RunnerRootError::Inspect {
1161 path: projection.anchor.clone(),
1162 source,
1163 })?;
1164 if !writable {
1165 if cfg!(target_os = "macos")
1181 && self.probe.runs_as_superuser()
1182 && self.probe.is_on_privacy_gated_volume(&projection.anchor)
1183 && !self.probe.is_read_only(&projection.anchor)
1184 {
1185 return Err(RunnerRootError::DeniedByPrivacyPolicy {
1186 requested: candidate.to_path_buf(),
1190 refused: projection.anchor_as_written.clone(),
1191 remediation: owner.remediation(),
1192 });
1193 }
1194 return Err(if projection.missing == 0 {
1198 RunnerRootError::NotWritable {
1199 path: projection.anchor_as_written.clone(),
1200 remediation: owner.remediation(),
1201 }
1202 } else {
1203 RunnerRootError::ParentNotWritable {
1204 parent: projection.anchor_as_written.clone(),
1205 leaf: candidate.to_path_buf(),
1206 remediation: owner.remediation(),
1207 }
1208 });
1209 }
1210
1211 self.reject_overlap(owner, &projection.canonical.to_string_lossy(), true)?;
1215
1216 Ok(PreflightedRoot {
1217 root: root.clone(),
1218 canonical: projection.canonical,
1219 exists: projection.missing == 0,
1220 filesystem,
1221 })
1222 }
1223}
1224
1225pub fn derive_child(
1241 root: &LocalAbsolutePath,
1242 name: &str,
1243) -> Result<LocalAbsolutePath, RunnerRootError> {
1244 root.join_child(name)
1245 .map_err(|source| RunnerRootError::DerivedName { source })
1246}
1247
1248pub fn verify_containment(
1263 root: &LocalAbsolutePath,
1264 child: &LocalAbsolutePath,
1265) -> Result<(), RunnerRootError> {
1266 for value in [root, child] {
1271 if value.platform() != PathPlatform::NATIVE {
1272 return Err(RunnerRootError::ForeignPlatform {
1273 got: value.as_str().to_string(),
1274 platform: value.platform(),
1275 });
1276 }
1277 }
1278 let escapes = |resolved: PathBuf| RunnerRootError::Escapes {
1279 root: root.as_path().to_path_buf(),
1280 child: child.as_path().to_path_buf(),
1281 resolved,
1282 };
1283 if overlap_of(child.as_str(), root.as_str(), root.platform()) != Overlap::Inside {
1284 return Err(escapes(child.as_path().to_path_buf()));
1285 }
1286 let root_projection = project(root.as_path())?;
1287 let child_projection = project(child.as_path())?;
1288 let relation = overlap_of(
1289 &child_projection.canonical.to_string_lossy(),
1290 &root_projection.canonical.to_string_lossy(),
1291 root.platform(),
1292 );
1293 if relation != Overlap::Inside {
1294 return Err(escapes(child_projection.canonical));
1295 }
1296 Ok(())
1297}
1298
1299#[cfg(windows)]
1311mod sys {
1312 use std::io;
1313 use std::os::windows::ffi::OsStrExt;
1314 use std::path::Path;
1315
1316 use windows::Win32::Foundation::{CloseHandle, ERROR_ACCESS_DENIED};
1317 use windows::Win32::Storage::FileSystem::{
1318 CreateFileW, FILE_ADD_SUBDIRECTORY, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE,
1319 FILE_SHARE_READ, FILE_SHARE_WRITE, GetDriveTypeW, GetVolumePathNameW, OPEN_EXISTING,
1320 };
1321 use windows::Win32::System::SystemInformation::GetSystemDirectoryW;
1322 use windows::Win32::System::WindowsProgramming::{
1323 DRIVE_CDROM, DRIVE_FIXED, DRIVE_NO_ROOT_DIR, DRIVE_RAMDISK, DRIVE_REMOTE, DRIVE_REMOVABLE,
1324 DRIVE_UNKNOWN,
1325 };
1326 use windows::core::PCWSTR;
1327
1328 use super::FilesystemIdentity;
1329
1330 const fn hresult_from_win32(code: u32) -> i32 {
1332 if code == 0 {
1333 0
1334 } else {
1335 ((code & 0x0000_ffff) | 0x8007_0000) as i32
1336 }
1337 }
1338
1339 fn io_error(error: &windows::core::Error) -> io::Error {
1347 let code = error.code().0;
1348 #[allow(clippy::cast_sign_loss)]
1349 let unsigned = code as u32;
1350 if unsigned & 0xffff_0000 == 0x8007_0000 {
1351 #[allow(clippy::cast_possible_wrap)]
1352 return io::Error::from_raw_os_error((unsigned & 0x0000_ffff) as i32);
1353 }
1354 io::Error::from_raw_os_error(code)
1355 }
1356
1357 fn to_wide(path: &Path) -> Vec<u16> {
1358 path.as_os_str()
1359 .encode_wide()
1360 .chain(std::iter::once(0))
1361 .collect()
1362 }
1363
1364 pub(crate) fn system_directory() -> io::Result<String> {
1371 let mut buffer = [0u16; 512];
1375 let written = unsafe { GetSystemDirectoryW(Some(&mut buffer)) } as usize;
1379 if written == 0 {
1380 return Err(io::Error::last_os_error());
1381 }
1382 if written > buffer.len() {
1383 return Err(io::Error::other(format!(
1384 "the system directory needs {written} UTF-16 code units, which is more \
1385 than a system path is expected to occupy"
1386 )));
1387 }
1388 String::from_utf16(&buffer[..written]).map_err(io::Error::other)
1389 }
1390
1391 fn volume_path(directory: &Path) -> io::Result<Vec<u16>> {
1397 let file = to_wide(directory);
1398 let mut buffer = [0u16; 512];
1399 unsafe { GetVolumePathNameW(PCWSTR(file.as_ptr()), &mut buffer) }
1402 .map_err(|error| io_error(&error))?;
1403 let length = buffer
1404 .iter()
1405 .position(|unit| *unit == 0)
1406 .unwrap_or(buffer.len());
1407 let mut mount = buffer[..length].to_vec();
1408 mount.push(0);
1409 Ok(mount)
1410 }
1411
1412 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1413 let mount = volume_path(directory)?;
1414 let kind = unsafe { GetDriveTypeW(PCWSTR(mount.as_ptr())) };
1416 Ok(match kind {
1417 DRIVE_FIXED => FilesystemIdentity::local("a fixed local volume"),
1418 DRIVE_REMOVABLE => FilesystemIdentity::local("a removable local volume"),
1419 DRIVE_RAMDISK => FilesystemIdentity::local("a RAM disk"),
1420 DRIVE_CDROM => FilesystemIdentity::local("an optical drive"),
1421 DRIVE_REMOTE => FilesystemIdentity::remote("a network drive"),
1422 DRIVE_NO_ROOT_DIR => FilesystemIdentity::unprovable("no mounted volume"),
1423 DRIVE_UNKNOWN => FilesystemIdentity::unprovable("an unknown drive type"),
1424 other => FilesystemIdentity::unprovable(format!("drive type {other}")),
1425 })
1426 }
1427
1428 pub(super) const fn is_on_privacy_gated_volume(_path: &Path) -> bool {
1432 false
1433 }
1434
1435 pub(super) const fn runs_as_superuser() -> bool {
1439 false
1440 }
1441
1442 pub(super) const fn is_read_only(_path: &Path) -> bool {
1445 false
1446 }
1447
1448 pub(super) fn is_writable(directory: &Path) -> io::Result<bool> {
1468 let wide = to_wide(directory);
1469 let access = FILE_ADD_SUBDIRECTORY.0;
1470 let opened = unsafe {
1475 CreateFileW(
1476 PCWSTR(wide.as_ptr()),
1477 access,
1478 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1479 None,
1480 OPEN_EXISTING,
1481 FILE_FLAG_BACKUP_SEMANTICS,
1482 None,
1483 )
1484 };
1485 match opened {
1486 Ok(handle) => {
1487 unsafe {
1490 let _ = CloseHandle(handle);
1491 }
1492 Ok(true)
1493 }
1494 Err(error) if error.code().0 == hresult_from_win32(ERROR_ACCESS_DENIED.0) => Ok(false),
1495 Err(error) => Err(io_error(&error)),
1496 }
1497 }
1498}
1499
1500#[cfg(unix)]
1501mod sys {
1502 use std::ffi::CString;
1503 use std::io;
1504 use std::os::unix::ffi::OsStrExt;
1505 use std::path::Path;
1506
1507 use super::FilesystemIdentity;
1508
1509 fn c_path(path: &Path) -> io::Result<CString> {
1510 CString::new(path.as_os_str().as_bytes()).map_err(|_| {
1511 io::Error::other("a path containing a NUL cannot be given to the operating system")
1512 })
1513 }
1514
1515 #[cfg(target_os = "macos")]
1530 pub(super) fn is_on_privacy_gated_volume(path: &Path) -> bool {
1531 let Ok(path) = c_path(path) else {
1532 return false;
1533 };
1534 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1535 if unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) } != 0 {
1538 return false;
1539 }
1540 let mount = unsafe { std::ffi::CStr::from_ptr(buffer.f_mntonname.as_ptr()) };
1543 mount.to_bytes().starts_with(b"/Volumes/")
1544 }
1545
1546 #[cfg(not(target_os = "macos"))]
1549 pub(super) const fn is_on_privacy_gated_volume(_path: &Path) -> bool {
1550 false
1551 }
1552
1553 #[cfg(not(target_os = "macos"))]
1556 pub(super) const fn is_read_only(_path: &Path) -> bool {
1557 false
1558 }
1559
1560 #[cfg(target_os = "macos")]
1572 pub(super) fn is_read_only(path: &Path) -> bool {
1573 let Ok(path) = c_path(path) else {
1574 return false;
1575 };
1576 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1577 if unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) } != 0 {
1580 return false;
1581 }
1582 buffer.f_flags & u32::try_from(libc::MNT_RDONLY).unwrap_or(0) != 0
1583 }
1584
1585 pub(super) fn runs_as_superuser() -> bool {
1591 unsafe { libc::geteuid() == 0 }
1594 }
1595
1596 pub(super) fn is_writable(directory: &Path) -> io::Result<bool> {
1604 let path = c_path(directory)?;
1605 let result = unsafe { libc::access(path.as_ptr(), libc::W_OK | libc::X_OK) };
1608 if result == 0 {
1609 return Ok(true);
1610 }
1611 let error = io::Error::last_os_error();
1612 match error.raw_os_error() {
1613 Some(libc::EACCES | libc::EPERM | libc::EROFS) => Ok(false),
1615 _ => Err(error),
1616 }
1617 }
1618
1619 #[cfg(target_os = "linux")]
1628 const LOCAL_MAGICS: &[(u32, &str)] = &[
1629 (0x0000_ef53, "ext2/ext3/ext4"),
1630 (0x9123_683e, "btrfs"),
1631 (0x5846_5342, "xfs"),
1632 (0x0102_1994, "tmpfs"),
1633 (0x794c_7630, "overlayfs"),
1634 (0x2fc1_2fc1, "zfs"),
1635 (0xf2f5_2010, "f2fs"),
1636 (0x0000_4d44, "vfat"),
1637 (0x2011_bab0, "exfat"),
1638 (0x5346_544e, "ntfs"),
1639 (0x8584_58f6, "ramfs"),
1640 (0x0000_9660, "iso9660"),
1641 (0x7371_7368, "squashfs"),
1642 (0x3153_464a, "jfs"),
1643 (0x5265_4973, "reiserfs"),
1644 (0xca45_1a4e, "bcachefs"),
1645 (0x0000_4244, "hfs"),
1646 (0x0000_482b, "hfsplus"),
1647 ];
1648
1649 #[cfg(target_os = "linux")]
1651 const REMOTE_MAGICS: &[(u32, &str)] = &[
1652 (0x0000_6969, "nfs"),
1653 (0xff53_4d42, "cifs"),
1654 (0xfe53_4d42, "smb2"),
1655 (0x0000_517b, "smb"),
1656 (0x7375_7245, "coda"),
1657 (0x0000_564c, "ncpfs"),
1658 (0x5346_414f, "afs"),
1659 (0x6b41_4653, "afs"),
1660 (0x0bd0_0bd0, "lustre"),
1661 (0x00c3_6400, "ceph"),
1662 (0x0102_1997, "9p"),
1663 (0x0116_1970, "gfs2"),
1664 (0x7461_636f, "ocfs2"),
1665 ];
1666
1667 #[cfg(target_os = "linux")]
1668 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1669 let path = c_path(directory)?;
1670 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1671 let result = unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) };
1674 if result != 0 {
1675 return Err(io::Error::last_os_error());
1676 }
1677 let magic = buffer.f_type as u32;
1680 if let Some((_, name)) = LOCAL_MAGICS.iter().find(|(value, _)| *value == magic) {
1681 return Ok(FilesystemIdentity::local(*name));
1682 }
1683 if let Some((_, name)) = REMOTE_MAGICS.iter().find(|(value, _)| *value == magic) {
1684 return Ok(FilesystemIdentity::remote(*name));
1685 }
1686 Ok(FilesystemIdentity::unprovable(format!(
1687 "filesystem type 0x{magic:08x}"
1688 )))
1689 }
1690
1691 #[cfg(target_os = "macos")]
1695 pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1696 let path = c_path(directory)?;
1697 let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1698 let result = unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) };
1701 if result != 0 {
1702 return Err(io::Error::last_os_error());
1703 }
1704 let name = unsafe { std::ffi::CStr::from_ptr(buffer.f_fstypename.as_ptr()) }
1707 .to_string_lossy()
1708 .into_owned();
1709 #[allow(clippy::cast_sign_loss)]
1710 let local = buffer.f_flags & (libc::MNT_LOCAL as u32) != 0;
1711 Ok(if local {
1712 FilesystemIdentity::local(name)
1713 } else {
1714 FilesystemIdentity::remote(name)
1715 })
1716 }
1717
1718 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1721 pub(super) fn identify(_directory: &Path) -> io::Result<FilesystemIdentity> {
1722 Ok(FilesystemIdentity::unprovable(
1723 "an operating system this build cannot interrogate",
1724 ))
1725 }
1726}
1727
1728#[cfg(test)]
1729mod tests {
1730 use super::*;
1731
1732 use PathPlatform::{Unix, Windows};
1733
1734 #[derive(Debug)]
1743 struct StubFilesystem {
1744 identity: FilesystemIdentity,
1745 writable: bool,
1746 superuser: bool,
1747 gated_volume: bool,
1748 read_only: bool,
1749 }
1750
1751 impl StubFilesystem {
1752 fn saying(identity: FilesystemIdentity) -> Self {
1753 Self {
1754 identity,
1755 writable: true,
1756 superuser: false,
1757 gated_volume: false,
1758 read_only: false,
1759 }
1760 }
1761
1762 fn unwritable() -> Self {
1763 Self {
1764 identity: FilesystemIdentity::local("a test volume"),
1765 writable: false,
1766 superuser: false,
1767 gated_volume: false,
1768 read_only: false,
1769 }
1770 }
1771
1772 #[cfg(target_os = "macos")]
1780 fn unwritable_to_the_superuser() -> Self {
1781 Self {
1782 superuser: true,
1783 gated_volume: true,
1784 ..Self::unwritable()
1785 }
1786 }
1787
1788 #[cfg(target_os = "macos")]
1791 fn unwritable_to_the_superuser_on_the_startup_disk() -> Self {
1792 Self {
1793 superuser: true,
1794 ..Self::unwritable()
1795 }
1796 }
1797
1798 #[cfg(target_os = "macos")]
1801 fn read_only_gated_volume() -> Self {
1802 Self {
1803 superuser: true,
1804 gated_volume: true,
1805 read_only: true,
1806 ..Self::unwritable()
1807 }
1808 }
1809 }
1810
1811 impl FilesystemProbe for StubFilesystem {
1812 fn identify(&self, _directory: &Path) -> io::Result<FilesystemIdentity> {
1813 Ok(self.identity.clone())
1814 }
1815
1816 fn is_writable(&self, _directory: &Path) -> io::Result<bool> {
1817 Ok(self.writable)
1818 }
1819
1820 fn runs_as_superuser(&self) -> bool {
1821 self.superuser
1822 }
1823
1824 fn is_on_privacy_gated_volume(&self, _directory: &Path) -> bool {
1825 self.gated_volume
1826 }
1827
1828 fn is_read_only(&self, _directory: &Path) -> bool {
1829 self.read_only
1830 }
1831 }
1832
1833 fn native(path: &Path) -> LocalAbsolutePath {
1835 LocalAbsolutePath::new(path.to_str().expect("the fixture path is unicode"))
1836 .expect("the fixture path is a storable local path")
1837 }
1838
1839 fn foreign() -> LocalAbsolutePath {
1841 if cfg!(windows) {
1842 LocalAbsolutePath::parse_for("/srv/rman", Unix)
1843 } else {
1844 LocalAbsolutePath::parse_for("C:\\rman", Windows)
1845 }
1846 .expect("the fixture is valid for the other platform")
1847 }
1848
1849 #[cfg(windows)]
1855 fn link_dir(target: &Path, link: &Path) -> bool {
1856 std::process::Command::new("cmd")
1857 .arg("/C")
1858 .arg("mklink")
1859 .arg("/J")
1860 .arg(link)
1861 .arg(target)
1862 .output()
1863 .is_ok_and(|output| output.status.success())
1864 }
1865
1866 #[cfg(unix)]
1867 fn link_dir(target: &Path, link: &Path) -> bool {
1868 std::os::unix::fs::symlink(target, link).is_ok()
1869 }
1870
1871 struct Fixture {
1874 root: tempfile::TempDir,
1875 paths: AppPaths,
1876 workspaces: PathBuf,
1877 }
1878
1879 impl Fixture {
1880 fn check(&self, path: &Path) -> Result<PreflightedRoot, RunnerRootError> {
1887 RootPreflight::new(&self.paths).check(&RootOwner::Host, &native(path))
1888 }
1889 }
1890
1891 fn fixture() -> Fixture {
1892 let root = tempfile::tempdir().expect("a temporary directory");
1893 let paths = AppPaths::rooted_at(root.path());
1894 paths.create_all().expect("the layout is created");
1895 let workspaces = root.path().join("workspaces");
1896 std::fs::create_dir(&workspaces).expect("the workspace parent is created");
1897 Fixture {
1898 root,
1899 paths,
1900 workspaces,
1901 }
1902 }
1903
1904 fn snapshot(root: &Path) -> Vec<String> {
1906 fn walk(path: &Path, into: &mut Vec<String>) {
1907 let Ok(entries) = std::fs::read_dir(path) else {
1908 return;
1909 };
1910 for entry in entries.flatten() {
1911 let metadata = entry
1912 .metadata()
1913 .expect("an entry that was just listed can be inspected");
1914 #[cfg(unix)]
1915 let permissions = {
1916 use std::os::unix::fs::PermissionsExt;
1917 format!("{:04o}", metadata.permissions().mode() & 0o7777)
1918 };
1919 #[cfg(not(unix))]
1920 let permissions = format!("readonly={}", metadata.permissions().readonly());
1921 into.push(format!(
1922 "{} dir={} len={} {permissions}",
1923 entry.path().display(),
1924 metadata.is_dir(),
1925 metadata.len()
1926 ));
1927 if metadata.is_dir() {
1928 walk(&entry.path(), into);
1929 }
1930 }
1931 }
1932 let mut entries = Vec::new();
1933 walk(root, &mut entries);
1934 entries.sort();
1935 entries
1936 }
1937
1938 #[test]
1941 fn the_windows_default_is_the_system_drive_plus_rman() {
1942 let cases = [
1946 ("C:\\Windows\\system32", "C:\\rman"),
1947 ("E:\\Windows\\system32", "E:\\rman"),
1948 ("c:/windows/system32", "C:\\rman"),
1949 ("Z:\\WINDOWS\\SYSTEM32", "Z:\\rman"),
1950 ("D:\\Windows", "D:\\rman"),
1951 ];
1952 for (system_directory, expected) in cases {
1953 let resolved =
1954 default_runner_root_from(PlatformDefault::WindowsSystemDirectory(system_directory))
1955 .expect("a drive path resolves");
1956 assert_eq!(
1957 resolved.as_str(),
1958 expected,
1959 "system directory {system_directory:?}"
1960 );
1961 assert_eq!(resolved.platform(), Windows);
1962 }
1963 }
1964
1965 #[test]
1966 fn a_system_directory_that_is_not_a_local_drive_fails_with_the_remediation() {
1967 for system_directory in [
1968 "\\\\nas\\share\\system32",
1969 "\\\\?\\C:\\Windows\\system32",
1970 "C:\\",
1971 "windows\\system32",
1972 "",
1973 ] {
1974 let error =
1975 default_runner_root_from(PlatformDefault::WindowsSystemDirectory(system_directory))
1976 .expect_err("an unusable system directory must not resolve");
1977 let message = error.to_string();
1978 assert!(
1979 message.contains("host set-runtime-root"),
1980 "the message must name the command that fixes it: {message}"
1981 );
1982 }
1983 }
1984
1985 #[test]
1986 fn the_application_runtime_directory_arm_changes_nothing() {
1987 let root = tempfile::tempdir().expect("a temporary directory");
1991 let paths = AppPaths::rooted_at(root.path());
1992 let resolved = default_runner_root_from(PlatformDefault::ApplicationRuntimeDirectory(
1993 paths.runtime_dir(),
1994 ))
1995 .expect("a resolved runtime directory is storable");
1996 assert_eq!(resolved.as_path(), paths.runtime_dir());
1997 }
1998
1999 #[cfg(not(windows))]
2000 #[test]
2001 fn the_macos_and_linux_defaults_are_the_existing_runtime_directory() {
2002 let discovered = AppPaths::discover().expect("a home directory exists on every CI leg");
2003 assert_eq!(
2004 default_runner_root(&discovered)
2005 .expect("the discovered layout resolves")
2006 .as_path(),
2007 discovered.runtime_dir(),
2008 "moving the Unix defaults would relocate live workspaces for no reason"
2009 );
2010
2011 let root = tempfile::tempdir().expect("a temporary directory");
2012 let rooted = AppPaths::rooted_at(root.path());
2013 assert_eq!(
2014 default_runner_root(&rooted)
2015 .expect("an explicit root resolves")
2016 .as_path(),
2017 rooted.runtime_dir()
2018 );
2019 }
2020
2021 #[cfg(windows)]
2022 #[test]
2023 fn the_windows_default_is_this_machines_system_drive() {
2024 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2025 let resolved = default_runner_root(&paths).expect("this host has a system directory");
2026 let text = resolved.as_str();
2027 assert_eq!(
2028 &text[1..],
2029 format!(":\\{WINDOWS_RUNNER_ROOT_NAME}"),
2030 "the default is <system-drive> plus {WINDOWS_RUNNER_ROOT_NAME}, got {text}"
2031 );
2032 assert!(text.starts_with(|c: char| c.is_ascii_uppercase()));
2033 }
2034
2035 #[cfg(windows)]
2036 #[test]
2037 #[serial_test::serial(environment)]
2038 fn the_windows_default_ignores_a_rewritten_system_drive_variable() {
2039 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2043 let before = default_runner_root(&paths).expect("this host has a system directory");
2044
2045 let restore_drive = std::env::var_os("SystemDrive");
2046 let restore_root = std::env::var_os("SystemRoot");
2047 unsafe {
2050 std::env::set_var("SystemDrive", "Q:");
2051 std::env::set_var("SystemRoot", "Q:\\Windows");
2052 }
2053 let after = default_runner_root(&paths);
2054 unsafe {
2056 match restore_drive {
2057 Some(value) => std::env::set_var("SystemDrive", value),
2058 None => std::env::remove_var("SystemDrive"),
2059 }
2060 match restore_root {
2061 Some(value) => std::env::set_var("SystemRoot", value),
2062 None => std::env::remove_var("SystemRoot"),
2063 }
2064 }
2065
2066 let after = after.expect("the kernel still answers");
2067 assert_eq!(after, before);
2068 assert_ne!(after.as_str(), "Q:\\rman");
2069 }
2070
2071 #[test]
2074 fn overlap_is_decided_component_by_component_on_both_platforms() {
2075 let cases = [
2076 (Unix, "/srv/rman", "/srv/rman", Overlap::Same),
2077 (Unix, "/srv/rman/s1", "/srv/rman", Overlap::Inside),
2078 (Unix, "/srv", "/srv/rman", Overlap::Contains),
2079 (Unix, "/srv/rman", "/srv/other", Overlap::Disjoint),
2080 (Unix, "/srv/rman-old", "/srv/rman", Overlap::Disjoint),
2082 (Unix, "/", "/srv/rman", Overlap::Contains),
2083 (Unix, "/srv/Rman", "/srv/rman", Overlap::Disjoint),
2086 (Windows, "C:\\rman", "C:\\rman", Overlap::Same),
2087 (Windows, "C:\\RMAN", "c:\\rman", Overlap::Same),
2088 (Windows, "C:\\rman\\s1", "C:\\rman", Overlap::Inside),
2089 (Windows, "C:\\", "C:\\rman", Overlap::Contains),
2090 (Windows, "D:\\rman", "C:\\rman", Overlap::Disjoint),
2093 (Windows, "C:\\rman-old", "C:\\rman", Overlap::Disjoint),
2094 ];
2095 for (platform, candidate, other, expected) in cases {
2096 assert_eq!(
2097 overlap_of(candidate, other, platform),
2098 expected,
2099 "{platform}: {candidate:?} vs {other:?}"
2100 );
2101 }
2102 }
2103
2104 #[test]
2105 fn a_filesystem_root_never_reaches_the_preflight() {
2106 for (raw, platform) in [("/", Unix), ("C:\\", Windows), ("c:/", Windows)] {
2110 assert!(
2111 LocalAbsolutePath::parse_for(raw, platform).is_err(),
2112 "{raw:?} must not be storable"
2113 );
2114 }
2115 let (root, below) = if cfg!(windows) {
2116 ("C:\\", "C:\\rman")
2117 } else {
2118 ("/", "/srv")
2119 };
2120 assert!(is_filesystem_root(Path::new(root)));
2121 assert!(!is_filesystem_root(Path::new(below)));
2122 }
2123
2124 #[test]
2127 fn an_existing_writable_local_directory_is_accepted() {
2128 let fixture = fixture();
2129 let root = fixture.workspaces.join("rman");
2130 std::fs::create_dir(&root).expect("the root is created");
2131
2132 let checked = fixture
2133 .check(&root)
2134 .expect("a plain writable directory on this machine is usable");
2135
2136 assert!(checked.exists());
2137 assert_eq!(checked.leaf_to_create(), None);
2138 assert_eq!(checked.filesystem().locality, Locality::Local);
2139 assert_eq!(
2140 checked.canonical(),
2141 plain(&std::fs::canonicalize(&root).expect("it exists"))
2142 );
2143 }
2144
2145 #[test]
2146 fn a_missing_leaf_below_a_writable_parent_is_accepted_and_not_created() {
2147 let fixture = fixture();
2148 let root = fixture.workspaces.join("rman");
2149
2150 let checked = fixture.check(&root).expect("a creatable leaf is usable");
2151
2152 assert!(!checked.exists());
2153 assert_eq!(checked.leaf_to_create(), Some(root.as_path()));
2154 assert!(
2155 !root.exists(),
2156 "creation is the caller's explicit step, never the preflight's"
2157 );
2158 }
2159
2160 #[test]
2161 fn more_than_one_missing_level_is_refused_with_the_deepest_directory() {
2162 let fixture = fixture();
2163 let root = fixture.workspaces.join("a").join("b");
2164
2165 let error = fixture
2166 .check(&root)
2167 .expect_err("only the leaf may be missing");
2168 let RunnerRootError::MissingParents {
2169 deepest_existing, ..
2170 } = &error
2171 else {
2172 panic!("expected MissingParents, got {error}");
2173 };
2174 assert_eq!(deepest_existing, &fixture.workspaces);
2175 }
2176
2177 #[test]
2178 fn an_existing_file_is_refused() {
2179 let fixture = fixture();
2180 let root = fixture.workspaces.join("rman");
2181 std::fs::write(&root, b"not a directory").expect("the file is created");
2182
2183 let error = fixture
2184 .check(&root)
2185 .expect_err("a file is not a runner root");
2186 assert!(
2187 matches!(error, RunnerRootError::ExistingFile { .. }),
2188 "got {error}"
2189 );
2190 }
2191
2192 #[test]
2193 fn a_file_where_the_parent_should_be_is_refused() {
2194 let fixture = fixture();
2195 let file = fixture.workspaces.join("notes.txt");
2196 std::fs::write(&file, b"notes").expect("the file is created");
2197 let root = file.join("rman");
2198
2199 let error = fixture
2200 .check(&root)
2201 .expect_err("nothing can be created inside a file");
2202 assert!(
2203 matches!(error, RunnerRootError::ParentIsNotADirectory { .. }),
2204 "got {error}"
2205 );
2206 }
2207
2208 #[test]
2209 fn a_linked_root_is_refused_rather_than_followed() {
2210 let fixture = fixture();
2211 let target = fixture.workspaces.join("real");
2212 std::fs::create_dir(&target).expect("the target is created");
2213 let root = fixture.workspaces.join("rman");
2214 if !link_dir(&target, &root) {
2215 return;
2216 }
2217
2218 let error = fixture
2219 .check(&root)
2220 .expect_err("a runner root is the base of a recursive cleanup");
2221 assert!(
2222 matches!(error, RunnerRootError::Symlinked { .. }),
2223 "got {error}"
2224 );
2225 }
2226
2227 #[test]
2228 fn a_link_whose_target_is_gone_is_still_reported_as_a_link() {
2229 let fixture = fixture();
2234 let root = fixture.workspaces.join("rman");
2235 if !link_dir(&fixture.workspaces.join("gone"), &root) {
2236 return;
2237 }
2238
2239 let error = fixture
2240 .check(&root)
2241 .expect_err("a dangling link is not a runner root");
2242 assert!(
2243 matches!(error, RunnerRootError::Symlinked { .. }),
2244 "got {error}"
2245 );
2246 }
2247
2248 #[test]
2249 fn a_link_in_the_path_cannot_smuggle_a_root_into_application_data() {
2250 let fixture = fixture();
2251 let bridge = fixture.workspaces.join("bridge");
2252 if !link_dir(fixture.paths.state_dir(), &bridge) {
2253 return;
2254 }
2255 let root = bridge.join("rman");
2258
2259 let error = fixture
2260 .check(&root)
2261 .expect_err("the canonical check must see through the link");
2262 let RunnerRootError::Overlaps { relation, .. } = &error else {
2263 panic!("expected Overlaps, got {error}");
2264 };
2265 assert_eq!(*relation, Overlap::Inside);
2266 }
2267
2268 #[test]
2269 fn a_root_that_collides_with_application_data_is_refused() {
2270 let fixture = fixture();
2271 let preflight = RootPreflight::new(&fixture.paths);
2272 let cases = [
2273 (fixture.paths.state_dir().to_path_buf(), Overlap::Same),
2275 (fixture.paths.logs_dir().join("rman"), Overlap::Inside),
2277 (fixture.root.path().to_path_buf(), Overlap::Contains),
2279 ];
2280 for (candidate, expected) in cases {
2281 let error = preflight
2282 .check(&RootOwner::Host, &native(&candidate))
2283 .expect_err("application data may not share a tree with runner workspaces");
2284 let RunnerRootError::Overlaps { relation, .. } = &error else {
2285 panic!("{} gave {error}", candidate.display());
2286 };
2287 assert_eq!(*relation, expected, "{}", candidate.display());
2288 }
2289 }
2290
2291 #[test]
2292 fn the_macos_shaped_layout_still_accepts_its_own_runtime_directory() {
2293 let root = tempfile::tempdir().expect("a temporary directory");
2298 let base = root.path();
2299 let paths = AppPaths::from_directories(
2300 base,
2301 base.join("state"),
2302 base.join("runtime"),
2303 base.join("logs"),
2304 );
2305 paths.create_all().expect("the layout is created");
2306 let preflight = RootPreflight::new(&paths);
2307
2308 preflight
2309 .check(&RootOwner::Host, &native(paths.runtime_dir()))
2310 .expect("the platform default must pass its own preflight");
2311 preflight
2312 .check(
2313 &RootOwner::Host,
2314 &native(&paths.runtime_dir().join("nested")),
2315 )
2316 .expect("a directory below the runtime directory is still the runner area");
2317
2318 for refused in [base.to_path_buf(), base.join("state"), base.join("beside")] {
2319 let error = preflight
2320 .check(&RootOwner::Host, &native(&refused))
2321 .expect_err("only the runtime subtree is exempt");
2322 assert!(
2323 matches!(error, RunnerRootError::Overlaps { .. }),
2324 "{} gave {error}",
2325 refused.display()
2326 );
2327 }
2328 }
2329
2330 #[test]
2331 fn two_roots_may_not_contain_one_another() {
2332 let fixture = fixture();
2333 let host = fixture.workspaces.join("host");
2334 let repository = host.join("acme");
2335 let other = fixture.workspaces.join("other");
2336 std::fs::create_dir_all(&repository).expect("both roots are created");
2337 std::fs::create_dir(&other).expect("the third root is created");
2338
2339 let owner = RootOwner::Repository("acme/widgets".to_string());
2340 let preflight = RootPreflight::new(&fixture.paths)
2341 .against(RootOwner::Host, native(&host))
2342 .against(
2343 RootOwner::Repository("acme/gadgets".to_string()),
2344 native(&other),
2345 );
2346
2347 let error = preflight
2348 .check(&owner, &native(&repository))
2349 .expect_err("a repository root inside the host root is refused");
2350 let RunnerRootError::Overlaps {
2351 relation,
2352 other_owner,
2353 ..
2354 } = &error
2355 else {
2356 panic!("expected Overlaps, got {error}");
2357 };
2358 assert_eq!(*relation, Overlap::Inside);
2359 assert_eq!(other_owner, &RootOwner::Host.to_string());
2360
2361 let error = preflight
2362 .check(&owner, &native(&other))
2363 .expect_err("two repositories may not share a root");
2364 assert!(
2365 matches!(error, RunnerRootError::Overlaps { .. }),
2366 "got {error}"
2367 );
2368 }
2369
2370 #[test]
2371 fn a_root_does_not_overlap_itself_when_it_is_revalidated() {
2372 let fixture = fixture();
2373 let root = fixture.workspaces.join("acme");
2374 std::fs::create_dir(&root).expect("the root is created");
2375 let owner = RootOwner::Repository("acme/widgets".to_string());
2376
2377 RootPreflight::new(&fixture.paths)
2378 .against(owner.clone(), native(&root))
2379 .check(&owner, &native(&root))
2380 .expect("re-checking a stored setting must not report it against itself");
2381 }
2382
2383 #[test]
2384 fn a_row_written_on_another_operating_system_fails_closed() {
2385 let fixture = fixture();
2386 let error = RootPreflight::new(&fixture.paths)
2387 .check(&RootOwner::Host, &foreign())
2388 .expect_err("a foreign path is corrupt state on this host");
2389 assert!(
2390 matches!(error, RunnerRootError::ForeignPlatform { .. }),
2391 "got {error}"
2392 );
2393 }
2394
2395 #[test]
2398 fn a_remote_filesystem_is_refused() {
2399 let fixture = fixture();
2400 let root = fixture.workspaces.join("rman");
2401 std::fs::create_dir(&root).expect("the root is created");
2402 let probe = StubFilesystem::saying(FilesystemIdentity::remote("nfs"));
2403
2404 let error = RootPreflight::with_probe(&fixture.paths, &probe)
2405 .check(&RootOwner::Host, &native(&root))
2406 .expect_err("a network share may not hold runner workspaces");
2407 assert!(
2408 matches!(error, RunnerRootError::RemoteFilesystem { .. }),
2409 "got {error}"
2410 );
2411 }
2412
2413 #[test]
2414 fn a_filesystem_this_host_cannot_classify_fails_closed() {
2415 let fixture = fixture();
2416 let root = fixture.workspaces.join("rman");
2417 std::fs::create_dir(&root).expect("the root is created");
2418 let probe =
2419 StubFilesystem::saying(FilesystemIdentity::unprovable("filesystem type 0x00001234"));
2420
2421 let error = RootPreflight::with_probe(&fixture.paths, &probe)
2422 .check(&RootOwner::Host, &native(&root))
2423 .expect_err("unprovable locality is a refusal, not a shrug");
2424 assert!(
2425 matches!(error, RunnerRootError::UnprovableFilesystem { .. }),
2426 "got {error}"
2427 );
2428 }
2429
2430 #[cfg(target_os = "macos")]
2438 #[test]
2439 fn a_refusal_the_superuser_received_names_the_privacy_control_not_the_permissions() {
2440 let fixture = fixture();
2441 let existing = fixture.workspaces.join("rman");
2442 std::fs::create_dir(&existing).expect("the root is created");
2443 let probe = StubFilesystem::unwritable_to_the_superuser();
2444 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2445
2446 let error = preflight
2447 .check(&RootOwner::Host, &native(&existing))
2448 .expect_err("a root the service cannot write is unusable");
2449
2450 assert!(
2451 matches!(error, RunnerRootError::DeniedByPrivacyPolicy { .. }),
2452 "a superuser cannot be refused by file permissions, so this is the \
2453 privacy layer: {error}"
2454 );
2455 let rendered = error.to_string();
2456 assert!(
2457 rendered.contains("Full Disk Access"),
2458 "the refusal must name the control that grants it: {rendered}"
2459 );
2460 assert!(
2461 rendered.contains(&RootOwner::Host.remediation()),
2462 "the refusal must still show the command that moves the root: {rendered}"
2463 );
2464 }
2465
2466 #[cfg(target_os = "macos")]
2471 #[test]
2472 fn a_superuser_refused_on_the_startup_disk_is_not_blamed_on_the_privacy_layer() {
2473 let fixture = fixture();
2474 let existing = fixture.workspaces.join("rman");
2475 std::fs::create_dir(&existing).expect("the root is created");
2476 let probe = StubFilesystem::unwritable_to_the_superuser_on_the_startup_disk();
2477 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2478
2479 let error = preflight
2480 .check(&RootOwner::Host, &native(&existing))
2481 .expect_err("an unwritable root is unusable");
2482
2483 assert!(
2484 matches!(error, RunnerRootError::NotWritable { .. }),
2485 "the privacy layer gates no directory on the startup disk: {error}"
2486 );
2487 }
2488
2489 #[cfg(target_os = "macos")]
2495 #[test]
2496 fn a_read_only_volume_is_not_blamed_on_the_privacy_layer() {
2497 let fixture = fixture();
2498 let existing = fixture.workspaces.join("rman");
2499 std::fs::create_dir(&existing).expect("the root is created");
2500 let probe = StubFilesystem::read_only_gated_volume();
2501 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2502
2503 let error = preflight
2504 .check(&RootOwner::Host, &native(&existing))
2505 .expect_err("a read-only root is unusable");
2506
2507 assert!(
2508 matches!(error, RunnerRootError::NotWritable { .. }),
2509 "consent cannot make a read-only mount writable: {error}"
2510 );
2511 }
2512
2513 #[cfg(target_os = "macos")]
2517 #[test]
2518 fn a_privacy_refusal_names_the_root_that_was_asked_for_and_the_one_that_refused() {
2519 let fixture = fixture();
2520 let leaf = fixture.workspaces.join("not-created-yet");
2521 let probe = StubFilesystem::unwritable_to_the_superuser();
2522 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2523
2524 let error = preflight
2525 .check(&RootOwner::Host, &native(&leaf))
2526 .expect_err("a root the service cannot create is unusable");
2527
2528 let rendered = error.to_string();
2529 assert!(
2530 rendered.contains("not-created-yet"),
2531 "the root the operator asked for is missing from the refusal: {rendered}"
2532 );
2533 assert!(
2534 rendered.contains(&fixture.workspaces.display().to_string()),
2535 "the directory that actually refused is missing from the refusal: {rendered}"
2536 );
2537 }
2538
2539 #[test]
2542 fn a_refusal_an_ordinary_account_received_still_names_file_permissions() {
2543 let fixture = fixture();
2544 let existing = fixture.workspaces.join("rman");
2545 std::fs::create_dir(&existing).expect("the root is created");
2546 let probe = StubFilesystem::unwritable();
2547 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2548
2549 let error = preflight
2550 .check(&RootOwner::Host, &native(&existing))
2551 .expect_err("an unwritable root is unusable");
2552
2553 assert!(
2554 matches!(error, RunnerRootError::NotWritable { .. }),
2555 "got {error}"
2556 );
2557 }
2558
2559 #[test]
2560 fn an_unwritable_directory_and_an_unwritable_parent_are_reported_apart() {
2561 let fixture = fixture();
2562 let existing = fixture.workspaces.join("rman");
2563 std::fs::create_dir(&existing).expect("the root is created");
2564 let missing = fixture.workspaces.join("other");
2565 let probe = StubFilesystem::unwritable();
2566 let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2567
2568 let error = preflight
2569 .check(&RootOwner::Host, &native(&existing))
2570 .expect_err("an unwritable root is unusable");
2571 assert!(
2572 matches!(error, RunnerRootError::NotWritable { .. }),
2573 "got {error}"
2574 );
2575 assert!(
2576 error.to_string().contains(&RootOwner::Host.remediation()),
2577 "the refusal must show the command that fixes it: {error}"
2578 );
2579
2580 let error = preflight
2581 .check(&RootOwner::Host, &native(&missing))
2582 .expect_err("an unwritable parent cannot hold a new leaf");
2583 let RunnerRootError::ParentNotWritable { parent, leaf, .. } = &error else {
2584 panic!("expected ParentNotWritable, got {error}");
2585 };
2586 assert_eq!(parent, &fixture.workspaces);
2587 assert_eq!(leaf, &missing);
2588 assert!(
2589 error.to_string().contains(&RootOwner::Host.remediation()),
2590 "the refusal must show the command that fixes it: {error}"
2591 );
2592
2593 let repository = RootOwner::Repository("acme/widgets".to_string());
2594 let error = preflight
2595 .check(&repository, &native(&missing))
2596 .expect_err("an unwritable parent cannot hold a new leaf");
2597 assert!(
2598 error.to_string().contains(&repository.remediation()),
2599 "a repository root must name its own command: {error}"
2600 );
2601 }
2602
2603 #[test]
2604 fn nothing_is_created_removed_or_repermissioned_by_any_verdict() {
2605 let fixture = fixture();
2609 let existing = fixture.workspaces.join("rman");
2610 std::fs::create_dir(&existing).expect("the root is created");
2611 let file = fixture.workspaces.join("notes.txt");
2612 std::fs::write(&file, b"operator data").expect("the file is created");
2613
2614 let before = snapshot(fixture.root.path());
2615
2616 let stub = StubFilesystem::unwritable();
2617 let host_preflight = RootPreflight::new(&fixture.paths);
2618 let stub_preflight = RootPreflight::with_probe(&fixture.paths, &stub);
2619 for preflight in [&host_preflight, &stub_preflight] {
2620 for candidate in [
2621 existing.clone(),
2622 fixture.workspaces.join("missing"),
2623 fixture.workspaces.join("a").join("b"),
2624 file.clone(),
2625 file.join("leaf"),
2626 fixture.paths.state_dir().to_path_buf(),
2627 ] {
2628 let _ = preflight.check(&RootOwner::Host, &native(&candidate));
2629 }
2630 }
2631
2632 assert_eq!(
2633 before,
2634 snapshot(fixture.root.path()),
2635 "the preflight changed the filesystem"
2636 );
2637 }
2638
2639 #[test]
2642 fn this_machines_temporary_directory_is_local_and_writable() {
2643 let root = tempfile::tempdir().expect("a temporary directory");
2644 let canonical = plain(&std::fs::canonicalize(root.path()).expect("it exists"));
2645
2646 let identity = HostFilesystem
2647 .identify(&canonical)
2648 .expect("the platform answers");
2649 assert_eq!(
2650 identity.locality,
2651 Locality::Local,
2652 "the suite's own temporary directory reported {identity:?}; a CI leg whose \
2653 temporary filesystem is unknown to this table would refuse every runner root"
2654 );
2655 assert!(
2656 HostFilesystem
2657 .is_writable(&canonical)
2658 .expect("the platform answers"),
2659 "a directory this process just created must be writable"
2660 );
2661 }
2662
2663 #[cfg(windows)]
2664 #[test]
2665 fn the_system_drive_root_is_writable_when_a_directory_can_be_created_in_it() {
2666 let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2673 let default = default_runner_root(&paths).expect("this host has a system directory");
2674 let parent = default
2675 .as_path()
2676 .parent()
2677 .expect("the default root is one level below the system drive")
2678 .to_path_buf();
2679
2680 let probe = parent.join(format!("rman-preflight-probe-{}", std::process::id()));
2681 if std::fs::create_dir(&probe).is_err() {
2682 return;
2683 }
2684 let writable = HostFilesystem.is_writable(&parent);
2685 std::fs::remove_dir(&probe).expect("the probe is removed");
2686
2687 assert!(
2688 writable.expect("the platform answers"),
2689 "{} accepts a new directory, so the preflight must not refuse the default \
2690 runner root as unwritable",
2691 parent.display()
2692 );
2693 }
2694
2695 #[cfg(unix)]
2696 #[test]
2697 fn a_directory_this_account_cannot_write_is_reported_as_such() {
2698 use std::os::unix::fs::PermissionsExt;
2699
2700 let root = tempfile::tempdir().expect("a temporary directory");
2701 let locked = root.path().join("locked");
2702 std::fs::create_dir(&locked).expect("the directory is created");
2703 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555))
2704 .expect("the directory is made unwritable");
2705
2706 let probe = locked.join("probe");
2709 let is_root = std::fs::create_dir(&probe).is_ok();
2710 if is_root {
2711 std::fs::remove_dir(&probe).expect("the probe is removed");
2712 }
2713 let writable = HostFilesystem.is_writable(&locked);
2714 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
2715 .expect("the directory is restored");
2716 if is_root {
2717 return;
2718 }
2719 assert!(
2720 !writable.expect("the platform answers"),
2721 "a 0555 directory must not be reported writable"
2722 );
2723 }
2724
2725 #[test]
2728 fn a_derived_child_is_one_component_below_the_root() {
2729 let root = LocalAbsolutePath::parse_for("/srv/rman", Unix).expect("a valid root");
2730 assert_eq!(
2731 derive_child(&root, "s1").expect("a valid slot").as_str(),
2732 "/srv/rman/s1"
2733 );
2734 let root = LocalAbsolutePath::parse_for("C:\\rman", Windows).expect("a valid root");
2735 assert_eq!(
2736 derive_child(&root, "0123456789ab")
2737 .expect("a valid attempt")
2738 .as_str(),
2739 "C:\\rman\\0123456789ab"
2740 );
2741 for name in ["..", "a/b", "", "."] {
2742 assert!(
2743 derive_child(&root, name).is_err(),
2744 "{name:?} must not be a derived child"
2745 );
2746 }
2747 }
2748
2749 #[test]
2750 fn containment_is_proven_lexically_and_after_resolution() {
2751 let fixture = fixture();
2752 let directory = fixture.workspaces.join("rman");
2753 std::fs::create_dir(&directory).expect("the root is created");
2754 let root = native(&directory);
2755
2756 let slot = derive_child(&root, "s1").expect("a valid slot");
2757 verify_containment(&root, &slot)
2758 .expect("a slot that does not exist yet is still contained");
2759 std::fs::create_dir(slot.as_path()).expect("the slot is created");
2760 verify_containment(&root, &slot).expect("an existing slot is contained");
2761
2762 let sibling = native(&fixture.workspaces.join("elsewhere"));
2763 assert!(
2764 matches!(
2765 verify_containment(&root, &sibling),
2766 Err(RunnerRootError::Escapes { .. })
2767 ),
2768 "a sibling is not contained"
2769 );
2770 assert!(
2771 matches!(
2772 verify_containment(&root, &root),
2773 Err(RunnerRootError::Escapes { .. })
2774 ),
2775 "the root is not strictly inside itself"
2776 );
2777 }
2778
2779 #[test]
2780 fn a_link_inside_the_root_that_points_outside_it_is_not_contained() {
2781 let fixture = fixture();
2782 let root = fixture.workspaces.join("rman");
2783 std::fs::create_dir(&root).expect("the root is created");
2784 let outside = fixture.workspaces.join("outside");
2785 std::fs::create_dir(&outside).expect("the escape target is created");
2786 let escape = root.join("s1");
2787 if !link_dir(&outside, &escape) {
2788 return;
2789 }
2790
2791 let error = verify_containment(&native(&root), &native(&escape))
2792 .expect_err("cleanup may not follow a link out of the root it was given");
2793 assert!(
2794 matches!(error, RunnerRootError::Escapes { .. }),
2795 "got {error}"
2796 );
2797 }
2798
2799 #[test]
2802 fn each_owner_names_the_command_that_changes_it() {
2803 assert_eq!(
2804 RootOwner::Host.remediation(),
2805 "runner-manager host set-runtime-root --path <PATH>"
2806 );
2807 assert_eq!(
2808 RootOwner::Repository("acme/widgets".to_string()).remediation(),
2809 "runner-manager repo set-workspace acme/widgets --mode persistent --path <PATH>"
2810 );
2811 assert!(RootOwner::Host.to_string().contains("host runner root"));
2812 assert!(
2813 RootOwner::Repository("acme/widgets".to_string())
2814 .to_string()
2815 .contains("acme/widgets")
2816 );
2817 }
2818
2819 #[test]
2820 fn a_refusal_names_the_paths_and_says_what_to_do_about_it() {
2821 let fixture = fixture();
2825 let root = fixture.workspaces.join("rman");
2826 std::fs::create_dir(&root).expect("the root is created");
2827
2828 let probe = StubFilesystem::saying(FilesystemIdentity::remote("nfs"));
2829 let message = RootPreflight::with_probe(&fixture.paths, &probe)
2830 .check(&RootOwner::Host, &native(&root))
2831 .expect_err("a network share is refused")
2832 .to_string();
2833 assert!(message.contains("nfs"), "{message}");
2834 assert!(message.contains("local volume"), "{message}");
2835
2836 let message = fixture
2837 .check(fixture.paths.state_dir())
2838 .expect_err("application data is protected")
2839 .to_string();
2840 assert!(
2841 message.contains(&fixture.paths.state_dir().display().to_string()),
2842 "the directory that refused must be named: {message}"
2843 );
2844 assert!(
2845 message.contains("the application state directory"),
2846 "the operator must be told what it collided with: {message}"
2847 );
2848 assert!(
2849 message.contains("outside the application data tree"),
2850 "the message must say what to do instead: {message}"
2851 );
2852 }
2853}