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    /// The app's own data directory inside the root: `<root>/<dir_name>`.
183    pub fn app_dir(&self, app: &AppId) -> PathBuf {
184        self.path.join(app.dir_name)
185    }
186}
187
188/// How a run was decided. `Release` is the only value a build without `test-mode` can produce.
189#[derive(Debug, Clone)]
190pub enum Mode {
191    Release,
192    Test(TestRoot),
193}
194
195impl Mode {
196    pub fn is_test(&self) -> bool {
197        matches!(self, Mode::Test(_))
198    }
199
200    /// The test root, when this is a test run.
201    pub fn test_root(&self) -> Option<&TestRoot> {
202        match self {
203            Mode::Test(root) => Some(root),
204            Mode::Release => None,
205        }
206    }
207
208    /// Read an override selector. Only [`test_selector!`] calls this, and only in a build that
209    /// compiled `test-mode`; always `None` for [`Mode::Release`].
210    #[cfg(feature = "test-mode")]
211    #[doc(hidden)]
212    pub fn selector_env(&self, name: &str) -> Option<String> {
213        match self {
214            Mode::Test(_) => std::env::var(name).ok().filter(|v| !v.trim().is_empty()),
215            Mode::Release => None,
216        }
217    }
218}
219
220/// The only sanctioned reader of an override selector: `test_selector!(mode, "NAME")` returns the
221/// variable's value for a test run and `None` otherwise. In a build without `test-mode` it expands
222/// to `None` and the name literal does not appear in the binary.
223#[cfg(feature = "test-mode")]
224#[macro_export]
225macro_rules! test_selector {
226    ($mode:expr, $name:literal) => {
227        $crate::mode::Mode::selector_env(&$mode, $name)
228    };
229}
230
231/// See the `test-mode` form: this one drops the name.
232#[cfg(not(feature = "test-mode"))]
233#[macro_export]
234macro_rules! test_selector {
235    ($mode:expr, $name:literal) => {{
236        let _ = &$mode;
237        ::core::option::Option::<::std::string::String>::None
238    }};
239}
240
241/// Where [`decide`] reads from. The default implementation of the provided methods is the real
242/// operating system. A test supplies a fake, including one that panics on any read.
243pub trait ModeInput {
244    /// An environment variable. Called only by a build that compiled `test-mode`.
245    fn env(&self, name: &str) -> Option<String>;
246
247    /// The real per-user suite root, from the account database.
248    fn per_user_default(&self) -> Result<Suite, SuiteError> {
249        Suite::per_user_default()
250    }
251
252    /// Locations a test root may never overlap, given the real suite root
253    /// (`<base>/Orthic Labs/shared`): the whole `Orthic Labs` folder and the user's content
254    /// folders under the home that `<base>` belongs to.
255    fn protected_paths(&self, real: &Suite) -> Vec<PathBuf> {
256        default_protected_paths(real)
257    }
258}
259
260/// The real process environment.
261#[derive(Debug, Clone, Copy, Default)]
262pub struct ProcessInput;
263
264impl ModeInput for ProcessInput {
265    fn env(&self, name: &str) -> Option<String> {
266        std::env::var(name).ok()
267    }
268}
269
270/// Protected paths derived from the real suite root: the whole `Orthic Labs` folder and the
271/// user's content folders under the home.
272pub fn default_protected_paths(real: &Suite) -> Vec<PathBuf> {
273    let mut out = Vec::new();
274    let Some(orthic) = real.root().parent() else {
275        return out;
276    };
277    out.push(orthic.to_path_buf());
278    // Only trust the shape when the parent is `Orthic Labs`: `<home>` is then three ancestors up
279    // (`<home>/Library/Application Support`, `<home>/.local/share`, `<home>\AppData\Local`).
280    if orthic.file_name().and_then(|n| n.to_str()) == Some(SUITE_DIR_NAME) {
281        if let Some(home) = orthic.ancestors().nth(3) {
282            for dir in user_content_dirs() {
283                out.push(home.join(dir));
284            }
285        }
286    }
287    out
288}
289
290fn user_content_dirs() -> [&'static str; 7] {
291    [
292        "Documents",
293        "Pictures",
294        "Desktop",
295        "Downloads",
296        "Movies",
297        "Music",
298        "Videos",
299    ]
300}
301
302/// Resolved roots a store must come through.
303#[derive(Debug, Clone)]
304pub struct AppRoots {
305    /// The suite root (`<Orthic Labs>/shared` in release, `<test root>/shared` in a test run).
306    pub suite: Suite,
307    /// The app's own data directory.
308    pub app_dir: PathBuf,
309}
310
311/// Decide once, before any store, log, credential or service starts.
312///
313/// Without `test-mode` this returns [`Mode::Release`] and reads nothing from `input`.
314pub fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
315    #[cfg(feature = "test-mode")]
316    {
317        capable::decide(app, input)
318    }
319    #[cfg(not(feature = "test-mode"))]
320    {
321        let _ = (app, input);
322        Ok(Mode::Release)
323    }
324}
325
326/// [`decide`] against the real process, once per process. Prints one line to stderr and exits with
327/// status 2 on a [`Refusal`]; hosts call it first in `main`.
328pub fn init_or_exit(app: &AppId) -> &'static Mode {
329    static MODE: OnceLock<Mode> = OnceLock::new();
330    if let Some(mode) = MODE.get() {
331        return mode;
332    }
333    match decide(app, &ProcessInput) {
334        Ok(mode) => MODE.get_or_init(|| mode),
335        Err(refusal) => {
336            eprintln!("{refusal}");
337            std::process::exit(2);
338        }
339    }
340}
341
342/// The roots every store must come through, against the real process.
343pub fn roots(app: &AppId, mode: &Mode) -> Result<AppRoots, SuiteError> {
344    roots_with(app, mode, &ProcessInput)
345}
346
347/// [`roots`] with an injectable source for the real per-user root.
348pub fn roots_with(app: &AppId, mode: &Mode, input: &dyn ModeInput) -> Result<AppRoots, SuiteError> {
349    match mode {
350        Mode::Release => {
351            let suite = input.per_user_default()?;
352            let orthic = suite
353                .root()
354                .parent()
355                .ok_or_else(|| SuiteError::NoRoot("the per-user suite root has no parent".into()))?
356                .to_path_buf();
357            Ok(AppRoots {
358                suite,
359                app_dir: orthic.join(app.dir_name),
360            })
361        }
362        Mode::Test(root) => Ok(AppRoots {
363            suite: root.suite(),
364            app_dir: root.app_dir(app),
365        }),
366    }
367}
368
369/// The test-capable half. Compiled only with `test-mode`.
370#[cfg(feature = "test-mode")]
371mod capable {
372    use super::*;
373    use std::fs;
374    use std::path::Component;
375
376    fn truthy(v: &str) -> bool {
377        let t = v.trim();
378        !(t.is_empty() || t == "0" || t.eq_ignore_ascii_case("false"))
379    }
380
381    pub(super) fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
382        let activated = input
383            .env(SELECTOR_ACTIVATION)
384            .filter(|v| truthy(v))
385            .is_some();
386        let root = input.env(SELECTOR_ROOT).filter(|v| !v.trim().is_empty());
387        match (activated, root) {
388            (false, None) => Ok(Mode::Release),
389            (false, Some(_)) => Err(Refusal::RootWithoutActivation),
390            (true, None) => Err(Refusal::ActivationWithoutRoot),
391            (true, Some(root)) => verify(app, input, &root).map(Mode::Test),
392        }
393    }
394
395    /// Longest existing ancestor canonicalised, remainder appended lexically.
396    fn resolve(path: &Path) -> PathBuf {
397        let mut rest: Vec<std::ffi::OsString> = Vec::new();
398        let mut cur = path.to_path_buf();
399        loop {
400            if let Ok(canon) = fs::canonicalize(&cur) {
401                let mut out = canon;
402                for part in rest.iter().rev() {
403                    out.push(part);
404                }
405                return out;
406            }
407            match (cur.file_name().map(|n| n.to_os_string()), cur.parent()) {
408                (Some(name), Some(parent)) => {
409                    rest.push(name);
410                    cur = parent.to_path_buf();
411                }
412                _ => return path.to_path_buf(),
413            }
414        }
415    }
416
417    fn overlaps(root: &Path, protected: &Path) -> bool {
418        root.starts_with(protected) || protected.starts_with(root)
419    }
420
421    fn on_cloud_folder(path: &Path) -> bool {
422        path.components().any(|c| match c {
423            Component::Normal(name) => {
424                let n = name.to_string_lossy().to_lowercase();
425                n.starts_with("onedrive")
426                    || n == "dropbox"
427                    || n == "icloud drive"
428                    || n == "mobile documents"
429                    || n == "google drive"
430                    || n == "googledrive"
431            }
432            _ => false,
433        })
434    }
435
436    #[cfg(unix)]
437    fn private_to_user(path: &Path) -> Result<bool, Refusal> {
438        use std::os::unix::fs::{MetadataExt, PermissionsExt};
439        let md = fs::metadata(path)?;
440        Ok(md.uid() == crate::transport::current_uid() && md.permissions().mode() & 0o077 == 0)
441    }
442
443    #[cfg(not(unix))]
444    fn private_to_user(_path: &Path) -> Result<bool, Refusal> {
445        // Windows: a per-user DACL check is not implemented here; the runner creates the home
446        // under the user's own profile. Disjointness and binding are still enforced.
447        Ok(true)
448    }
449
450    fn check_binding(app: &AppId, canon: &Path) -> Result<(), Refusal> {
451        let bytes =
452            fs::read(canon.join(BINDING_FILE)).map_err(|_| Refusal::RootNotCreatedByRunner)?;
453        let value: serde_json::Value =
454            serde_json::from_slice(&bytes).map_err(|_| Refusal::RootNotCreatedByRunner)?;
455        match value.get("app").and_then(|v| v.as_str()) {
456            None => Err(Refusal::RootNotCreatedByRunner),
457            Some(bound) if bound == app.id => Ok(()),
458            Some(_) => Err(Refusal::BindingAppMismatch),
459        }
460    }
461
462    fn verify(app: &AppId, input: &dyn ModeInput, raw: &str) -> Result<TestRoot, Refusal> {
463        let path = PathBuf::from(raw.trim());
464        // Verbatim Windows paths (`\\?\`) keep `..` as a literal component, so match the text too.
465        let parent_dir =
466            |c: &Component<'_>| matches!(c, Component::ParentDir) || c.as_os_str() == "..";
467        if !path.is_absolute() || path.components().any(|c| parent_dir(&c)) {
468            return Err(Refusal::RootNotAbsolute);
469        }
470        let md = fs::symlink_metadata(&path).map_err(|_| Refusal::RootNotDirectory)?;
471        if md.file_type().is_symlink() {
472            return Err(Refusal::RootHasSymlink);
473        }
474        if !md.is_dir() {
475            return Err(Refusal::RootNotDirectory);
476        }
477        let canon = fs::canonicalize(&path)?;
478
479        let real = input
480            .per_user_default()
481            .map_err(|_| Refusal::UserRootUnknown)?;
482        let mut protected = input.protected_paths(&real);
483        protected.push(real.root().to_path_buf());
484        if protected.is_empty() {
485            return Err(Refusal::UserRootUnknown);
486        }
487        for p in &protected {
488            if overlaps(&canon, &resolve(p)) {
489                return Err(Refusal::RootOverlapsUserData);
490            }
491        }
492        if on_cloud_folder(&canon) {
493            return Err(Refusal::RootOnRemovableOrCloud);
494        }
495        if !private_to_user(&canon)? {
496            return Err(Refusal::RootNotPrivate);
497        }
498        check_binding(app, &canon)?;
499
500        let suite = Suite::at(canon.join("shared")).map_err(|e| Refusal::Io(e.to_string()))?;
501        Ok(TestRoot {
502            path: canon,
503            suite,
504            app_id: app.id,
505        })
506    }
507
508    impl TestRoot {
509        /// Remove everything inside the root except the runner's dot files (binding and markers),
510        /// after re-verifying the binding. Never follows a symlink out of the root, and nothing
511        /// reachable from this type is a real user location.
512        pub fn reset(&self) -> Result<(), Refusal> {
513            let app = AppId {
514                id: self.app_id,
515                dir_name: self.app_id,
516            };
517            check_binding(&app, &self.path)?;
518            for entry in fs::read_dir(&self.path)? {
519                let entry = entry?;
520                if entry.file_name().to_string_lossy().starts_with('.') {
521                    continue;
522                }
523                let ty = entry.file_type()?;
524                if ty.is_dir() && !ty.is_symlink() {
525                    fs::remove_dir_all(entry.path())?;
526                } else {
527                    fs::remove_file(entry.path())?;
528                }
529            }
530            Ok(())
531        }
532    }
533}