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 if !has_prefix && c.first().map(String::as_str) == Some("volumes") {
320 return Some((
321 Rule::ExternalVolume,
322 "an external or network volume under /Volumes".into(),
323 ));
324 }
325 for (i, name) in c.iter().enumerate() {
326 let cloud = name.starts_with("onedrive")
327 || name.starts_with("dropbox")
328 || name.starts_with("google drive")
329 || name == "googledrive"
330 || name == "my drive"
331 || name == "icloud drive"
332 || name == "iclouddrive"
333 || name == "mobile documents"
334 || (name == "cloudstorage" && i > 0 && c[i - 1] == "library");
335 if cloud {
336 return Some((
337 Rule::CloudStorage,
338 format!("a cloud-synced folder ({name})"),
339 ));
340 }
341 }
342 if matches!(c.first().map(String::as_str), Some("users") | Some("home")) && c.len() >= 2 {
344 if let Some(hit) = under_home(&c[2..], self.app.as_deref()) {
345 if c.len() > 2 {
346 return Some(hit);
347 }
348 }
349 }
350 for home in &self.user_homes {
351 if let Ok(rel) = resolved.strip_prefix(home) {
352 if let Some(hit) = under_home(&comps(rel), self.app.as_deref()) {
353 return Some(hit);
354 }
355 }
356 }
357 None
358 }
359
360 fn judge_outside_workspace(
361 &self,
362 resolved: &Path,
363 ) -> std::result::Result<(), UserDataViolation> {
364 let violation = |rule, detail: String| UserDataViolation {
365 path: resolved.to_path_buf(),
366 rule,
367 detail,
368 };
369 if let Some((rule, detail)) = self.forbidden(resolved) {
370 return Err(violation(rule, detail));
371 }
372 if !self.temp_roots.iter().any(|t| resolved.starts_with(t)) {
373 return Err(violation(
374 Rule::OutsideTemp,
375 "not inside a system temp directory on the internal disk".into(),
376 ));
377 }
378 Ok(())
379 }
380
381 pub fn check_dir(&self, path: &Path) -> std::result::Result<PathBuf, UserDataViolation> {
383 if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) {
384 return Err(UserDataViolation {
385 path: path.to_path_buf(),
386 rule: Rule::Unresolvable,
387 detail: "the path must be absolute and contain no `..`".into(),
388 });
389 }
390 let resolved = resolve(path);
391 if let Some(root) = &self.qa_root {
392 if resolved.starts_with(root) {
393 return Ok(resolved);
394 }
395 if let Some((rule, detail)) = self.forbidden(&resolved) {
396 return Err(UserDataViolation {
397 path: resolved,
398 rule,
399 detail,
400 });
401 }
402 return Err(UserDataViolation {
403 detail: format!("not inside the QA root {}", root.display()),
404 path: resolved,
405 rule: Rule::OutsideQaRoot,
406 });
407 }
408 self.judge_outside_workspace(&resolved)?;
409 Ok(resolved)
410 }
411
412 pub fn check_env(
414 &self,
415 env: &[(String, String)],
416 ) -> std::result::Result<(), UserDataViolation> {
417 for (_, value) in env {
418 if Path::new(value).is_absolute() {
419 self.check_dir(Path::new(value))?;
420 }
421 }
422 Ok(())
423 }
424}
425
426pub fn check_engine_dirs(data_dir: Option<&Path>, env: &[(String, String)]) -> Result<()> {
433 Ok(engine_dirs_violation(
434 &Guard::for_temp_roots(None),
435 data_dir,
436 env,
437 )?)
438}
439
440fn engine_dirs_violation(
441 guard: &Guard,
442 data_dir: Option<&Path>,
443 env: &[(String, String)],
444) -> std::result::Result<(), UserDataViolation> {
445 if let Some(dir) = data_dir {
446 guard.check_dir(dir)?;
447 }
448 for (key, value) in env {
449 let k = key.to_uppercase();
450 let names_a_dir = ["DATA", "HOME", "PROFILE", "CONFIG"]
451 .iter()
452 .any(|w| k.contains(w))
453 || matches!(k.as_str(), "APPDATA" | "LOCALAPPDATA" | "TMPDIR");
454 if names_a_dir && Path::new(value).is_absolute() {
455 guard.check_dir(Path::new(value))?;
456 }
457 }
458 Ok(())
459}
460
461#[derive(Debug, Clone)]
466pub struct EngineLaunch<'a> {
467 pub binary: &'a Path,
469 pub args: &'a [String],
471 pub data_dir: Option<&'a Path>,
473 pub data_dir_env: Option<&'a str>,
475 pub data_dir_arg: Option<&'a str>,
478 pub forbidden_args: &'a [String],
480 pub env: &'a [(String, String)],
482 pub refusal_probe: bool,
484}
485
486fn env_value<'e>(env: &'e [(String, String)], key: &str, windows: bool) -> Option<&'e str> {
487 env.iter()
488 .rev()
489 .find(|(k, _)| {
490 if windows {
491 k.eq_ignore_ascii_case(key)
492 } else {
493 k == key
494 }
495 })
496 .map(|(_, v)| v.as_str())
497}
498
499fn arg_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
501 let mut found = None;
502 for (i, a) in args.iter().enumerate() {
503 if a == flag {
504 found = args.get(i + 1).map(String::as_str);
505 } else if let Some(v) = a.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
506 found = Some(v);
507 }
508 }
509 found
510}
511
512pub fn check_engine_launch(v: &EngineLaunch<'_>) -> std::result::Result<(), UserDataViolation> {
528 check_engine_launch_in(
529 &Guard::for_temp_roots(None),
530 v,
531 cfg!(windows),
532 &|k: &str| std::env::var(k).ok(),
533 )
534}
535
536fn check_engine_launch_in(
537 guard: &Guard,
538 v: &EngineLaunch<'_>,
539 windows: bool,
540 inherited: &dyn Fn(&str) -> Option<String>,
541) -> std::result::Result<(), UserDataViolation> {
542 let target = v.binary.display().to_string();
543 let refuse = |rule, detail: String| UserDataViolation {
544 path: v.binary.to_path_buf(),
545 rule,
546 detail,
547 };
548 for arg in v.args {
549 for flag in v.forbidden_args {
550 if arg == flag
551 || arg
552 .strip_prefix(flag.as_str())
553 .is_some_and(|r| r.starts_with('='))
554 {
555 return Err(refuse(
556 Rule::ForbiddenArgument,
557 format!("launch of {target} carries {flag}, which the target declares as a switch to the real library"),
558 ));
559 }
560 }
561 }
562 engine_dirs_violation(guard, v.data_dir, v.env)?;
563 let mut named = 0usize;
564 if let Some(var) = v.data_dir_env {
565 if let Some(value) = env_value(v.env, var, windows).filter(|s| !s.is_empty()) {
566 guard.check_dir(Path::new(value))?;
567 named += 1;
568 }
569 }
570 if let Some(flag) = v.data_dir_arg {
571 if let Some(value) = arg_value(v.args, flag).filter(|s| !s.is_empty()) {
572 guard.check_dir(Path::new(value))?;
573 named += 1;
574 }
575 }
576 if named > 0 {
577 return Ok(());
578 }
579 if !v.refusal_probe {
580 let why = if v.data_dir.is_some() {
581 "a data directory was passed, but the target declares no data_dir_env, so the engine would never receive it"
582 } else {
583 "no data directory was passed"
584 };
585 return Err(refuse(
586 Rule::NoIsolatedDataDir,
587 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"),
588 ));
589 }
590 if windows {
591 return Err(refuse(
592 Rule::ProbeUnavailable,
593 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"),
594 ));
595 }
596 let barrier = |detail: String| {
597 refuse(
598 Rule::ProbeUnavailable,
599 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}"),
600 )
601 };
602 let home = env_value(v.env, "HOME", false)
603 .filter(|s| !s.is_empty())
604 .ok_or_else(|| barrier("HOME is not set in the child environment".into()))?;
605 guard
606 .check_dir(Path::new(home))
607 .map_err(|e| barrier(format!("HOME: {e}")))?;
608 for key in ["XDG_DATA_HOME", "XDG_CONFIG_HOME"] {
609 let effective = env_value(v.env, key, false)
610 .map(str::to_owned)
611 .or_else(|| inherited(key))
612 .filter(|s| !s.is_empty());
613 if let Some(value) = effective {
614 guard
615 .check_dir(Path::new(&value))
616 .map_err(|e| barrier(format!("{key}: {e}")))?;
617 }
618 }
619 Ok(())
620}
621
622pub fn check_app_data_dir(
626 guard: &Guard,
627 app_binary: &Path,
628 env: &[(String, String)],
629) -> std::result::Result<(), UserDataViolation> {
630 match env_value(env, "RIGHTKIT_QA_DATA_DIR", cfg!(windows)).filter(|s| !s.is_empty()) {
631 Some(dir) => guard.check_dir(Path::new(dir)).map(|_| ()),
632 None => Err(UserDataViolation {
633 path: app_binary.to_path_buf(),
634 rule: Rule::NoIsolatedDataDir,
635 detail: format!(
636 "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",
637 app_binary.display()
638 ),
639 }),
640 }
641}
642
643pub struct ScrubDeclaration<'a> {
645 pub declared_scrubbed: bool,
647 pub verify: &'a dyn Fn(&Path) -> bool,
649}
650
651pub fn seed_from(
658 ws: &QaWorkspace,
659 app: &str,
660 source: &Path,
661 dest_rel: &Path,
662 transform: impl FnOnce(&Path, &Path) -> Result<()>,
663 scrub: Option<&ScrubDeclaration<'_>>,
664) -> Result<PathBuf> {
665 let guard = Guard::for_workspace(ws)?.with_app(app);
666 seed_from_guarded(&guard, ws, source, dest_rel, transform, scrub)
667}
668
669pub fn seed_from_guarded(
671 guard: &Guard,
672 ws: &QaWorkspace,
673 source: &Path,
674 dest_rel: &Path,
675 transform: impl FnOnce(&Path, &Path) -> Result<()>,
676 scrub: Option<&ScrubDeclaration<'_>>,
677) -> Result<PathBuf> {
678 if dest_rel.is_absolute() || dest_rel.components().any(|c| c == Component::ParentDir) {
679 return err("seed destination must be relative to the QA data directory");
680 }
681 let dest = guard.check_dir(&ws.data_dir.join(dest_rel))?;
682 let resolved_source = resolve(source);
683 let real = guard.forbidden(&resolved_source);
684 if let Some((_, detail)) = &real {
685 match scrub {
686 None => {
687 return Err(UserDataViolation {
688 path: resolved_source,
689 rule: Rule::UnscrubbedRealData,
690 detail: format!("{detail}; use a generated fixture, or declare the copy scrubbed and supply a verification callback"),
691 }
692 .into())
693 }
694 Some(s) if !s.declared_scrubbed => {
695 return Err(UserDataViolation {
696 path: resolved_source,
697 rule: Rule::UnscrubbedRealData,
698 detail: format!("{detail}; the scenario did not declare the copy scrubbed"),
699 }
700 .into())
701 }
702 Some(_) => {}
703 }
704 }
705 transform(source, &dest)?;
706 if let Some(s) = scrub {
707 if (real.is_some() || s.declared_scrubbed) && !(s.verify)(&dest) {
708 let _ = if dest.is_dir() {
710 fs::remove_dir_all(&dest)
711 } else {
712 fs::remove_file(&dest)
713 };
714 return Err(UserDataViolation {
715 path: dest,
716 rule: Rule::UnscrubbedRealData,
717 detail: "the verification callback rejected the seeded copy; it was deleted and nothing launches".into(),
718 }
719 .into());
720 }
721 }
722 Ok(dest)
723}
724
725const SYSTEM_DIALOG_OWNERS: &[&str] = &[
727 "usernotificationcenter",
728 "coreservicesuiagent",
729 "universalaccessauthwarn",
730 "securityagent",
731 "tccd",
732];
733
734pub fn is_system_dialog_owner(name: &str) -> bool {
736 let n = name.to_lowercase();
737 SYSTEM_DIALOG_OWNERS.iter().any(|o| n == *o)
738}
739
740#[derive(Debug, Clone, PartialEq, Eq)]
742pub enum DialogCheck {
743 Clear,
745 Dialog(String),
747 Unsupported(&'static str),
749}
750
751pub fn permission_dialog_in(frontmost_states: &[String]) -> Option<String> {
755 frontmost_states
756 .iter()
757 .find(|n| is_system_dialog_owner(n))
758 .cloned()
759}
760
761pub fn live_permission_dialog_check(_app_pid: u32) -> DialogCheck {
767 DialogCheck::Unsupported(
768 "no window-by-owner enumeration in rightkit-qa-driver; permission dialogs are not observable",
769 )
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775 use crate::util::new_id;
776
777 struct Fx {
778 root: PathBuf,
779 fake_home: PathBuf,
780 ws: QaWorkspace,
781 }
782
783 fn fx() -> Fx {
784 let root = storage::app_temp_dir().join(format!("rkqa-ud-{}", new_id()));
785 let fake_home = root.join("fakehome");
786 for d in [
787 "Documents",
788 "Desktop",
789 "Downloads",
790 "Library/Mobile Documents/com~apple~CloudDocs",
791 "Library/CloudStorage/OneDrive-Acme",
792 "Library/Application Support/ViewRight/db",
793 "AppData/Roaming/ViewRight",
794 "Dropbox",
795 "Elsewhere",
796 ] {
797 fs::create_dir_all(fake_home.join(d)).unwrap();
798 }
799 let ws = crate::workspace::create(&root.join("managed"), None, "viewright").unwrap();
800 Fx {
801 root,
802 fake_home,
803 ws,
804 }
805 }
806
807 fn done(f: Fx) {
808 let _ = crate::workspace::dispose(&f.ws, false, 1, 0);
809 let _ = fs::remove_dir_all(&f.root);
810 }
811
812 fn guard(f: &Fx) -> Guard {
813 Guard::for_workspace(&f.ws)
814 .unwrap()
815 .with_user_homes(vec![f.fake_home.clone()])
816 }
817
818 #[test]
819 fn every_forbidden_location_is_refused_with_its_rule() {
820 let f = fx();
821 let g = guard(&f);
822 let h = &f.fake_home;
823 let cases: Vec<(PathBuf, Rule)> = vec![
824 (h.join("Documents"), Rule::UserFolder),
825 (h.join("Documents/sub/dir"), Rule::UserFolder),
826 (h.join("Desktop"), Rule::UserFolder),
827 (h.join("Downloads"), Rule::UserFolder),
828 (h.clone(), Rule::UserFolder),
829 (
830 h.join("Library/Mobile Documents/com~apple~CloudDocs"),
831 Rule::CloudStorage,
832 ),
833 (
834 h.join("Library/CloudStorage/OneDrive-Acme"),
835 Rule::CloudStorage,
836 ),
837 (h.join("Dropbox"), Rule::CloudStorage),
838 (
839 h.join("Library/Application Support/ViewRight/db"),
840 Rule::RealAppData,
841 ),
842 (h.join("AppData/Roaming/ViewRight"), Rule::RealAppData),
843 ];
844 for (path, rule) in cases {
845 let v = g.check_dir(&path).unwrap_err();
846 assert_eq!(v.rule, rule, "{}", path.display());
847 assert!(
850 v.to_string().contains(&path.display().to_string())
851 || v.path.starts_with(&f.fake_home)
852 || v.path.starts_with(resolve(&f.fake_home)),
853 "{v}"
854 );
855 }
856 let v = g.check_dir(&h.join("Elsewhere")).unwrap_err();
858 assert_eq!(v.rule, Rule::OutsideQaRoot);
859 done(f);
860 }
861
862 #[cfg(unix)]
863 #[test]
864 fn external_volumes_and_other_accounts_are_refused() {
865 let f = fx();
866 let g = guard(&f);
867 let v = g
868 .check_dir(Path::new("/Volumes/Backup/qa-home"))
869 .unwrap_err();
870 assert_eq!(v.rule, Rule::ExternalVolume);
871 let v = g
872 .check_dir(Path::new("/Users/someone-else/Documents/x"))
873 .unwrap_err();
874 assert_eq!(v.rule, Rule::UserFolder);
875 let v = g
876 .check_dir(Path::new(
877 "/Users/someone-else/Library/Application Support/ViewRight",
878 ))
879 .unwrap_err();
880 assert_eq!(v.rule, Rule::RealAppData);
881 let v = g.check_dir(Path::new("relative/path")).unwrap_err();
882 assert_eq!(v.rule, Rule::Unresolvable);
883 done(f);
884 }
885
886 #[test]
887 fn a_created_qa_root_and_its_directories_are_accepted() {
888 let f = fx();
889 let g = guard(&f);
890 for dir in [
891 f.ws.home.clone(),
892 f.ws.data_dir.clone(),
893 f.ws.home.join("tmp"),
894 f.ws.home.join("not/created/yet"),
895 ] {
896 g.check_dir(&dir).unwrap();
897 }
898 let env: Vec<(String, String)> = f.ws.env.clone().into_iter().collect();
899 g.check_env(&env).unwrap();
900 done(f);
901 }
902
903 #[test]
904 fn a_directory_that_is_not_a_qa_root_is_not_accepted_as_one() {
905 let f = fx();
906 let mut ws = f.ws.clone();
907 ws.home = f.root.join("fakehome/Elsewhere");
908 assert_eq!(Guard::for_workspace(&ws).unwrap_err().rule, Rule::NotQaRoot);
909 ws.home = f.root.clone();
910 assert_eq!(Guard::for_workspace(&ws).unwrap_err().rule, Rule::NotQaRoot);
911 done(f);
912 }
913
914 #[cfg(unix)]
915 #[test]
916 fn a_symlink_out_of_the_qa_root_is_refused() {
917 let f = fx();
918 let g = guard(&f);
919 let into_documents = f.ws.home.join("looks-internal");
920 std::os::unix::fs::symlink(f.fake_home.join("Documents"), &into_documents).unwrap();
921 assert_eq!(
922 g.check_dir(&into_documents).unwrap_err().rule,
923 Rule::UserFolder
924 );
925 assert_eq!(
926 g.check_dir(&into_documents.join("deeper/file"))
927 .unwrap_err()
928 .rule,
929 Rule::UserFolder
930 );
931 let plain = f.ws.home.join("to-plain");
932 std::os::unix::fs::symlink(f.fake_home.join("Elsewhere"), &plain).unwrap();
933 assert_eq!(g.check_dir(&plain).unwrap_err().rule, Rule::OutsideQaRoot);
934 done(f);
935 }
936
937 #[test]
938 fn an_environment_path_outside_the_root_is_refused() {
939 let f = fx();
940 let g = guard(&f);
941 let env = vec![(
942 "VIEWRIGHT_DATA_DIR".to_string(),
943 f.fake_home
944 .join("Library/Application Support/ViewRight")
945 .to_string_lossy()
946 .into_owned(),
947 )];
948 assert_eq!(g.check_env(&env).unwrap_err().rule, Rule::RealAppData);
949 done(f);
950 }
951
952 #[test]
953 fn engine_data_dirs_must_be_in_temp_and_clear_of_user_folders() {
954 let f = fx();
955 check_engine_dirs(Some(&f.ws.data_dir), &[]).unwrap();
956 let g = Guard::for_temp_roots(Some("viewright")).with_user_homes(vec![f.fake_home.clone()]);
957 assert_eq!(
958 g.check_dir(&f.fake_home.join("Documents"))
959 .unwrap_err()
960 .rule,
961 Rule::UserFolder
962 );
963 done(f);
964 }
965
966 #[test]
967 fn seed_refuses_real_app_data_without_a_verified_scrub() {
968 let f = fx();
969 let g = guard(&f);
970 let real = f.fake_home.join("Library/Application Support/ViewRight/db");
971 fs::write(real.join("app.sqlite"), "user rows").unwrap();
972 let copy = |from: &Path, to: &Path| -> Result<()> {
973 fs::create_dir_all(to)?;
974 fs::copy(from.join("app.sqlite"), to.join("app.sqlite"))?;
975 Ok(())
976 };
977 let rel = Path::new("seed");
978 let e = seed_from_guarded(&g, &f.ws, &real, rel, copy, None).unwrap_err();
980 assert!(e.to_string().contains("unscrubbed-real-data"), "{e}");
981 assert!(!f.ws.data_dir.join("seed").exists());
982 let no = ScrubDeclaration {
984 declared_scrubbed: false,
985 verify: &|_| true,
986 };
987 assert!(seed_from_guarded(&g, &f.ws, &real, rel, copy, Some(&no)).is_err());
988 let bad = ScrubDeclaration {
990 declared_scrubbed: true,
991 verify: &|_| false,
992 };
993 assert!(seed_from_guarded(&g, &f.ws, &real, rel, copy, Some(&bad)).is_err());
994 assert!(!f.ws.data_dir.join("seed").exists());
995 let good = ScrubDeclaration {
997 declared_scrubbed: true,
998 verify: &|dest| fs::write(dest.join("app.sqlite"), "").is_ok(),
999 };
1000 let out = seed_from_guarded(&g, &f.ws, &real, rel, copy, Some(&good)).unwrap();
1001 assert_eq!(fs::read(out.join("app.sqlite")).unwrap(), b"");
1002 let generated = f.ws.scenario_dir("generated").unwrap();
1004 fs::write(generated.join("fixture.txt"), "fixture").unwrap();
1005 let ok = seed_from_guarded(
1006 &g,
1007 &f.ws,
1008 &generated,
1009 Path::new("seed2"),
1010 |from, to| {
1011 fs::create_dir_all(to)?;
1012 fs::copy(from.join("fixture.txt"), to.join("fixture.txt"))?;
1013 Ok(())
1014 },
1015 None,
1016 )
1017 .unwrap();
1018 assert!(ok.join("fixture.txt").exists());
1019 assert!(seed_from_guarded(
1021 &g,
1022 &f.ws,
1023 &generated,
1024 Path::new("../x"),
1025 |_, _| Ok(()),
1026 None
1027 )
1028 .is_err());
1029 done(f);
1030 }
1031
1032 #[test]
1033 fn dialog_owner_matching() {
1034 assert!(is_system_dialog_owner("UserNotificationCenter"));
1035 assert!(is_system_dialog_owner("CoreServicesUIAgent"));
1036 assert!(!is_system_dialog_owner("Finder"));
1037 assert_eq!(
1038 permission_dialog_in(&["Finder".into(), "CoreServicesUIAgent".into()]).as_deref(),
1039 Some("CoreServicesUIAgent")
1040 );
1041 assert_eq!(permission_dialog_in(&["Finder".into()]), None);
1042 assert!(matches!(
1043 live_permission_dialog_check(1),
1044 DialogCheck::Unsupported(_)
1045 ));
1046 }
1047 struct Case {
1052 args: Vec<String>,
1053 env: Vec<(String, String)>,
1054 data_dir: Option<PathBuf>,
1055 data_dir_env: Option<&'static str>,
1056 data_dir_arg: Option<&'static str>,
1057 forbidden: Vec<String>,
1058 probe: bool,
1059 windows: bool,
1060 inherited: Vec<(String, String)>,
1061 }
1062
1063 fn case() -> Case {
1064 Case {
1065 args: vec!["doctor".into()],
1066 env: Vec::new(),
1067 data_dir: None,
1068 data_dir_env: Some("VIEWRIGHT_APP_DATA"),
1069 data_dir_arg: None,
1070 forbidden: vec!["--use-user-library".into()],
1071 probe: false,
1072 windows: false,
1073 inherited: Vec::new(),
1074 }
1075 }
1076
1077 fn set(c: &mut Case, key: &str, dir: &Path) {
1078 c.env.push((key.into(), dir.to_string_lossy().into_owned()));
1079 }
1080
1081 fn judge(f: &Fx, c: &Case) -> std::result::Result<(), UserDataViolation> {
1082 let guard =
1083 Guard::for_temp_roots(Some("viewright")).with_user_homes(vec![f.fake_home.clone()]);
1084 let binary = f.root.join("engine");
1085 check_engine_launch_in(
1086 &guard,
1087 &EngineLaunch {
1088 binary: &binary,
1089 args: &c.args,
1090 data_dir: c.data_dir.as_deref(),
1091 data_dir_env: c.data_dir_env,
1092 data_dir_arg: c.data_dir_arg,
1093 forbidden_args: &c.forbidden,
1094 env: &c.env,
1095 refusal_probe: c.probe,
1096 },
1097 c.windows,
1098 &|k: &str| {
1099 c.inherited
1100 .iter()
1101 .find(|(n, _)| n == k)
1102 .map(|(_, v)| v.clone())
1103 },
1104 )
1105 }
1106
1107 fn rule_of(r: std::result::Result<(), UserDataViolation>) -> Rule {
1108 r.unwrap_err().rule
1109 }
1110
1111 #[test]
1112 fn a_launch_with_no_data_dir_is_refused_and_says_which_target() {
1113 let f = fx();
1114 let c = case();
1115 let v = judge(&f, &c).unwrap_err();
1116 assert_eq!(v.rule, Rule::NoIsolatedDataDir);
1117 assert_eq!(v.path, f.root.join("engine"));
1118 let text = v.to_string();
1119 assert!(text.contains("engine"), "{text}");
1120 assert!(text.contains("isolated data directory"), "{text}");
1121 assert!(text.contains("nothing was launched"), "{text}");
1122 let mut c = case();
1124 c.env.push(("VIEWRIGHT_APP_DATA".into(), String::new()));
1125 assert_eq!(rule_of(judge(&f, &c)), Rule::NoIsolatedDataDir);
1126 let mut c = case();
1128 set(&mut c, "HOME", &f.ws.home);
1129 assert_eq!(rule_of(judge(&f, &c)), Rule::NoIsolatedDataDir);
1130 done(f);
1131 }
1132
1133 #[test]
1134 fn a_data_dir_the_target_cannot_deliver_does_not_count() {
1135 let f = fx();
1136 let mut c = case();
1137 c.data_dir = Some(f.ws.data_dir.clone());
1138 c.data_dir_env = None;
1139 let v = judge(&f, &c).unwrap_err();
1140 assert_eq!(v.rule, Rule::NoIsolatedDataDir);
1141 assert!(v.detail.contains("never receive"), "{}", v.detail);
1142 done(f);
1143 }
1144
1145 #[test]
1146 fn a_data_dir_inside_the_workspace_is_allowed() {
1147 let f = fx();
1148 let mut c = case();
1149 c.data_dir = Some(f.ws.data_dir.clone());
1150 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1151 judge(&f, &c).unwrap();
1152 for args in [
1154 vec![
1155 "doctor".to_string(),
1156 "--data-dir".to_string(),
1157 f.ws.data_dir.to_string_lossy().into_owned(),
1158 ],
1159 vec![format!("--data-dir={}", f.ws.data_dir.display())],
1160 ] {
1161 let mut c = case();
1162 c.data_dir_env = None;
1163 c.data_dir_arg = Some("--data-dir");
1164 c.args = args;
1165 judge(&f, &c).unwrap();
1166 }
1167 done(f);
1168 }
1169
1170 #[test]
1171 fn the_real_per_user_folder_is_refused_whichever_way_it_is_named() {
1172 let f = fx();
1173 let real = f.fake_home.join("Library/Application Support/ViewRight/db");
1174 let mut c = case();
1175 set(&mut c, "VIEWRIGHT_APP_DATA", &real);
1176 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1177 let mut c = case();
1178 c.data_dir = Some(real.clone());
1179 set(&mut c, "VIEWRIGHT_APP_DATA", &real);
1180 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1181 let mut c = case();
1182 set(&mut c, "VIEWRIGHT_APP_DATA", &f.fake_home.join("Documents"));
1183 assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1184 let mut c = case();
1185 c.data_dir_env = None;
1186 c.data_dir_arg = Some("--data-dir");
1187 c.args = vec!["--data-dir".into(), real.to_string_lossy().into_owned()];
1188 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1189 let mut c = case();
1191 c.data_dir_arg = Some("--data-dir");
1192 c.args = vec!["--data-dir".into(), real.to_string_lossy().into_owned()];
1193 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1194 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1195 let mut c = case();
1197 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1198 set(&mut c, "VIEWRIGHT_APP_DATA", &real);
1199 assert_eq!(rule_of(judge(&f, &c)), Rule::RealAppData);
1200 if let Some(home) = default_user_homes().into_iter().next() {
1202 let env = vec![(
1203 "VIEWRIGHT_APP_DATA".to_string(),
1204 home.to_string_lossy().into_owned(),
1205 )];
1206 let binary = f.root.join("engine");
1207 let v = check_engine_launch(&EngineLaunch {
1208 binary: &binary,
1209 args: &[],
1210 data_dir: Some(&home),
1211 data_dir_env: Some("VIEWRIGHT_APP_DATA"),
1212 data_dir_arg: None,
1213 forbidden_args: &[],
1214 env: &env,
1215 refusal_probe: false,
1216 })
1217 .unwrap_err();
1218 assert!(
1220 matches!(
1221 v.rule,
1222 Rule::UserFolder | Rule::ExternalVolume | Rule::CloudStorage
1223 ),
1224 "{v}"
1225 );
1226 }
1227 done(f);
1228 }
1229
1230 #[test]
1231 fn the_probe_on_unix_needs_home_redirected_into_the_workspace() {
1232 let f = fx();
1233 let mut c = case();
1234 c.probe = true;
1235 let v = judge(&f, &c).unwrap_err();
1237 assert_eq!(v.rule, Rule::ProbeUnavailable);
1238 assert!(v.detail.contains("HOME"), "{}", v.detail);
1239 set(&mut c, "HOME", &f.fake_home);
1241 assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1242 let mut c = case();
1244 c.probe = true;
1245 set(&mut c, "HOME", &f.ws.home);
1246 judge(&f, &c).unwrap();
1247 c.inherited.push((
1249 "XDG_DATA_HOME".into(),
1250 f.fake_home.join("Documents").to_string_lossy().into_owned(),
1251 ));
1252 let v = judge(&f, &c).unwrap_err();
1253 assert_eq!(v.rule, Rule::ProbeUnavailable);
1254 assert!(v.detail.contains("XDG_DATA_HOME"), "{}", v.detail);
1255 set(&mut c, "XDG_DATA_HOME", &f.ws.data_dir);
1257 judge(&f, &c).unwrap();
1258 set(&mut c, "XDG_CONFIG_HOME", &f.fake_home.join("Documents"));
1259 assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1260 done(f);
1261 }
1262
1263 #[test]
1264 fn the_probe_on_windows_is_refused_outright() {
1265 let f = fx();
1266 let mut c = case();
1267 c.probe = true;
1268 c.windows = true;
1269 set(&mut c, "HOME", &f.ws.home);
1270 set(&mut c, "USERPROFILE", &f.ws.home);
1271 set(&mut c, "APPDATA", &f.ws.data_dir);
1272 let v = judge(&f, &c).unwrap_err();
1273 assert_eq!(v.rule, Rule::ProbeUnavailable);
1274 assert!(v.detail.contains("Known Folder"), "{}", v.detail);
1275 done(f);
1276 }
1277
1278 #[cfg(windows)]
1279 #[test]
1280 fn on_windows_the_probe_is_refused_by_the_public_rule() {
1281 let f = fx();
1282 let env = vec![
1283 ("HOME".to_string(), f.ws.home.to_string_lossy().into_owned()),
1284 (
1285 "USERPROFILE".to_string(),
1286 f.ws.home.to_string_lossy().into_owned(),
1287 ),
1288 ];
1289 let binary = f.root.join("engine.exe");
1290 let v = check_engine_launch(&EngineLaunch {
1291 binary: &binary,
1292 args: &[],
1293 data_dir: None,
1294 data_dir_env: Some("VIEWRIGHT_APP_DATA"),
1295 data_dir_arg: None,
1296 forbidden_args: &[],
1297 env: &env,
1298 refusal_probe: true,
1299 })
1300 .unwrap_err();
1301 assert_eq!(v.rule, Rule::ProbeUnavailable);
1302 done(f);
1303 }
1304
1305 #[test]
1306 fn the_probe_does_not_excuse_a_bad_directory_or_a_forbidden_flag() {
1307 let f = fx();
1308 let mut c = case();
1309 c.probe = true;
1310 set(&mut c, "HOME", &f.ws.home);
1311 set(&mut c, "VIEWRIGHT_APP_DATA", &f.fake_home.join("Documents"));
1312 assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1313 let mut c = case();
1314 c.probe = true;
1315 set(&mut c, "HOME", &f.ws.home);
1316 c.args.push("--use-user-library".into());
1317 assert_eq!(rule_of(judge(&f, &c)), Rule::ForbiddenArgument);
1318 done(f);
1319 }
1320
1321 #[test]
1322 fn a_forbidden_argument_is_refused_even_with_an_isolated_data_dir() {
1323 let f = fx();
1324 let mut c = case();
1325 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1326 judge(&f, &c).unwrap();
1327 for arg in ["--use-user-library", "--use-user-library=1"] {
1328 let mut c = case();
1329 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1330 c.args.push(arg.into());
1331 let v = judge(&f, &c).unwrap_err();
1332 assert_eq!(v.rule, Rule::ForbiddenArgument, "{arg}");
1333 assert!(v.detail.contains("--use-user-library"), "{}", v.detail);
1334 }
1335 let mut c = case();
1337 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1338 c.args.push("--use-user-library-copy".into());
1339 judge(&f, &c).unwrap();
1340 let mut c = case();
1342 c.forbidden.clear();
1343 set(&mut c, "VIEWRIGHT_APP_DATA", &f.ws.data_dir);
1344 c.args.push("--use-user-library".into());
1345 judge(&f, &c).unwrap();
1346 done(f);
1347 }
1348
1349 #[test]
1350 fn an_app_launch_environment_must_carry_the_isolated_data_dir() {
1351 let f = fx();
1352 let g = guard(&f);
1353 let app = f.root.join("App.app");
1354 let env: Vec<(String, String)> = f.ws.env.clone().into_iter().collect();
1355 check_app_data_dir(&g, &app, &env).unwrap();
1356 let without: Vec<(String, String)> = env
1357 .iter()
1358 .filter(|(k, _)| k != "RIGHTKIT_QA_DATA_DIR")
1359 .cloned()
1360 .collect();
1361 let v = check_app_data_dir(&g, &app, &without).unwrap_err();
1362 assert_eq!(v.rule, Rule::NoIsolatedDataDir);
1363 assert_eq!(v.path, app);
1364 let mut elsewhere = without;
1365 elsewhere.push((
1366 "RIGHTKIT_QA_DATA_DIR".into(),
1367 f.fake_home.join("Documents").to_string_lossy().into_owned(),
1368 ));
1369 assert_eq!(
1370 check_app_data_dir(&g, &app, &elsewhere).unwrap_err().rule,
1371 Rule::UserFolder
1372 );
1373 done(f);
1374 }
1375}