Skip to main content

rightkit_qa/
userdata.rs

1//! QA runs never touch real user data.
2//!
3//! A test run may not start an app on the user's real data or on a location that can raise a
4//! system dialog (macOS file-access prompts for Documents, Desktop, Downloads, iCloud Drive and
5//! removable or network volumes; the same cloud-sync folders on Windows). [`Guard`] is evaluated
6//! by `control::launch` (and by the engine launch paths) before anything is started, with no
7//! opt-out for apps: a violation is a typed [`UserDataViolation`] naming the path and the rule,
8//! and nothing is launched. An engine launch must also name an isolated data directory
9//! ([`check_engine_launch`]); the one exception is a probe of the engine's own refusal, available
10//! on Unix only and only with HOME redirected.
11//!
12//! The rule for an app launched with a workspace:
13//! * the workspace home must be a QA root this runner created (it sits directly under a
14//!   `rightkit-qa` directory inside a system temp root on the internal disk and carries the
15//!   `.rightkit-app.json` binding), and must itself break none of the forbidden-location rules;
16//! * every directory passed to the app (the launch working directory and each absolute path in
17//!   the environment) must resolve, symlinks followed, inside that home.
18//!
19//! Forbidden anywhere: the user's Documents, Desktop, Downloads, Pictures, Music and Movies;
20//! iCloud Drive (`~/Library/Mobile Documents`, `~/Library/CloudStorage`), OneDrive, Dropbox and
21//! Google Drive folders; the user's real application-support / AppData / `.config` directory for
22//! the app under test; any `/Volumes/*` volume; any Windows UNC (network) path.
23//!
24//! Scenarios that need existing state build it with generated fixtures (the default) or with
25//! [`seed_from`], which refuses to read the real per-user data of the app under test unless the
26//! scenario declares the copy scrubbed and a verification callback returns true before launch.
27use 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/// Why a path was refused.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Rule {
37    /// The user's home, Documents, Desktop, Downloads, Pictures, Music or Movies.
38    UserFolder,
39    /// iCloud Drive, OneDrive, Dropbox, Google Drive or another cloud-synced folder.
40    CloudStorage,
41    /// An external or network volume (`/Volumes/*`, a UNC path).
42    ExternalVolume,
43    /// The user's real application-support / AppData / config directory of the app under test.
44    RealAppData,
45    /// Not inside the QA root the runner created for this launch.
46    OutsideQaRoot,
47    /// Not inside a system temp root on the internal disk.
48    OutsideTemp,
49    /// The directory is not a QA home created by this runner (no binding file).
50    NotQaRoot,
51    /// The path cannot be judged (relative, or containing `..`).
52    Unresolvable,
53    /// A seed copy reads real user data without a verified scrub.
54    UnscrubbedRealData,
55    /// The launch names no isolated data directory, so the app or engine would resolve its real
56    /// per-user folder by its own default.
57    NoIsolatedDataDir,
58    /// A launch argument is on the target's deny list (a switch that selects the real library).
59    ForbiddenArgument,
60    /// The refusal-probe opt-out is unavailable: no second barrier stands between the child and
61    /// the real per-user folder (Windows always; elsewhere HOME / XDG homes not redirected).
62    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/// A launch refused before anything started. `From<UserDataViolation> for Error` keeps `?` working
85/// in code that returns [`crate::Result`]; match on `rule` where the reason matters.
86#[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
121/// Resolve symlinks in the longest existing prefix and append the rest, so a path that does not
122/// exist yet is still judged by where its existing parents really are.
123pub 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
171/// Rules applied to the components that follow a user's home directory.
172fn 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/// What decides whether a launch may proceed. Build one with [`Guard::for_workspace`] (apps) or
221/// [`Guard::for_temp_roots`] (engines without a workspace).
222#[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    /// Guard for an engine or any launch without a workspace: paths must sit in a system temp
255    /// root and break no forbidden-location rule.
256    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    /// Guard for an app launched in `ws`. Fails when the home is not a QA root this runner created
266    /// or is itself in a forbidden location.
267    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    /// Replace the user homes the guard protects (default: `HOME` and `USERPROFILE` of this
300    /// process). Tests point it at a fake home.
301    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    /// Name the app under test; its real data directories become forbidden.
307    pub fn with_app(mut self, app: &str) -> Guard {
308        self.app = Some(app.to_owned());
309        self
310    }
311
312    /// First forbidden-location rule `resolved` breaks, ignoring any QA root.
313    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        // A temp root or user home may itself sit on a /Volumes disk (the Mac build home is /Volumes/D);
320        // paths under them are judged by the home, temp and QA-root rules instead (as in @rightkit/qa).
321        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        // `/Users/<name>/...` and `C:\Users\<name>\...` for any account, not only this process's.
350        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    /// Judge one directory the runner would hand to the app. Returns its resolved path.
389    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    /// Judge every absolute-path value of a launch environment.
420    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
433/// Engine launches have no workspace: judge the data directory and the environment values that
434/// name data, home, profile or config directories.
435///
436/// This judges only what it is given. A launch that names no directory passes it, so engine
437/// launches go through [`check_engine_launch`], which also refuses a launch with no isolated data
438/// directory.
439pub 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/// Everything the guard judges about one engine, sidecar or app-engine launch, before the process
469/// starts. `env` is the environment the launcher will set on the child (target values, the data
470/// directory variable, then the caller's values, in that order: the last value of a key wins, as
471/// with `Command::env`).
472#[derive(Debug, Clone)]
473pub struct EngineLaunch<'a> {
474    /// The program that would start; named in every refusal.
475    pub binary: &'a Path,
476    /// Arguments handed to the program.
477    pub args: &'a [String],
478    /// The directory the caller passed as the launch's data directory.
479    pub data_dir: Option<&'a Path>,
480    /// Environment variable through which the target receives its data directory.
481    pub data_dir_env: Option<&'a str>,
482    /// Flag through which the target takes its data directory in `args` (`--data-dir <dir>` or
483    /// `--data-dir=<dir>`).
484    pub data_dir_arg: Option<&'a str>,
485    /// Flags the target declares as "use the real library"; a launch carrying one is refused.
486    pub forbidden_args: &'a [String],
487    /// The environment the child will get from the launcher.
488    pub env: &'a [(String, String)],
489    /// The caller opted out of the isolated-directory rule to probe the engine's own refusal.
490    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
506/// Value of the last `flag <value>` / `flag=<value>` in `args`.
507fn 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
519/// Judge one engine launch. A launch is refused, before anything starts, when:
520/// 1. an argument equals (or is `flag=...` of) a flag in `forbidden_args`;
521/// 2. the data directory, or an absolute environment value naming data, home, profile or config,
522///    breaks [`check_engine_dirs`]'s rules;
523/// 3. it names no isolated data directory: neither the `data_dir_env` variable nor the
524///    `data_dir_arg` flag carries a non-empty value that [`Guard::check_dir`] accepts. A
525///    `data_dir` the target cannot deliver (no `data_dir_env`) does not count, because the engine
526///    would never see it and would resolve its real per-user folder by its own default.
527///
528/// With `refusal_probe` the third rule is replaced by a second barrier, available only on Unix:
529/// the child environment must redirect `HOME` into a directory that passes the guard, and
530/// `XDG_DATA_HOME` / `XDG_CONFIG_HOME` (set by the launcher or inherited from this process) must
531/// do the same where set. On Windows the probe is refused outright: the engine finds its folder
532/// through the Known Folder API, which ignores the environment, so no environment redirect is a
533/// barrier there.
534pub 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
629/// App launches get their data directory from the workspace: the launch environment must carry
630/// `RIGHTKIT_QA_DATA_DIR`, pointing inside the QA root `guard` was built for. A workspace whose
631/// environment lost it is refused rather than launched on the app's own default folder.
632pub 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
650/// How a seed copy proves it holds no user data.
651pub struct ScrubDeclaration<'a> {
652    /// The scenario states that its transform removes all user data.
653    pub declared_scrubbed: bool,
654    /// Called on the seeded copy before launch; must return true (for example "zero user rows").
655    pub verify: &'a dyn Fn(&Path) -> bool,
656}
657
658/// Seed `<data_dir>/<dest_rel>` from `source` through the caller's `transform(source, dest)`.
659///
660/// Generated fixtures are the default and need no scrub. A `source` that is the real per-user data
661/// of `app` (or in any forbidden location) is refused unless `scrub` declares the copy scrubbed and
662/// its callback returns true on the result; when it returns false the copy is deleted and the
663/// seed fails, so nothing launches on it.
664pub 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
676/// [`seed_from`] with an explicit guard (tests use a fake user home).
677pub 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            // The copy lives inside the QA data directory the guard just verified.
716            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
732/// Names of the macOS agents that show system permission prompts.
733const SYSTEM_DIALOG_OWNERS: &[&str] = &[
734    "usernotificationcenter",
735    "coreservicesuiagent",
736    "universalaccessauthwarn",
737    "securityagent",
738    "tccd",
739];
740
741/// True when `name` is a macOS process that owns permission prompts.
742pub 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/// Outcome of a permission-dialog check.
748#[derive(Debug, Clone, PartialEq, Eq)]
749pub enum DialogCheck {
750    /// Evidence shows no dialog.
751    Clear,
752    /// A dialog owner was seen.
753    Dialog(String),
754    /// This build cannot observe system dialogs; the string says why. Not evidence of absence.
755    Unsupported(&'static str),
756}
757
758/// Positive evidence only: the first sampled foreground name that belongs to a system dialog
759/// agent, from `StopReport::frontmost_states`. An empty answer proves nothing, because these
760/// agents rarely become frontmost.
761pub 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
768/// TODO: observe live TCC prompts. `rightkit_automation::macos::background::windows(pid)` lists windows of one pid
769/// only; there is no enumeration of windows by owner name (`UserNotificationCenter`,
770/// `CoreServicesUIAgent`), so no reliable detector exists in this crate yet. This hook reports
771/// [`DialogCheck::Unsupported`] instead of guessing. Replace the body once the automation layer
772/// can list windows by owner without clicking them.
773pub 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            // The refused path is reported in resolved form; on Windows that is the canonical
855            // spelling of the home, which the fixture's own spelling does not start.
856            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        // A directory in the fake home that no rule names is still outside the QA root.
864        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        // The Mac build home is /Volumes/D: its temp root must not trip the external-volume rule.
873        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        // No declaration.
1005        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        // Declared false.
1009        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        // Declared, but verification fails: copy deleted.
1015        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        // Declared and verified.
1022        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        // A generated fixture needs no scrub.
1029        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        // Destination escapes are refused.
1046        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    // ---- engine launches must name an isolated data directory ----
1074
1075    /// One engine launch as the tests describe it. `judge` runs the real rule with a fake user
1076    /// home, so the cases behave the same on every host.
1077    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        // An empty value reads as absent.
1149        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        // Redirecting HOME is not naming a data directory.
1153        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        // The same through the target's data-dir flag, in both spellings.
1179        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        // A good variable does not excuse a bad flag.
1216        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        // A later caller value replaces the target's, and is judged.
1222        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        // The public rule, with this process's own home, refuses the home itself.
1227        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            // A home on an external volume or a cloud folder is refused under that rule instead.
1245            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        // No HOME in the child environment.
1262        let v = judge(&f, &c).unwrap_err();
1263        assert_eq!(v.rule, Rule::ProbeUnavailable);
1264        assert!(v.detail.contains("HOME"), "{}", v.detail);
1265        // HOME at the real home: the directory rules refuse it before the probe is judged.
1266        set(&mut c, "HOME", &f.fake_home);
1267        assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1268        // HOME in the workspace.
1269        let mut c = case();
1270        c.probe = true;
1271        set(&mut c, "HOME", &f.ws.home);
1272        judge(&f, &c).unwrap();
1273        // An inherited XDG home outside the workspace defeats the redirect ...
1274        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        // ... until the launcher overrides it.
1282        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        // Only a whole flag matches; a longer flag that shares a prefix is not on the list.
1362        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        // The deny list is empty by default.
1367        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}