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        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        // `/Users/<name>/...` and `C:\Users\<name>\...` for any account, not only this process's.
343        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    /// Judge one directory the runner would hand to the app. Returns its resolved path.
382    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    /// Judge every absolute-path value of a launch environment.
413    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
426/// Engine launches have no workspace: judge the data directory and the environment values that
427/// name data, home, profile or config directories.
428///
429/// This judges only what it is given. A launch that names no directory passes it, so engine
430/// launches go through [`check_engine_launch`], which also refuses a launch with no isolated data
431/// directory.
432pub 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/// Everything the guard judges about one engine, sidecar or app-engine launch, before the process
462/// starts. `env` is the environment the launcher will set on the child (target values, the data
463/// directory variable, then the caller's values, in that order: the last value of a key wins, as
464/// with `Command::env`).
465#[derive(Debug, Clone)]
466pub struct EngineLaunch<'a> {
467    /// The program that would start; named in every refusal.
468    pub binary: &'a Path,
469    /// Arguments handed to the program.
470    pub args: &'a [String],
471    /// The directory the caller passed as the launch's data directory.
472    pub data_dir: Option<&'a Path>,
473    /// Environment variable through which the target receives its data directory.
474    pub data_dir_env: Option<&'a str>,
475    /// Flag through which the target takes its data directory in `args` (`--data-dir <dir>` or
476    /// `--data-dir=<dir>`).
477    pub data_dir_arg: Option<&'a str>,
478    /// Flags the target declares as "use the real library"; a launch carrying one is refused.
479    pub forbidden_args: &'a [String],
480    /// The environment the child will get from the launcher.
481    pub env: &'a [(String, String)],
482    /// The caller opted out of the isolated-directory rule to probe the engine's own refusal.
483    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
499/// Value of the last `flag <value>` / `flag=<value>` in `args`.
500fn 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
512/// Judge one engine launch. A launch is refused, before anything starts, when:
513/// 1. an argument equals (or is `flag=...` of) a flag in `forbidden_args`;
514/// 2. the data directory, or an absolute environment value naming data, home, profile or config,
515///    breaks [`check_engine_dirs`]'s rules;
516/// 3. it names no isolated data directory: neither the `data_dir_env` variable nor the
517///    `data_dir_arg` flag carries a non-empty value that [`Guard::check_dir`] accepts. A
518///    `data_dir` the target cannot deliver (no `data_dir_env`) does not count, because the engine
519///    would never see it and would resolve its real per-user folder by its own default.
520///
521/// With `refusal_probe` the third rule is replaced by a second barrier, available only on Unix:
522/// the child environment must redirect `HOME` into a directory that passes the guard, and
523/// `XDG_DATA_HOME` / `XDG_CONFIG_HOME` (set by the launcher or inherited from this process) must
524/// do the same where set. On Windows the probe is refused outright: the engine finds its folder
525/// through the Known Folder API, which ignores the environment, so no environment redirect is a
526/// barrier there.
527pub 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
622/// App launches get their data directory from the workspace: the launch environment must carry
623/// `RIGHTKIT_QA_DATA_DIR`, pointing inside the QA root `guard` was built for. A workspace whose
624/// environment lost it is refused rather than launched on the app's own default folder.
625pub 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
643/// How a seed copy proves it holds no user data.
644pub struct ScrubDeclaration<'a> {
645    /// The scenario states that its transform removes all user data.
646    pub declared_scrubbed: bool,
647    /// Called on the seeded copy before launch; must return true (for example "zero user rows").
648    pub verify: &'a dyn Fn(&Path) -> bool,
649}
650
651/// Seed `<data_dir>/<dest_rel>` from `source` through the caller's `transform(source, dest)`.
652///
653/// Generated fixtures are the default and need no scrub. A `source` that is the real per-user data
654/// of `app` (or in any forbidden location) is refused unless `scrub` declares the copy scrubbed and
655/// its callback returns true on the result; when it returns false the copy is deleted and the
656/// seed fails, so nothing launches on it.
657pub 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
669/// [`seed_from`] with an explicit guard (tests use a fake user home).
670pub 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            // The copy lives inside the QA data directory the guard just verified.
709            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
725/// Names of the macOS agents that show system permission prompts.
726const SYSTEM_DIALOG_OWNERS: &[&str] = &[
727    "usernotificationcenter",
728    "coreservicesuiagent",
729    "universalaccessauthwarn",
730    "securityagent",
731    "tccd",
732];
733
734/// True when `name` is a macOS process that owns permission prompts.
735pub 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/// Outcome of a permission-dialog check.
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub enum DialogCheck {
743    /// Evidence shows no dialog.
744    Clear,
745    /// A dialog owner was seen.
746    Dialog(String),
747    /// This build cannot observe system dialogs; the string says why. Not evidence of absence.
748    Unsupported(&'static str),
749}
750
751/// Positive evidence only: the first sampled foreground name that belongs to a system dialog
752/// agent, from `StopReport::frontmost_states`. An empty answer proves nothing, because these
753/// agents rarely become frontmost.
754pub 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
761/// TODO: observe live TCC prompts. `rightkit_qa_driver::mac::windows(pid)` lists windows of one pid
762/// only; there is no enumeration of windows by owner name (`UserNotificationCenter`,
763/// `CoreServicesUIAgent`), so no reliable detector exists in this crate yet. This hook reports
764/// [`DialogCheck::Unsupported`] instead of guessing. Replace the body once the automation layer
765/// can list windows by owner without clicking them.
766pub 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            // The refused path is reported in resolved form; on Windows that is the canonical
848            // spelling of the home, which the fixture's own spelling does not start.
849            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        // A directory in the fake home that no rule names is still outside the QA root.
857        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        // No declaration.
979        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        // Declared false.
983        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        // Declared, but verification fails: copy deleted.
989        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        // Declared and verified.
996        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        // A generated fixture needs no scrub.
1003        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        // Destination escapes are refused.
1020        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    // ---- engine launches must name an isolated data directory ----
1048
1049    /// One engine launch as the tests describe it. `judge` runs the real rule with a fake user
1050    /// home, so the cases behave the same on every host.
1051    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        // An empty value reads as absent.
1123        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        // Redirecting HOME is not naming a data directory.
1127        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        // The same through the target's data-dir flag, in both spellings.
1153        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        // A good variable does not excuse a bad flag.
1190        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        // A later caller value replaces the target's, and is judged.
1196        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        // The public rule, with this process's own home, refuses the home itself.
1201        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            // A home on an external volume or a cloud folder is refused under that rule instead.
1219            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        // No HOME in the child environment.
1236        let v = judge(&f, &c).unwrap_err();
1237        assert_eq!(v.rule, Rule::ProbeUnavailable);
1238        assert!(v.detail.contains("HOME"), "{}", v.detail);
1239        // HOME at the real home: the directory rules refuse it before the probe is judged.
1240        set(&mut c, "HOME", &f.fake_home);
1241        assert_eq!(rule_of(judge(&f, &c)), Rule::UserFolder);
1242        // HOME in the workspace.
1243        let mut c = case();
1244        c.probe = true;
1245        set(&mut c, "HOME", &f.ws.home);
1246        judge(&f, &c).unwrap();
1247        // An inherited XDG home outside the workspace defeats the redirect ...
1248        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        // ... until the launcher overrides it.
1256        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        // Only a whole flag matches; a longer flag that shares a prefix is not on the list.
1336        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        // The deny list is empty by default.
1341        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}