1use crate::storage;
28use crate::util::{err, Error, Result};
29use crate::workspace::QaWorkspace;
30use std::fmt;
31use std::fs;
32use std::path::{Component, Path, PathBuf, Prefix};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Rule {
37 UserFolder,
39 CloudStorage,
41 ExternalVolume,
43 RealAppData,
45 OutsideQaRoot,
47 OutsideTemp,
49 NotQaRoot,
51 Unresolvable,
53 UnscrubbedRealData,
55 NoIsolatedDataDir,
58 ForbiddenArgument,
60 ProbeUnavailable,
63}
64
65impl fmt::Display for Rule {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.write_str(match self {
68 Rule::UserFolder => "user-folder",
69 Rule::CloudStorage => "cloud-storage",
70 Rule::ExternalVolume => "external-volume",
71 Rule::RealAppData => "real-app-data",
72 Rule::OutsideQaRoot => "outside-qa-root",
73 Rule::OutsideTemp => "outside-temp",
74 Rule::NotQaRoot => "not-a-qa-root",
75 Rule::Unresolvable => "unresolvable",
76 Rule::UnscrubbedRealData => "unscrubbed-real-data",
77 Rule::NoIsolatedDataDir => "no-isolated-data-dir",
78 Rule::ForbiddenArgument => "forbidden-argument",
79 Rule::ProbeUnavailable => "refusal-probe-unavailable",
80 })
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct UserDataViolation {
88 pub path: PathBuf,
89 pub rule: Rule,
90 pub detail: String,
91}
92
93impl fmt::Display for UserDataViolation {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 write!(
96 f,
97 "QA refuses to touch {} (rule {}): {}; nothing was launched",
98 self.path.display(),
99 self.rule,
100 self.detail
101 )
102 }
103}
104impl std::error::Error for UserDataViolation {}
105impl From<UserDataViolation> for Error {
106 fn from(v: UserDataViolation) -> Self {
107 Error(v.to_string())
108 }
109}
110
111const USER_FOLDERS: &[&str] = &[
112 "documents",
113 "desktop",
114 "downloads",
115 "pictures",
116 "music",
117 "movies",
118 "videos",
119];
120
121pub fn resolve(path: &Path) -> PathBuf {
124 let mut base = if path.is_absolute() {
125 path.to_path_buf()
126 } else {
127 std::env::current_dir().unwrap_or_default().join(path)
128 };
129 let mut tail: Vec<std::ffi::OsString> = Vec::new();
130 loop {
131 if let Ok(c) = fs::canonicalize(&base) {
132 let mut out = c;
133 for t in tail.iter().rev() {
134 out.push(t);
135 }
136 return out;
137 }
138 match (base.file_name(), base.parent()) {
139 (Some(name), Some(parent)) => {
140 tail.push(name.to_os_string());
141 base = parent.to_path_buf();
142 }
143 _ => return path.to_path_buf(),
144 }
145 }
146}
147
148fn comps(p: &Path) -> Vec<String> {
149 p.components()
150 .filter_map(|c| match c {
151 Component::Normal(s) => Some(s.to_string_lossy().to_lowercase()),
152 _ => None,
153 })
154 .collect()
155}
156
157fn alnum(s: &str) -> String {
158 s.chars()
159 .filter(|c| c.is_alphanumeric())
160 .flat_map(char::to_lowercase)
161 .collect()
162}
163
164fn is_network_prefix(p: &Path) -> bool {
165 matches!(
166 p.components().next(),
167 Some(Component::Prefix(pre)) if matches!(pre.kind(), Prefix::UNC(..) | Prefix::VerbatimUNC(..))
168 )
169}
170
171fn under_home(rest: &[String], app: Option<&str>) -> Option<(Rule, String)> {
173 if rest.is_empty() {
174 return Some((Rule::UserFolder, "the user's home directory".into()));
175 }
176 if USER_FOLDERS.contains(&rest[0].as_str()) {
177 return Some((Rule::UserFolder, format!("the user's {} folder", rest[0])));
178 }
179 if rest[0] == "library"
180 && matches!(
181 rest.get(1).map(String::as_str),
182 Some("mobile documents") | Some("cloudstorage")
183 )
184 {
185 return Some((Rule::CloudStorage, "iCloud Drive / CloudStorage".into()));
186 }
187 let app = alnum(app?);
188 if app.is_empty() {
189 return None;
190 }
191 let hit = |i: usize| rest.get(i).is_some_and(|c| alnum(c).contains(&app));
192 let second = rest.get(1).map(String::as_str);
193 let in_set = |set: &[&str]| second.is_some_and(|s| set.contains(&s));
194 let found = match rest[0].as_str() {
195 "library" => {
196 in_set(&[
197 "application support",
198 "containers",
199 "group containers",
200 "preferences",
201 "caches",
202 "saved application state",
203 "webkit",
204 "logs",
205 ]) && hit(2)
206 }
207 "appdata" => in_set(&["roaming", "local", "locallow"]) && hit(2),
208 ".local" => in_set(&["share", "state"]) && hit(2),
209 ".config" | ".cache" => hit(1),
210 _ => false,
211 };
212 found.then(|| {
213 (
214 Rule::RealAppData,
215 "the user's real data directory of the app under test".into(),
216 )
217 })
218}
219
220#[derive(Debug, Clone)]
223pub struct Guard {
224 app: Option<String>,
225 user_homes: Vec<PathBuf>,
226 temp_roots: Vec<PathBuf>,
227 qa_root: Option<PathBuf>,
228}
229
230fn default_user_homes() -> Vec<PathBuf> {
231 ["HOME", "USERPROFILE"]
232 .iter()
233 .filter_map(std::env::var_os)
234 .filter(|v| !v.is_empty())
235 .map(|v| resolve(Path::new(&v)))
236 .collect()
237}
238
239fn default_temp_roots() -> Vec<PathBuf> {
240 let mut roots = vec![
241 resolve(&storage::app_temp_dir()),
242 resolve(&std::env::temp_dir()),
243 resolve(&storage::home_base()),
244 ];
245 if let Some(t) = std::env::var_os("RUNNER_TEMP").filter(|v| !v.is_empty()) {
246 roots.push(resolve(Path::new(&t)));
247 }
248 roots.sort();
249 roots.dedup();
250 roots
251}
252
253impl Guard {
254 pub fn for_temp_roots(app: Option<&str>) -> Guard {
257 Guard {
258 app: app.map(str::to_owned),
259 user_homes: default_user_homes(),
260 temp_roots: default_temp_roots(),
261 qa_root: None,
262 }
263 }
264
265 pub fn for_workspace(ws: &QaWorkspace) -> std::result::Result<Guard, UserDataViolation> {
268 let mut guard = Guard::for_temp_roots(None);
269 let home = resolve(&ws.home);
270 let fail = |rule, detail: String| UserDataViolation {
271 path: home.clone(),
272 rule,
273 detail,
274 };
275 let binding = fs::read(home.join(".rightkit-app.json"))
276 .ok()
277 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok());
278 let Some(binding) = binding else {
279 return Err(fail(
280 Rule::NotQaRoot,
281 "no .rightkit-app.json binding: not a QA home created by rightkit-qa".into(),
282 ));
283 };
284 guard.app = binding
285 .get("app")
286 .and_then(|v| v.as_str())
287 .map(str::to_owned);
288 if home.parent().and_then(Path::file_name) != Some(std::ffi::OsStr::new("rightkit-qa")) {
289 return Err(fail(
290 Rule::NotQaRoot,
291 "the home is not directly under a rightkit-qa directory".into(),
292 ));
293 }
294 guard.judge_outside_workspace(&home)?;
295 guard.qa_root = Some(home);
296 Ok(guard)
297 }
298
299 pub fn with_user_homes(mut self, homes: Vec<PathBuf>) -> Guard {
302 self.user_homes = homes.iter().map(|h| resolve(h)).collect();
303 self
304 }
305
306 pub fn with_app(mut self, app: &str) -> Guard {
308 self.app = Some(app.to_owned());
309 self
310 }
311
312 pub fn forbidden(&self, resolved: &Path) -> Option<(Rule, String)> {
314 if is_network_prefix(resolved) {
315 return Some((Rule::ExternalVolume, "a network (UNC) path".into()));
316 }
317 let c = comps(resolved);
318 let has_prefix = matches!(resolved.components().next(), Some(Component::Prefix(_)));
319 let on_known_root = self
322 .temp_roots
323 .iter()
324 .chain(self.user_homes.iter())
325 .any(|root| resolved.starts_with(root));
326 if !has_prefix && !on_known_root && c.first().map(String::as_str) == Some("volumes") {
327 return Some((
328 Rule::ExternalVolume,
329 "an external or network volume under /Volumes".into(),
330 ));
331 }
332 for (i, name) in c.iter().enumerate() {
333 let cloud = name.starts_with("onedrive")
334 || name.starts_with("dropbox")
335 || name.starts_with("google drive")
336 || name == "googledrive"
337 || name == "my drive"
338 || name == "icloud drive"
339 || name == "iclouddrive"
340 || name == "mobile documents"
341 || (name == "cloudstorage" && i > 0 && c[i - 1] == "library");
342 if cloud {
343 return Some((
344 Rule::CloudStorage,
345 format!("a cloud-synced folder ({name})"),
346 ));
347 }
348 }
349 if matches!(c.first().map(String::as_str), Some("users") | Some("home")) && c.len() >= 2 {
351 if let Some(hit) = under_home(&c[2..], self.app.as_deref()) {
352 if c.len() > 2 {
353 return Some(hit);
354 }
355 }
356 }
357 for home in &self.user_homes {
358 if let Ok(rel) = resolved.strip_prefix(home) {
359 if let Some(hit) = under_home(&comps(rel), self.app.as_deref()) {
360 return Some(hit);
361 }
362 }
363 }
364 None
365 }
366
367 fn judge_outside_workspace(
368 &self,
369 resolved: &Path,
370 ) -> std::result::Result<(), UserDataViolation> {
371 let violation = |rule, detail: String| UserDataViolation {
372 path: resolved.to_path_buf(),
373 rule,
374 detail,
375 };
376 if let Some((rule, detail)) = self.forbidden(resolved) {
377 return Err(violation(rule, detail));
378 }
379 if !self.temp_roots.iter().any(|t| resolved.starts_with(t)) {
380 return Err(violation(
381 Rule::OutsideTemp,
382 "not inside a system temp directory on the internal disk".into(),
383 ));
384 }
385 Ok(())
386 }
387
388 pub fn check_dir(&self, path: &Path) -> std::result::Result<PathBuf, UserDataViolation> {
390 if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) {
391 return Err(UserDataViolation {
392 path: path.to_path_buf(),
393 rule: Rule::Unresolvable,
394 detail: "the path must be absolute and contain no `..`".into(),
395 });
396 }
397 let resolved = resolve(path);
398 if let Some(root) = &self.qa_root {
399 if resolved.starts_with(root) {
400 return Ok(resolved);
401 }
402 if let Some((rule, detail)) = self.forbidden(&resolved) {
403 return Err(UserDataViolation {
404 path: resolved,
405 rule,
406 detail,
407 });
408 }
409 return Err(UserDataViolation {
410 detail: format!("not inside the QA root {}", root.display()),
411 path: resolved,
412 rule: Rule::OutsideQaRoot,
413 });
414 }
415 self.judge_outside_workspace(&resolved)?;
416 Ok(resolved)
417 }
418
419 pub fn check_env(
421 &self,
422 env: &[(String, String)],
423 ) -> std::result::Result<(), UserDataViolation> {
424 for (_, value) in env {
425 if Path::new(value).is_absolute() {
426 self.check_dir(Path::new(value))?;
427 }
428 }
429 Ok(())
430 }
431}
432
433pub fn check_engine_dirs(data_dir: Option<&Path>, env: &[(String, String)]) -> Result<()> {
440 Ok(engine_dirs_violation(
441 &Guard::for_temp_roots(None),
442 data_dir,
443 env,
444 )?)
445}
446
447fn engine_dirs_violation(
448 guard: &Guard,
449 data_dir: Option<&Path>,
450 env: &[(String, String)],
451) -> std::result::Result<(), UserDataViolation> {
452 if let Some(dir) = data_dir {
453 guard.check_dir(dir)?;
454 }
455 for (key, value) in env {
456 let k = key.to_uppercase();
457 let names_a_dir = ["DATA", "HOME", "PROFILE", "CONFIG"]
458 .iter()
459 .any(|w| k.contains(w))
460 || matches!(k.as_str(), "APPDATA" | "LOCALAPPDATA" | "TMPDIR");
461 if names_a_dir && Path::new(value).is_absolute() {
462 guard.check_dir(Path::new(value))?;
463 }
464 }
465 Ok(())
466}
467
468#[derive(Debug, Clone)]
473pub struct EngineLaunch<'a> {
474 pub binary: &'a Path,
476 pub args: &'a [String],
478 pub data_dir: Option<&'a Path>,
480 pub data_dir_env: Option<&'a str>,
482 pub data_dir_arg: Option<&'a str>,
485 pub forbidden_args: &'a [String],
487 pub env: &'a [(String, String)],
489 pub refusal_probe: bool,
491}
492
493fn env_value<'e>(env: &'e [(String, String)], key: &str, windows: bool) -> Option<&'e str> {
494 env.iter()
495 .rev()
496 .find(|(k, _)| {
497 if windows {
498 k.eq_ignore_ascii_case(key)
499 } else {
500 k == key
501 }
502 })
503 .map(|(_, v)| v.as_str())
504}
505
506fn arg_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
508 let mut found = None;
509 for (i, a) in args.iter().enumerate() {
510 if a == flag {
511 found = args.get(i + 1).map(String::as_str);
512 } else if let Some(v) = a.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
513 found = Some(v);
514 }
515 }
516 found
517}
518
519pub fn check_engine_launch(v: &EngineLaunch<'_>) -> std::result::Result<(), UserDataViolation> {
535 check_engine_launch_in(
536 &Guard::for_temp_roots(None),
537 v,
538 cfg!(windows),
539 &|k: &str| std::env::var(k).ok(),
540 )
541}
542
543fn check_engine_launch_in(
544 guard: &Guard,
545 v: &EngineLaunch<'_>,
546 windows: bool,
547 inherited: &dyn Fn(&str) -> Option<String>,
548) -> std::result::Result<(), UserDataViolation> {
549 let target = v.binary.display().to_string();
550 let refuse = |rule, detail: String| UserDataViolation {
551 path: v.binary.to_path_buf(),
552 rule,
553 detail,
554 };
555 for arg in v.args {
556 for flag in v.forbidden_args {
557 if arg == flag
558 || arg
559 .strip_prefix(flag.as_str())
560 .is_some_and(|r| r.starts_with('='))
561 {
562 return Err(refuse(
563 Rule::ForbiddenArgument,
564 format!("launch of {target} carries {flag}, which the target declares as a switch to the real library"),
565 ));
566 }
567 }
568 }
569 engine_dirs_violation(guard, v.data_dir, v.env)?;
570 let mut named = 0usize;
571 if let Some(var) = v.data_dir_env {
572 if let Some(value) = env_value(v.env, var, windows).filter(|s| !s.is_empty()) {
573 guard.check_dir(Path::new(value))?;
574 named += 1;
575 }
576 }
577 if let Some(flag) = v.data_dir_arg {
578 if let Some(value) = arg_value(v.args, flag).filter(|s| !s.is_empty()) {
579 guard.check_dir(Path::new(value))?;
580 named += 1;
581 }
582 }
583 if named > 0 {
584 return Ok(());
585 }
586 if !v.refusal_probe {
587 let why = if v.data_dir.is_some() {
588 "a data directory was passed, but the target declares no data_dir_env, so the engine would never receive it"
589 } else {
590 "no data directory was passed"
591 };
592 return Err(refuse(
593 Rule::NoIsolatedDataDir,
594 format!("launch of {target} names no isolated data directory ({why}); an isolated directory is required, because the engine would resolve its real per-user folder by its own default. Pass one through the target's data_dir_env (or data_dir_arg); to assert the engine's own refusal on Unix, use allow_no_data_dir_for_refusal_probe with HOME redirected"),
595 ));
596 }
597 if windows {
598 return Err(refuse(
599 Rule::ProbeUnavailable,
600 format!("launch of {target} names no isolated data directory, and the refusal-probe opt-out is unavailable on Windows: the engine resolves its per-user folder through the Known Folder API, which ignores HOME, USERPROFILE and APPDATA in the child environment, so nothing would stand between it and the real folder"),
601 ));
602 }
603 let barrier = |detail: String| {
604 refuse(
605 Rule::ProbeUnavailable,
606 format!("launch of {target} names no isolated data directory, and the refusal-probe opt-out needs the child environment to redirect HOME and any XDG data/config home into a QA directory: {detail}"),
607 )
608 };
609 let home = env_value(v.env, "HOME", false)
610 .filter(|s| !s.is_empty())
611 .ok_or_else(|| barrier("HOME is not set in the child environment".into()))?;
612 guard
613 .check_dir(Path::new(home))
614 .map_err(|e| barrier(format!("HOME: {e}")))?;
615 for key in ["XDG_DATA_HOME", "XDG_CONFIG_HOME"] {
616 let effective = env_value(v.env, key, false)
617 .map(str::to_owned)
618 .or_else(|| inherited(key))
619 .filter(|s| !s.is_empty());
620 if let Some(value) = effective {
621 guard
622 .check_dir(Path::new(&value))
623 .map_err(|e| barrier(format!("{key}: {e}")))?;
624 }
625 }
626 Ok(())
627}
628
629pub fn check_app_data_dir(
633 guard: &Guard,
634 app_binary: &Path,
635 env: &[(String, String)],
636) -> std::result::Result<(), UserDataViolation> {
637 match env_value(env, "RIGHTKIT_QA_DATA_DIR", cfg!(windows)).filter(|s| !s.is_empty()) {
638 Some(dir) => guard.check_dir(Path::new(dir)).map(|_| ()),
639 None => Err(UserDataViolation {
640 path: app_binary.to_path_buf(),
641 rule: Rule::NoIsolatedDataDir,
642 detail: format!(
643 "launch of {} names no isolated data directory (RIGHTKIT_QA_DATA_DIR is missing from the launch environment); an isolated directory is required, because the app would resolve its real per-user folder by its own default",
644 app_binary.display()
645 ),
646 }),
647 }
648}
649
650pub struct ScrubDeclaration<'a> {
652 pub declared_scrubbed: bool,
654 pub verify: &'a dyn Fn(&Path) -> bool,
656}
657
658pub fn seed_from(
665 ws: &QaWorkspace,
666 app: &str,
667 source: &Path,
668 dest_rel: &Path,
669 transform: impl FnOnce(&Path, &Path) -> Result<()>,
670 scrub: Option<&ScrubDeclaration<'_>>,
671) -> Result<PathBuf> {
672 let guard = Guard::for_workspace(ws)?.with_app(app);
673 seed_from_guarded(&guard, ws, source, dest_rel, transform, scrub)
674}
675
676pub fn seed_from_guarded(
678 guard: &Guard,
679 ws: &QaWorkspace,
680 source: &Path,
681 dest_rel: &Path,
682 transform: impl FnOnce(&Path, &Path) -> Result<()>,
683 scrub: Option<&ScrubDeclaration<'_>>,
684) -> Result<PathBuf> {
685 if dest_rel.is_absolute() || dest_rel.components().any(|c| c == Component::ParentDir) {
686 return err("seed destination must be relative to the QA data directory");
687 }
688 let dest = guard.check_dir(&ws.data_dir.join(dest_rel))?;
689 let resolved_source = resolve(source);
690 let real = guard.forbidden(&resolved_source);
691 if let Some((_, detail)) = &real {
692 match scrub {
693 None => {
694 return Err(UserDataViolation {
695 path: resolved_source,
696 rule: Rule::UnscrubbedRealData,
697 detail: format!("{detail}; use a generated fixture, or declare the copy scrubbed and supply a verification callback"),
698 }
699 .into())
700 }
701 Some(s) if !s.declared_scrubbed => {
702 return Err(UserDataViolation {
703 path: resolved_source,
704 rule: Rule::UnscrubbedRealData,
705 detail: format!("{detail}; the scenario did not declare the copy scrubbed"),
706 }
707 .into())
708 }
709 Some(_) => {}
710 }
711 }
712 transform(source, &dest)?;
713 if let Some(s) = scrub {
714 if (real.is_some() || s.declared_scrubbed) && !(s.verify)(&dest) {
715 let _ = if dest.is_dir() {
717 fs::remove_dir_all(&dest)
718 } else {
719 fs::remove_file(&dest)
720 };
721 return Err(UserDataViolation {
722 path: dest,
723 rule: Rule::UnscrubbedRealData,
724 detail: "the verification callback rejected the seeded copy; it was deleted and nothing launches".into(),
725 }
726 .into());
727 }
728 }
729 Ok(dest)
730}
731
732const SYSTEM_DIALOG_OWNERS: &[&str] = &[
734 "usernotificationcenter",
735 "coreservicesuiagent",
736 "universalaccessauthwarn",
737 "securityagent",
738 "tccd",
739];
740
741pub fn is_system_dialog_owner(name: &str) -> bool {
743 let n = name.to_lowercase();
744 SYSTEM_DIALOG_OWNERS.iter().any(|o| n == *o)
745}
746
747#[derive(Debug, Clone, PartialEq, Eq)]
749pub enum DialogCheck {
750 Clear,
752 Dialog(String),
754 Unsupported(&'static str),
756}
757
758pub fn permission_dialog_in(frontmost_states: &[String]) -> Option<String> {
762 frontmost_states
763 .iter()
764 .find(|n| is_system_dialog_owner(n))
765 .cloned()
766}
767
768pub fn live_permission_dialog_check(_app_pid: u32) -> DialogCheck {
774 DialogCheck::Unsupported(
775 "no window-by-owner enumeration in rightkit-qa-driver; permission dialogs are not observable",
776 )
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::util::new_id;
783
784 struct Fx {
785 root: PathBuf,
786 fake_home: PathBuf,
787 ws: QaWorkspace,
788 }
789
790 fn fx() -> Fx {
791 let root = storage::app_temp_dir().join(format!("rkqa-ud-{}", new_id()));
792 let fake_home = root.join("fakehome");
793 for d in [
794 "Documents",
795 "Desktop",
796 "Downloads",
797 "Library/Mobile Documents/com~apple~CloudDocs",
798 "Library/CloudStorage/OneDrive-Acme",
799 "Library/Application Support/ViewRight/db",
800 "AppData/Roaming/ViewRight",
801 "Dropbox",
802 "Elsewhere",
803 ] {
804 fs::create_dir_all(fake_home.join(d)).unwrap();
805 }
806 let ws = crate::workspace::create(&root.join("managed"), None, "viewright").unwrap();
807 Fx {
808 root,
809 fake_home,
810 ws,
811 }
812 }
813
814 fn done(f: Fx) {
815 let _ = crate::workspace::dispose(&f.ws, false, 1, 0);
816 let _ = fs::remove_dir_all(&f.root);
817 }
818
819 fn guard(f: &Fx) -> Guard {
820 Guard::for_workspace(&f.ws)
821 .unwrap()
822 .with_user_homes(vec![f.fake_home.clone()])
823 }
824
825 #[test]
826 fn every_forbidden_location_is_refused_with_its_rule() {
827 let f = fx();
828 let g = guard(&f);
829 let h = &f.fake_home;
830 let cases: Vec<(PathBuf, Rule)> = vec![
831 (h.join("Documents"), Rule::UserFolder),
832 (h.join("Documents/sub/dir"), Rule::UserFolder),
833 (h.join("Desktop"), Rule::UserFolder),
834 (h.join("Downloads"), Rule::UserFolder),
835 (h.clone(), Rule::UserFolder),
836 (
837 h.join("Library/Mobile Documents/com~apple~CloudDocs"),
838 Rule::CloudStorage,
839 ),
840 (
841 h.join("Library/CloudStorage/OneDrive-Acme"),
842 Rule::CloudStorage,
843 ),
844 (h.join("Dropbox"), Rule::CloudStorage),
845 (
846 h.join("Library/Application Support/ViewRight/db"),
847 Rule::RealAppData,
848 ),
849 (h.join("AppData/Roaming/ViewRight"), Rule::RealAppData),
850 ];
851 for (path, rule) in cases {
852 let v = g.check_dir(&path).unwrap_err();
853 assert_eq!(v.rule, rule, "{}", path.display());
854 assert!(
857 v.to_string().contains(&path.display().to_string())
858 || v.path.starts_with(&f.fake_home)
859 || v.path.starts_with(resolve(&f.fake_home)),
860 "{v}"
861 );
862 }
863 let v = g.check_dir(&h.join("Elsewhere")).unwrap_err();
865 assert_eq!(v.rule, Rule::OutsideQaRoot);
866 done(f);
867 }
868
869 #[cfg(unix)]
870 #[test]
871 fn a_temp_root_on_a_volumes_disk_is_not_an_external_volume() {
872 let g = Guard {
874 app: None,
875 user_homes: vec![],
876 temp_roots: vec![PathBuf::from("/Volumes/D/.rightkit-managed/tmp")],
877 qa_root: None,
878 };
879 assert!(g
880 .forbidden(Path::new(
881 "/Volumes/D/.rightkit-managed/tmp/jobs/rightkit-qa-budget-1"
882 ))
883 .is_none_or(|(rule, _)| rule != Rule::ExternalVolume));
884 let (rule, _) = g.forbidden(Path::new("/Volumes/Backup/qa-home")).unwrap();
885 assert_eq!(rule, Rule::ExternalVolume);
886 }
887
888 #[cfg(unix)]
889 #[test]
890 fn external_volumes_and_other_accounts_are_refused() {
891 let f = fx();
892 let g = guard(&f);
893 let v = g
894 .check_dir(Path::new("/Volumes/Backup/qa-home"))
895 .unwrap_err();
896 assert_eq!(v.rule, Rule::ExternalVolume);
897 let v = g
898 .check_dir(Path::new("/Users/someone-else/Documents/x"))
899 .unwrap_err();
900 assert_eq!(v.rule, Rule::UserFolder);
901 let v = g
902 .check_dir(Path::new(
903 "/Users/someone-else/Library/Application Support/ViewRight",
904 ))
905 .unwrap_err();
906 assert_eq!(v.rule, Rule::RealAppData);
907 let v = g.check_dir(Path::new("relative/path")).unwrap_err();
908 assert_eq!(v.rule, Rule::Unresolvable);
909 done(f);
910 }
911
912 #[test]
913 fn a_created_qa_root_and_its_directories_are_accepted() {
914 let f = fx();
915 let g = guard(&f);
916 for dir in [
917 f.ws.home.clone(),
918 f.ws.data_dir.clone(),
919 f.ws.home.join("tmp"),
920 f.ws.home.join("not/created/yet"),
921 ] {
922 g.check_dir(&dir).unwrap();
923 }
924 let env: Vec<(String, String)> = f.ws.env.clone().into_iter().collect();
925 g.check_env(&env).unwrap();
926 done(f);
927 }
928
929 #[test]
930 fn a_directory_that_is_not_a_qa_root_is_not_accepted_as_one() {
931 let f = fx();
932 let mut ws = f.ws.clone();
933 ws.home = f.root.join("fakehome/Elsewhere");
934 assert_eq!(Guard::for_workspace(&ws).unwrap_err().rule, Rule::NotQaRoot);
935 ws.home = f.root.clone();
936 assert_eq!(Guard::for_workspace(&ws).unwrap_err().rule, Rule::NotQaRoot);
937 done(f);
938 }
939
940 #[cfg(unix)]
941 #[test]
942 fn a_symlink_out_of_the_qa_root_is_refused() {
943 let f = fx();
944 let g = guard(&f);
945 let into_documents = f.ws.home.join("looks-internal");
946 std::os::unix::fs::symlink(f.fake_home.join("Documents"), &into_documents).unwrap();
947 assert_eq!(
948 g.check_dir(&into_documents).unwrap_err().rule,
949 Rule::UserFolder
950 );
951 assert_eq!(
952 g.check_dir(&into_documents.join("deeper/file"))
953 .unwrap_err()
954 .rule,
955 Rule::UserFolder
956 );
957 let plain = f.ws.home.join("to-plain");
958 std::os::unix::fs::symlink(f.fake_home.join("Elsewhere"), &plain).unwrap();
959 assert_eq!(g.check_dir(&plain).unwrap_err().rule, Rule::OutsideQaRoot);
960 done(f);
961 }
962
963 #[test]
964 fn an_environment_path_outside_the_root_is_refused() {
965 let f = fx();
966 let g = guard(&f);
967 let env = vec![(
968 "VIEWRIGHT_DATA_DIR".to_string(),
969 f.fake_home
970 .join("Library/Application Support/ViewRight")
971 .to_string_lossy()
972 .into_owned(),
973 )];
974 assert_eq!(g.check_env(&env).unwrap_err().rule, Rule::RealAppData);
975 done(f);
976 }
977
978 #[test]
979 fn engine_data_dirs_must_be_in_temp_and_clear_of_user_folders() {
980 let f = fx();
981 check_engine_dirs(Some(&f.ws.data_dir), &[]).unwrap();
982 let g = Guard::for_temp_roots(Some("viewright")).with_user_homes(vec![f.fake_home.clone()]);
983 assert_eq!(
984 g.check_dir(&f.fake_home.join("Documents"))
985 .unwrap_err()
986 .rule,
987 Rule::UserFolder
988 );
989 done(f);
990 }
991
992 #[test]
993 fn seed_refuses_real_app_data_without_a_verified_scrub() {
994 let f = fx();
995 let g = guard(&f);
996 let real = f.fake_home.join("Library/Application Support/ViewRight/db");
997 fs::write(real.join("app.sqlite"), "user rows").unwrap();
998 let copy = |from: &Path, to: &Path| -> Result<()> {
999 fs::create_dir_all(to)?;
1000 fs::copy(from.join("app.sqlite"), to.join("app.sqlite"))?;
1001 Ok(())
1002 };
1003 let rel = Path::new("seed");
1004 let e = seed_from_guarded(&g, &f.ws, &real, rel, copy, None).unwrap_err();
1006 assert!(e.to_string().contains("unscrubbed-real-data"), "{e}");
1007 assert!(!f.ws.data_dir.join("seed").exists());
1008 let no = ScrubDeclaration {
1010 declared_scrubbed: false,
1011 verify: &|_| true,
1012 };
1013 assert!(seed_from_guarded(&g, &f.ws, &real, rel, copy, Some(&no)).is_err());
1014 let bad = ScrubDeclaration {
1016 declared_scrubbed: true,
1017 verify: &|_| false,
1018 };
1019 assert!(seed_from_guarded(&g, &f.ws, &real, rel, copy, Some(&bad)).is_err());
1020 assert!(!f.ws.data_dir.join("seed").exists());
1021 let good = ScrubDeclaration {
1023 declared_scrubbed: true,
1024 verify: &|dest| fs::write(dest.join("app.sqlite"), "").is_ok(),
1025 };
1026 let out = seed_from_guarded(&g, &f.ws, &real, rel, copy, Some(&good)).unwrap();
1027 assert_eq!(fs::read(out.join("app.sqlite")).unwrap(), b"");
1028 let generated = f.ws.scenario_dir("generated").unwrap();
1030 fs::write(generated.join("fixture.txt"), "fixture").unwrap();
1031 let ok = seed_from_guarded(
1032 &g,
1033 &f.ws,
1034 &generated,
1035 Path::new("seed2"),
1036 |from, to| {
1037 fs::create_dir_all(to)?;
1038 fs::copy(from.join("fixture.txt"), to.join("fixture.txt"))?;
1039 Ok(())
1040 },
1041 None,
1042 )
1043 .unwrap();
1044 assert!(ok.join("fixture.txt").exists());
1045 assert!(seed_from_guarded(
1047 &g,
1048 &f.ws,
1049 &generated,
1050 Path::new("../x"),
1051 |_, _| Ok(()),
1052 None
1053 )
1054 .is_err());
1055 done(f);
1056 }
1057
1058 #[test]
1059 fn dialog_owner_matching() {
1060 assert!(is_system_dialog_owner("UserNotificationCenter"));
1061 assert!(is_system_dialog_owner("CoreServicesUIAgent"));
1062 assert!(!is_system_dialog_owner("Finder"));
1063 assert_eq!(
1064 permission_dialog_in(&["Finder".into(), "CoreServicesUIAgent".into()]).as_deref(),
1065 Some("CoreServicesUIAgent")
1066 );
1067 assert_eq!(permission_dialog_in(&["Finder".into()]), None);
1068 assert!(matches!(
1069 live_permission_dialog_check(1),
1070 DialogCheck::Unsupported(_)
1071 ));
1072 }
1073 struct Case {
1078 args: Vec<String>,
1079 env: Vec<(String, String)>,
1080 data_dir: Option<PathBuf>,
1081 data_dir_env: Option<&'static str>,
1082 data_dir_arg: Option<&'static str>,
1083 forbidden: Vec<String>,
1084 probe: bool,
1085 windows: bool,
1086 inherited: Vec<(String, String)>,
1087 }
1088
1089 fn case() -> Case {
1090 Case {
1091 args: vec!["doctor".into()],
1092 env: Vec::new(),
1093 data_dir: None,
1094 data_dir_env: Some("VIEWRIGHT_APP_DATA"),
1095 data_dir_arg: None,
1096 forbidden: vec!["--use-user-library".into()],
1097 probe: false,
1098 windows: false,
1099 inherited: Vec::new(),
1100 }
1101 }
1102
1103 fn set(c: &mut Case, key: &str, dir: &Path) {
1104 c.env.push((key.into(), dir.to_string_lossy().into_owned()));
1105 }
1106
1107 fn judge(f: &Fx, c: &Case) -> std::result::Result<(), UserDataViolation> {
1108 let guard =
1109 Guard::for_temp_roots(Some("viewright")).with_user_homes(vec![f.fake_home.clone()]);
1110 let binary = f.root.join("engine");
1111 check_engine_launch_in(
1112 &guard,
1113 &EngineLaunch {
1114 binary: &binary,
1115 args: &c.args,
1116 data_dir: c.data_dir.as_deref(),
1117 data_dir_env: c.data_dir_env,
1118 data_dir_arg: c.data_dir_arg,
1119 forbidden_args: &c.forbidden,
1120 env: &c.env,
1121 refusal_probe: c.probe,
1122 },
1123 c.windows,
1124 &|k: &str| {
1125 c.inherited
1126 .iter()
1127 .find(|(n, _)| n == k)
1128 .map(|(_, v)| v.clone())
1129 },
1130 )
1131 }
1132
1133 fn rule_of(r: std::result::Result<(), UserDataViolation>) -> Rule {
1134 r.unwrap_err().rule
1135 }
1136
1137 #[test]
1138 fn a_launch_with_no_data_dir_is_refused_and_says_which_target() {
1139 let f = fx();
1140 let c = case();
1141 let v = judge(&f, &c).unwrap_err();
1142 assert_eq!(v.rule, Rule::NoIsolatedDataDir);
1143 assert_eq!(v.path, f.root.join("engine"));
1144 let text = v.to_string();
1145 assert!(text.contains("engine"), "{text}");
1146 assert!(text.contains("isolated data directory"), "{text}");
1147 assert!(text.contains("nothing was launched"), "{text}");
1148 let mut c = case();
1150 c.env.push(("VIEWRIGHT_APP_DATA".into(), String::new()));
1151 assert_eq!(rule_of(judge(&f, &c)), Rule::NoIsolatedDataDir);
1152 let mut c = case();
1154 set(&mut c, "HOME", &f.ws.home);
1155 assert_eq!(rule_of(judge(&f, &c)), Rule::NoIsolatedDataDir);
1156 done(f);
1157 }
1158
1159 #[test]
1160 fn a_data_dir_the_target_cannot_deliver_does_not_count() {
1161 let f = fx();
1162 let mut c = case();
1163 c.data_dir = Some(f.ws.data_dir.clone());
1164 c.data_dir_env = None;
1165 let v = judge(&f, &c).unwrap_err();
1166 assert_eq!(v.rule, Rule::NoIsolatedDataDir);
1167 assert!(v.detail.contains("never receive"), "{}", v.detail);
1168 done(f);
1169 }
1170
1171 #[test]
1172 fn a_data_dir_inside_the_workspace_is_allowed() {
1173 let f = fx();
1174 let mut c = case();
1175 c.data_dir = Some(f.ws.data_dir.clone());
1176 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1177 judge(&f, &c).unwrap();
1178 for args in [
1180 vec![
1181 "doctor".to_string(),
1182 "--data-dir".to_string(),
1183 f.ws.data_dir.to_string_lossy().into_owned(),
1184 ],
1185 vec![format!("--data-dir={}", f.ws.data_dir.display())],
1186 ] {
1187 let mut c = case();
1188 c.data_dir_env = None;
1189 c.data_dir_arg = Some("--data-dir");
1190 c.args = args;
1191 judge(&f, &c).unwrap();
1192 }
1193 done(f);
1194 }
1195
1196 #[test]
1197 fn the_real_per_user_folder_is_refused_whichever_way_it_is_named() {
1198 let f = fx();
1199 let real = f.fake_home.join("Library/Application Support/ViewRight/db");
1200 let mut c = case();
1201 set(&mut c, "VIEWRIGHT_APP_DATA", &real);
1202 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1203 let mut c = case();
1204 c.data_dir = Some(real.clone());
1205 set(&mut c, "VIEWRIGHT_APP_DATA", &real);
1206 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1207 let mut c = case();
1208 set(&mut c, "VIEWRIGHT_APP_DATA", &f.fake_home.join("Documents"));
1209 assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1210 let mut c = case();
1211 c.data_dir_env = None;
1212 c.data_dir_arg = Some("--data-dir");
1213 c.args = vec!["--data-dir".into(), real.to_string_lossy().into_owned()];
1214 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1215 let mut c = case();
1217 c.data_dir_arg = Some("--data-dir");
1218 c.args = vec!["--data-dir".into(), real.to_string_lossy().into_owned()];
1219 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1220 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1221 let mut c = case();
1223 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1224 set(&mut c, "VIEWRIGHT_APP_DATA", &real);
1225 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1226 if let Some(home) = default_user_homes().into_iter().next() {
1228 let env = vec![(
1229 "VIEWRIGHT_APP_DATA".to_string(),
1230 home.to_string_lossy().into_owned(),
1231 )];
1232 let binary = f.root.join("engine");
1233 let v = check_engine_launch(&EngineLaunch {
1234 binary: &binary,
1235 args: &[],
1236 data_dir: Some(&home),
1237 data_dir_env: Some("VIEWRIGHT_APP_DATA"),
1238 data_dir_arg: None,
1239 forbidden_args: &[],
1240 env: &env,
1241 refusal_probe: false,
1242 })
1243 .unwrap_err();
1244 assert!(
1246 matches!(
1247 v.rule,
1248 Rule::UserFolder | Rule::ExternalVolume | Rule::CloudStorage
1249 ),
1250 "{v}"
1251 );
1252 }
1253 done(f);
1254 }
1255
1256 #[test]
1257 fn the_probe_on_unix_needs_home_redirected_into_the_workspace() {
1258 let f = fx();
1259 let mut c = case();
1260 c.probe = true;
1261 let v = judge(&f, &c).unwrap_err();
1263 assert_eq!(v.rule, Rule::ProbeUnavailable);
1264 assert!(v.detail.contains("HOME"), "{}", v.detail);
1265 set(&mut c, "HOME", &f.fake_home);
1267 assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1268 let mut c = case();
1270 c.probe = true;
1271 set(&mut c, "HOME", &f.ws.home);
1272 judge(&f, &c).unwrap();
1273 c.inherited.push((
1275 "XDG_DATA_HOME".into(),
1276 f.fake_home.join("Documents").to_string_lossy().into_owned(),
1277 ));
1278 let v = judge(&f, &c).unwrap_err();
1279 assert_eq!(v.rule, Rule::ProbeUnavailable);
1280 assert!(v.detail.contains("XDG_DATA_HOME"), "{}", v.detail);
1281 set(&mut c, "XDG_DATA_HOME", &f.ws.data_dir);
1283 judge(&f, &c).unwrap();
1284 set(&mut c, "XDG_CONFIG_HOME", &f.fake_home.join("Documents"));
1285 assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1286 done(f);
1287 }
1288
1289 #[test]
1290 fn the_probe_on_windows_is_refused_outright() {
1291 let f = fx();
1292 let mut c = case();
1293 c.probe = true;
1294 c.windows = true;
1295 set(&mut c, "HOME", &f.ws.home);
1296 set(&mut c, "USERPROFILE", &f.ws.home);
1297 set(&mut c, "APPDATA", &f.ws.data_dir);
1298 let v = judge(&f, &c).unwrap_err();
1299 assert_eq!(v.rule, Rule::ProbeUnavailable);
1300 assert!(v.detail.contains("Known Folder"), "{}", v.detail);
1301 done(f);
1302 }
1303
1304 #[cfg(windows)]
1305 #[test]
1306 fn on_windows_the_probe_is_refused_by_the_public_rule() {
1307 let f = fx();
1308 let env = vec![
1309 ("HOME".to_string(), f.ws.home.to_string_lossy().into_owned()),
1310 (
1311 "USERPROFILE".to_string(),
1312 f.ws.home.to_string_lossy().into_owned(),
1313 ),
1314 ];
1315 let binary = f.root.join("engine.exe");
1316 let v = check_engine_launch(&EngineLaunch {
1317 binary: &binary,
1318 args: &[],
1319 data_dir: None,
1320 data_dir_env: Some("VIEWRIGHT_APP_DATA"),
1321 data_dir_arg: None,
1322 forbidden_args: &[],
1323 env: &env,
1324 refusal_probe: true,
1325 })
1326 .unwrap_err();
1327 assert_eq!(v.rule, Rule::ProbeUnavailable);
1328 done(f);
1329 }
1330
1331 #[test]
1332 fn the_probe_does_not_excuse_a_bad_directory_or_a_forbidden_flag() {
1333 let f = fx();
1334 let mut c = case();
1335 c.probe = true;
1336 set(&mut c, "HOME", &f.ws.home);
1337 set(&mut c, "VIEWRIGHT_APP_DATA", &f.fake_home.join("Documents"));
1338 assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1339 let mut c = case();
1340 c.probe = true;
1341 set(&mut c, "HOME", &f.ws.home);
1342 c.args.push("--use-user-library".into());
1343 assert_eq!(rule_of(judge(&f, &c)), Rule::ForbiddenArgument);
1344 done(f);
1345 }
1346
1347 #[test]
1348 fn a_forbidden_argument_is_refused_even_with_an_isolated_data_dir() {
1349 let f = fx();
1350 let mut c = case();
1351 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1352 judge(&f, &c).unwrap();
1353 for arg in ["--use-user-library", "--use-user-library=1"] {
1354 let mut c = case();
1355 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1356 c.args.push(arg.into());
1357 let v = judge(&f, &c).unwrap_err();
1358 assert_eq!(v.rule, Rule::ForbiddenArgument, "{arg}");
1359 assert!(v.detail.contains("--use-user-library"), "{}", v.detail);
1360 }
1361 let mut c = case();
1363 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1364 c.args.push("--use-user-library-copy".into());
1365 judge(&f, &c).unwrap();
1366 let mut c = case();
1368 c.forbidden.clear();
1369 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1370 c.args.push("--use-user-library".into());
1371 judge(&f, &c).unwrap();
1372 done(f);
1373 }
1374
1375 #[test]
1376 fn an_app_launch_environment_must_carry_the_isolated_data_dir() {
1377 let f = fx();
1378 let g = guard(&f);
1379 let app = f.root.join("App.app");
1380 let env: Vec<(String, String)> = f.ws.env.clone().into_iter().collect();
1381 check_app_data_dir(&g, &app, &env).unwrap();
1382 let without: Vec<(String, String)> = env
1383 .iter()
1384 .filter(|(k, _)| k != "RIGHTKIT_QA_DATA_DIR")
1385 .cloned()
1386 .collect();
1387 let v = check_app_data_dir(&g, &app, &without).unwrap_err();
1388 assert_eq!(v.rule, Rule::NoIsolatedDataDir);
1389 assert_eq!(v.path, app);
1390 let mut elsewhere = without;
1391 elsewhere.push((
1392 "RIGHTKIT_QA_DATA_DIR".into(),
1393 f.fake_home.join("Documents").to_string_lossy().into_owned(),
1394 ));
1395 assert_eq!(
1396 check_app_data_dir(&g, &app, &elsewhere).unwrap_err().rule,
1397 Rule::UserFolder
1398 );
1399 done(f);
1400 }
1401}