Skip to main content

rightkit_daemon/
mode.rs

1//! The one runtime decision between a release run and a test run (M21), and the only sanctioned
2//! reader of a test or override selector (M23).
3//!
4//! Design rule: the strongest guarantee is that a release binary contains **no code that reads a
5//! test or override selector**. So there are two builds of this module:
6//!
7//! * **Without the `test-mode` cargo feature** (the default, and the only thing a release build may
8//!   use): [`decide`] returns [`Mode::Release`] and performs no environment or argument read at all.
9//!   `test_selector!` expands to `None` and drops its name literal. The binary carries no selector
10//!   name and no `TEST_MODE_MARKER`. [`roots`] resolves from the operating system's account
11//!   database ([`Suite::per_user_default`]), never from the environment.
12//! * **With `test-mode`** (QA and dev builds only): [`decide`] reads the activation selector. A run
13//!   is a test run only when the activation selector AND a root that passes every check below are
14//!   present; anything partial is a [`Refusal`] before any store, log, credential or service opens.
15//!   The root must be absolute, an existing directory, not itself a symlink, disjoint from the real
16//!   per-user suite root and the user's Documents/Pictures-style folders (equal, ancestor,
17//!   descendant, or reached through a symlinked ancestor), outside cloud-sync folders, private to the
18//!   current user on Unix, and carry the binding file (`.rightkit-app.json`, `{"app": "<id>"}`)
19//!   that `rightkit-qa` writes when it creates an isolated home, naming this app.
20//!
21//! `@rightkit/release` (`release-switch-scan.mjs`) refuses a release build that enables
22//! `test-mode`, and refuses a native binary that contains `TEST_MODE_MARKER` text or any selector
23//! name. Selector names therefore live only behind the feature, and no message in this module that is
24//! compiled into a release build names one.
25//!
26//! The decision is deliberately injectable ([`ModeInput`]) so tests can prove the "reads nothing"
27//! and "refuses" properties without touching the real process environment.
28
29use std::fmt;
30use std::io;
31use std::path::{Path, PathBuf};
32use std::sync::OnceLock;
33
34use crate::suite::{Suite, SuiteError, SUITE_DIR_NAME};
35
36/// Whether this build compiled the test capability (cargo feature `test-mode`).
37pub const TEST_CAPABILITY: bool = cfg!(feature = "test-mode");
38
39/// Text of the marker a test-capable binary carries. Keep identical to `TEST_MODE_MARKER` in
40/// `packages/release/release-switch-scan.mjs`.
41#[cfg(feature = "test-mode")]
42pub const TEST_MODE_MARKER_TEXT: &str = "RIGHTKIT-TEST-MODE-COMPILED";
43
44/// Present in the binary only when `test-mode` is compiled; the release scan refuses any binary
45/// that contains it. Secondary evidence: the authoritative gate is the resolved feature closure
46/// (`cargo tree -e features`) checked by `@rightkit/release`. The bytes are inline in a named
47/// section, exported, and `#[used]`, so a linker's section garbage collection keeps them.
48#[cfg(feature = "test-mode")]
49#[used]
50#[no_mangle]
51#[cfg_attr(target_os = "macos", link_section = "__DATA,__rkit_tm")]
52#[cfg_attr(windows, link_section = ".rkittm")]
53#[cfg_attr(not(any(target_os = "macos", windows)), link_section = ".rkit_tm")]
54pub static TEST_MODE_MARKER: [u8; 27] = *b"RIGHTKIT-TEST-MODE-COMPILED";
55
56/// Activation selector (class S1). Test-capable builds only.
57#[cfg(feature = "test-mode")]
58const SELECTOR_ACTIVATION: &str = "RIGHTKIT_UNDER_TEST";
59/// Test root selector (class S4). Test-capable builds only.
60#[cfg(feature = "test-mode")]
61const SELECTOR_ROOT: &str = "RIGHTKIT_QA_DATA_DIR";
62/// Binding file `rightkit-qa` writes inside every home it creates.
63#[cfg(feature = "test-mode")]
64const BINDING_FILE: &str = ".rightkit-app.json";
65
66/// The app asking for a mode. `id` is the binding key (`"viewright"`); `dir_name` is the folder
67/// under `Orthic Labs` that holds the app's own data (`"ViewRight"`).
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct AppId {
70    id: &'static str,
71    dir_name: &'static str,
72}
73
74impl AppId {
75    /// An app whose data folder carries the same name as its id.
76    pub const fn new(id: &'static str) -> AppId {
77        AppId { id, dir_name: id }
78    }
79
80    /// An app whose data folder differs from its id.
81    pub const fn with_dir(id: &'static str, dir_name: &'static str) -> AppId {
82        AppId { id, dir_name }
83    }
84
85    pub const fn id(&self) -> &'static str {
86        self.id
87    }
88
89    pub const fn dir_name(&self) -> &'static str {
90        self.dir_name
91    }
92}
93
94/// Why a test run was refused. Each variant names its rule through [`Refusal::rule`]. Messages
95/// never name an environment variable: this enum is compiled into release builds too.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum Refusal {
98    /// The activation selector is set but no root is given.
99    ActivationWithoutRoot,
100    /// A root is given but the activation selector is not set.
101    RootWithoutActivation,
102    RootNotAbsolute,
103    /// Missing, or not a directory.
104    RootNotDirectory,
105    /// The root itself is a symlink.
106    RootHasSymlink,
107    /// Equal to, an ancestor of, a descendant of, or resolving into a real user location.
108    RootOverlapsUserData,
109    /// Inside a cloud-synchronised folder (name heuristic, see [`Refusal::rule`]).
110    RootOnRemovableOrCloud,
111    /// Not private to the current user (Unix: not owned by the user, or group/other bits set).
112    RootNotPrivate,
113    /// No readable binding file: not a home a runner created.
114    RootNotCreatedByRunner,
115    /// The binding names a different app.
116    BindingAppMismatch,
117    /// The real per-user root could not be determined, so disjointness cannot be proven.
118    UserRootUnknown,
119    /// A filesystem error while verifying or resetting.
120    Io(String),
121}
122
123impl Refusal {
124    /// A stable kebab-case rule id (the vocabulary `rightkit-qa`'s guard shares).
125    pub fn rule(&self) -> &'static str {
126        match self {
127            Refusal::ActivationWithoutRoot => "activation-without-root",
128            Refusal::RootWithoutActivation => "root-without-activation",
129            Refusal::RootNotAbsolute => "root-not-absolute",
130            Refusal::RootNotDirectory => "root-not-directory",
131            Refusal::RootHasSymlink => "root-has-symlink",
132            Refusal::RootOverlapsUserData => "root-overlaps-user-data",
133            Refusal::RootOnRemovableOrCloud => "root-on-removable-or-cloud",
134            Refusal::RootNotPrivate => "root-not-private",
135            Refusal::RootNotCreatedByRunner => "root-not-created-by-runner",
136            Refusal::BindingAppMismatch => "binding-app-mismatch",
137            Refusal::UserRootUnknown => "user-root-unknown",
138            Refusal::Io(_) => "io",
139        }
140    }
141}
142
143impl fmt::Display for Refusal {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Refusal::Io(e) => write!(f, "test mode refused ({}): {e}", self.rule()),
147            _ => write!(f, "test mode refused ({})", self.rule()),
148        }
149    }
150}
151
152impl std::error::Error for Refusal {}
153
154impl From<io::Error> for Refusal {
155    fn from(e: io::Error) -> Self {
156        Refusal::Io(e.to_string())
157    }
158}
159
160/// A verified test root: canonical, symlink-free at the leaf, disjoint from real user data,
161/// private, and bound to one app by the runner. Constructible only by [`decide`] in a build that
162/// compiled `test-mode`.
163#[derive(Debug, Clone)]
164pub struct TestRoot {
165    path: PathBuf,
166    suite: Suite,
167    #[cfg_attr(not(feature = "test-mode"), allow(dead_code))]
168    app_id: &'static str,
169}
170
171impl TestRoot {
172    /// The canonical root directory.
173    pub fn path(&self) -> &Path {
174        &self.path
175    }
176
177    /// The suite root for this run: `<root>/shared`.
178    pub fn suite(&self) -> Suite {
179        self.suite.clone()
180    }
181
182    /// A unit-test stand-in for what [`decide`] builds after validation.
183    #[cfg(test)]
184    pub(crate) fn for_test(path: PathBuf, suite: Suite) -> TestRoot {
185        TestRoot {
186            path,
187            suite,
188            app_id: "test",
189        }
190    }
191
192    /// The app's own data directory inside the root: `<root>/<dir_name>`.
193    pub fn app_dir(&self, app: &AppId) -> PathBuf {
194        self.path.join(app.dir_name)
195    }
196}
197
198/// How a run was decided. `Release` is the only value a build without `test-mode` can produce.
199#[derive(Debug, Clone)]
200pub enum Mode {
201    Release,
202    Test(TestRoot),
203}
204
205impl Mode {
206    pub fn is_test(&self) -> bool {
207        matches!(self, Mode::Test(_))
208    }
209
210    /// The test root, when this is a test run.
211    pub fn test_root(&self) -> Option<&TestRoot> {
212        match self {
213            Mode::Test(root) => Some(root),
214            Mode::Release => None,
215        }
216    }
217
218    /// Read an override selector. Only [`test_selector!`] calls this, and only in a build that
219    /// compiled `test-mode`; always `None` for [`Mode::Release`].
220    #[cfg(feature = "test-mode")]
221    #[doc(hidden)]
222    pub fn selector_env(&self, name: &str) -> Option<String> {
223        match self {
224            Mode::Test(_) => std::env::var(name).ok().filter(|v| !v.trim().is_empty()),
225            Mode::Release => None,
226        }
227    }
228}
229
230/// The only sanctioned reader of an override selector: `test_selector!(mode, "NAME")` returns the
231/// variable's value for a test run and `None` otherwise. In a build without `test-mode` it expands
232/// to `None` and the name literal does not appear in the binary.
233///
234/// It is also the reader for an app's own QA inputs (which data source a QA build uses, say): read
235/// `test_selector!(mode, "RIGHTKIT_<APP>_<NAME>")`, never `std::env::var`, so a release build
236/// neither reads the variable nor carries its name. Keep the `RIGHTKIT_` prefix, because a macOS
237/// QA launch passes only `RIGHTKIT_*` keys, and declare the key in the app's `switches.json`. A
238/// scenario sets the input with the `[inputs]` table of `rightkit-qa`, which refuses a malformed
239/// key, a name the harness uses itself and a credential name (`rightkit_qa::scenario::Scenario::inputs`).
240#[cfg(feature = "test-mode")]
241#[macro_export]
242macro_rules! test_selector {
243    ($mode:expr, $name:literal) => {
244        $crate::mode::Mode::selector_env(&$mode, $name)
245    };
246}
247
248/// See the `test-mode` form: this one drops the name.
249#[cfg(not(feature = "test-mode"))]
250#[macro_export]
251macro_rules! test_selector {
252    ($mode:expr, $name:literal) => {{
253        let _ = &$mode;
254        ::core::option::Option::<::std::string::String>::None
255    }};
256}
257
258/// Where [`decide`] reads from. The default implementation of the provided methods is the real
259/// operating system. A test supplies a fake, including one that panics on any read.
260pub trait ModeInput {
261    /// An environment variable. Called only by a build that compiled `test-mode`.
262    fn env(&self, name: &str) -> Option<String>;
263
264    /// The real per-user suite root, from the account database.
265    fn per_user_default(&self) -> Result<Suite, SuiteError> {
266        Suite::per_user_default()
267    }
268
269    /// Locations a test root may never overlap, given the real suite root
270    /// (`<base>/Orthic Labs/shared`): the whole `Orthic Labs` folder and the user's content
271    /// folders under the home that `<base>` belongs to.
272    fn protected_paths(&self, real: &Suite) -> Vec<PathBuf> {
273        default_protected_paths(real)
274    }
275}
276
277/// The real process environment.
278#[derive(Debug, Clone, Copy, Default)]
279pub struct ProcessInput;
280
281impl ModeInput for ProcessInput {
282    fn env(&self, name: &str) -> Option<String> {
283        std::env::var(name).ok()
284    }
285}
286
287/// Protected paths derived from the real suite root: the whole `Orthic Labs` folder and the
288/// user's content folders under the home.
289///
290/// Fails closed with an empty list (which [`decide`] turns into [`Refusal::UserRootUnknown`])
291/// when, on Windows in a `test-mode` build, the real root lies under a `USERPROFILE` or
292/// `LOCALAPPDATA` that differs from the account's own value: such a root was shaped by the
293/// environment (a QA home), and the folders derived from it would protect the wrong place.
294pub fn default_protected_paths(real: &Suite) -> Vec<PathBuf> {
295    let mut out = Vec::new();
296    if real_root_is_env_shaped(real.root()) {
297        return out;
298    }
299    let Some(orthic) = real.root().parent() else {
300        return out;
301    };
302    out.push(orthic.to_path_buf());
303    // Only trust the shape when the parent is `Orthic Labs`: `<home>` is then three ancestors up
304    // (`<home>/Library/Application Support`, `<home>/.local/share`, `<home>\AppData\Local`).
305    if orthic.file_name().and_then(|n| n.to_str()) == Some(SUITE_DIR_NAME) {
306        if let Some(home) = orthic.ancestors().nth(3) {
307            for dir in user_content_dirs() {
308                out.push(home.join(dir));
309            }
310        }
311    }
312    out
313}
314
315/// Windows, `test-mode` build: whether `real` lies under an environment profile value that
316/// differs from the account's. An unreadable account profile counts as env-shaped. Elsewhere
317/// (and in a build without `test-mode`, which reads no environment) always `false`.
318fn real_root_is_env_shaped(real: &Path) -> bool {
319    #[cfg(all(windows, feature = "test-mode"))]
320    {
321        let Ok(profile) = crate::suite::account_profile_dir() else {
322            return true;
323        };
324        let env = |key: &str| {
325            std::env::var_os(key)
326                .filter(|v| !v.is_empty())
327                .map(PathBuf::from)
328        };
329        let local = profile.join("AppData").join("Local");
330        root_under_differing_env(
331            real,
332            &[(env("USERPROFILE"), profile), (env("LOCALAPPDATA"), local)],
333        )
334    }
335    #[cfg(not(all(windows, feature = "test-mode")))]
336    {
337        let _ = real;
338        false
339    }
340}
341
342/// True when `root` lies under (or equals) an `env` value of a pair whose `env` differs from its
343/// `account` value. Paths compare by whole components, ignoring a verbatim prefix and, on
344/// Windows, case and separator style.
345#[cfg_attr(not(all(windows, feature = "test-mode")), allow(dead_code))]
346pub(crate) fn root_under_differing_env(root: &Path, pairs: &[(Option<PathBuf>, PathBuf)]) -> bool {
347    fn norm(p: &Path) -> Vec<String> {
348        let text = p.to_string_lossy();
349        let text = text.strip_prefix(r"\\?\").unwrap_or(&text);
350        text.split(['/', '\\'])
351            .filter(|c| !c.is_empty())
352            .map(|c| {
353                if cfg!(windows) {
354                    c.to_ascii_lowercase()
355                } else {
356                    c.to_string()
357                }
358            })
359            .collect()
360    }
361    let root = norm(root);
362    pairs.iter().any(|(env, account)| {
363        env.as_deref().is_some_and(|env| {
364            let env = norm(env);
365            !env.is_empty() && env != norm(account) && root.starts_with(&env)
366        })
367    })
368}
369
370fn user_content_dirs() -> [&'static str; 7] {
371    [
372        "Documents",
373        "Pictures",
374        "Desktop",
375        "Downloads",
376        "Movies",
377        "Music",
378        "Videos",
379    ]
380}
381
382/// Resolved roots a store must come through.
383#[derive(Debug, Clone)]
384pub struct AppRoots {
385    /// The suite root (`<Orthic Labs>/shared` in release, `<test root>/shared` in a test run).
386    pub suite: Suite,
387    /// The app's own data directory.
388    pub app_dir: PathBuf,
389}
390
391/// Decide once, before any store, log, credential or service starts.
392///
393/// Without `test-mode` this returns [`Mode::Release`] and reads nothing from `input`.
394pub fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
395    #[cfg(feature = "test-mode")]
396    {
397        capable::decide(app, input)
398    }
399    #[cfg(not(feature = "test-mode"))]
400    {
401        let _ = (app, input);
402        Ok(Mode::Release)
403    }
404}
405
406/// [`decide`] against the real process, once per process. Prints one line to stderr and exits with
407/// status 2 on a [`Refusal`]; hosts call it first in `main`.
408pub fn init_or_exit(app: &AppId) -> &'static Mode {
409    static MODE: OnceLock<Mode> = OnceLock::new();
410    if let Some(mode) = MODE.get() {
411        return mode;
412    }
413    match decide(app, &ProcessInput) {
414        Ok(mode) => MODE.get_or_init(|| mode),
415        Err(refusal) => {
416            eprintln!("{refusal}");
417            std::process::exit(2);
418        }
419    }
420}
421
422/// The roots every store must come through, against the real process.
423pub fn roots(app: &AppId, mode: &Mode) -> Result<AppRoots, SuiteError> {
424    roots_with(app, mode, &ProcessInput)
425}
426
427/// [`roots`] with an injectable source for the real per-user root.
428pub fn roots_with(app: &AppId, mode: &Mode, input: &dyn ModeInput) -> Result<AppRoots, SuiteError> {
429    match mode {
430        Mode::Release => {
431            let suite = input.per_user_default()?;
432            let orthic = suite
433                .root()
434                .parent()
435                .ok_or_else(|| SuiteError::NoRoot("the per-user suite root has no parent".into()))?
436                .to_path_buf();
437            Ok(AppRoots {
438                suite,
439                app_dir: orthic.join(app.dir_name),
440            })
441        }
442        Mode::Test(root) => Ok(AppRoots {
443            suite: root.suite(),
444            app_dir: root.app_dir(app),
445        }),
446    }
447}
448
449/// The test-capable half. Compiled only with `test-mode`.
450#[cfg(feature = "test-mode")]
451mod capable {
452    use super::*;
453    use std::fs;
454
455    fn truthy(v: &str) -> bool {
456        let t = v.trim();
457        !(t.is_empty() || t == "0" || t.eq_ignore_ascii_case("false"))
458    }
459
460    pub(super) fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
461        let activated = input
462            .env(SELECTOR_ACTIVATION)
463            .filter(|v| truthy(v))
464            .is_some();
465        let root = input.env(SELECTOR_ROOT).filter(|v| !v.trim().is_empty());
466        match (activated, root) {
467            (false, None) => Ok(Mode::Release),
468            (false, Some(_)) => Err(Refusal::RootWithoutActivation),
469            (true, None) => Err(Refusal::ActivationWithoutRoot),
470            (true, Some(root)) => verify(app, input, &root).map(Mode::Test),
471        }
472    }
473
474    fn map_refusal(refusal: rightkit_fs::test_mode::Refusal) -> Refusal {
475        use rightkit_fs::test_mode::Refusal as FsRefusal;
476        match refusal {
477            FsRefusal::ActivationWithoutRoot => Refusal::ActivationWithoutRoot,
478            FsRefusal::RootWithoutActivation => Refusal::RootWithoutActivation,
479            FsRefusal::RootNotAbsolute => Refusal::RootNotAbsolute,
480            FsRefusal::RootNotDirectory => Refusal::RootNotDirectory,
481            FsRefusal::RootHasSymlink => Refusal::RootHasSymlink,
482            FsRefusal::RootOverlapsUserData => Refusal::RootOverlapsUserData,
483            FsRefusal::RootOnRemovableOrCloud => Refusal::RootOnRemovableOrCloud,
484            FsRefusal::RootNotPrivate => Refusal::RootNotPrivate,
485            FsRefusal::RootNotCreatedByRunner => Refusal::RootNotCreatedByRunner,
486            FsRefusal::BindingAppMismatch => Refusal::BindingAppMismatch,
487            FsRefusal::UserRootUnknown => Refusal::UserRootUnknown,
488            FsRefusal::Io(e) => Refusal::Io(e),
489            other => Refusal::Io(other.rule().to_string()),
490        }
491    }
492
493    fn check_binding(app: &AppId, canon: &Path) -> Result<(), Refusal> {
494        let bytes =
495            fs::read(canon.join(BINDING_FILE)).map_err(|_| Refusal::RootNotCreatedByRunner)?;
496        let value: serde_json::Value =
497            serde_json::from_slice(&bytes).map_err(|_| Refusal::RootNotCreatedByRunner)?;
498        match value.get("app").and_then(|v| v.as_str()) {
499            None => Err(Refusal::RootNotCreatedByRunner),
500            Some(bound) if bound == app.id => Ok(()),
501            Some(_) => Err(Refusal::BindingAppMismatch),
502        }
503    }
504
505    fn verify(app: &AppId, input: &dyn ModeInput, raw: &str) -> Result<TestRoot, Refusal> {
506        let real = input
507            .per_user_default()
508            .map_err(|_| Refusal::UserRootUnknown)?;
509        let mut protected = input.protected_paths(&real);
510        if protected.is_empty() {
511            // Nothing to prove disjointness against; the real root alone is not enough.
512            return Err(Refusal::UserRootUnknown);
513        }
514        protected.push(real.root().to_path_buf());
515        let validated =
516            rightkit_fs::test_mode::validate_root(app.id, Path::new(raw.trim()), &protected)
517                .map_err(map_refusal)?;
518        let suite_path = validated.child(Path::new("shared")).map_err(map_refusal)?;
519        let suite = Suite::at(suite_path).map_err(|e| Refusal::Io(e.to_string()))?;
520        Ok(TestRoot {
521            path: validated.path().to_path_buf(),
522            suite,
523            app_id: app.id,
524        })
525    }
526
527    impl TestRoot {
528        /// Remove everything inside the root except the runner's dot files (binding and markers),
529        /// after re-verifying the binding. Never follows a symlink out of the root, and nothing
530        /// reachable from this type is a real user location.
531        pub fn reset(&self) -> Result<(), Refusal> {
532            let app = AppId {
533                id: self.app_id,
534                dir_name: self.app_id,
535            };
536            check_binding(&app, &self.path)?;
537            for entry in fs::read_dir(&self.path)? {
538                let entry = entry?;
539                if entry.file_name().to_string_lossy().starts_with('.') {
540                    continue;
541                }
542                let ty = entry.file_type()?;
543                if ty.is_dir() && !ty.is_symlink() {
544                    fs::remove_dir_all(entry.path())?;
545                } else {
546                    fs::remove_file(entry.path())?;
547                }
548            }
549            Ok(())
550        }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    fn pair(env: Option<&str>, account: &str) -> (Option<PathBuf>, PathBuf) {
559        (env.map(PathBuf::from), PathBuf::from(account))
560    }
561
562    #[test]
563    fn a_root_under_a_differing_env_profile_is_env_shaped() {
564        let root = Path::new("/qa/home/AppData/Local/Orthic Labs/shared");
565        // The env value differs from the account and the root lies under it: fail closed.
566        assert!(root_under_differing_env(
567            root,
568            &[pair(Some("/qa/home"), "/users/real")]
569        ));
570        assert!(root_under_differing_env(
571            root,
572            &[
573                pair(None, "/users/real"),
574                pair(Some("/qa/home/AppData/Local"), "/x")
575            ]
576        ));
577        // The env value equals the account value, is unset, or the root is not under it.
578        assert!(!root_under_differing_env(
579            root,
580            &[pair(Some("/qa/home"), "/qa/home")]
581        ));
582        assert!(!root_under_differing_env(root, &[pair(None, "/qa/home")]));
583        assert!(!root_under_differing_env(
584            root,
585            &[pair(Some("/elsewhere"), "/qa/home")]
586        ));
587        // Whole components only: `/qa/hom` is not an ancestor of `/qa/home/...`.
588        assert!(!root_under_differing_env(
589            root,
590            &[pair(Some("/qa/hom"), "/users/real")]
591        ));
592    }
593
594    #[cfg(windows)]
595    #[test]
596    fn env_comparison_ignores_case_separators_and_the_verbatim_prefix_on_windows() {
597        let root = Path::new(r"\\?\C:\Users\QA\AppData\Local\Orthic Labs\shared");
598        assert!(root_under_differing_env(
599            root,
600            &[pair(Some("c:/users/qa"), r"C:\Users\Real")]
601        ));
602        assert!(!root_under_differing_env(
603            root,
604            &[pair(Some(r"C:\USERS\QA"), r"c:\users\qa")]
605        ));
606    }
607
608    #[test]
609    fn the_default_protected_paths_keep_the_orthic_folder_and_content_dirs() {
610        let real = Suite::at(Path::new(if cfg!(windows) {
611            r"C:\Users\acct\AppData\Local\Orthic Labs\shared"
612        } else {
613            "/users/acct/AppData/Local/Orthic Labs/shared"
614        }))
615        .unwrap();
616        let paths = default_protected_paths(&real);
617        assert!(paths.iter().any(|p| p.ends_with("Orthic Labs")));
618        assert!(paths.iter().any(|p| p.ends_with("Documents")));
619    }
620}