Skip to main content

rightkit_daemon/
suite.rs

1use std::fmt;
2use std::io;
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Arc;
6
7pub const SUITE_DIR_NAME: &str = "Orthic Labs";
8/// The shared-data subdirectory under [`SUITE_DIR_NAME`]. HeardRight, ScrapeRight and
9/// CutRight already root their shared model store and speech discovery here
10/// (`.../Orthic Labs/shared`), so the one convention adopts it.
11pub const SHARED_DIR_NAME: &str = "shared";
12/// Overrides the platform default (tests, isolated dev profiles). Must be absolute.
13/// `RIGHT_SUITE_DIR` and `ORTHIC_SHARED_DIR` are honoured as legacy aliases.
14pub const ENV_SUITE_ROOT: &str = "RIGHTKIT_SUITE_ROOT";
15
16#[derive(Debug)]
17pub enum SuiteError {
18    /// No absolute app-data root could be determined. Never resolved to the cwd.
19    NoRoot(String),
20    NotAbsolute(PathBuf),
21    /// A directory that must be private to the current user is not.
22    Insecure(String),
23    Io(io::Error),
24}
25
26impl fmt::Display for SuiteError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            SuiteError::NoRoot(s) => write!(
30                f,
31                "no app-data root: {s} (refusing to fall back to the working directory)"
32            ),
33            SuiteError::NotAbsolute(p) => write!(f, "suite root must be absolute: {}", p.display()),
34            SuiteError::Insecure(s) => write!(f, "insecure service directory: {s}"),
35            SuiteError::Io(e) => write!(f, "{e}"),
36        }
37    }
38}
39impl std::error::Error for SuiteError {}
40impl From<io::Error> for SuiteError {
41    fn from(e: io::Error) -> Self {
42        SuiteError::Io(e)
43    }
44}
45
46struct TempGuard(PathBuf);
47impl Drop for TempGuard {
48    fn drop(&mut self) {
49        let _ = std::fs::remove_dir_all(&self.0);
50    }
51}
52
53/// The per-user suite directory that holds `runtime/<service>/service.json`
54/// and other shared state. Always absolute.
55#[derive(Clone)]
56pub struct Suite {
57    root: PathBuf,
58    _temp: Option<Arc<TempGuard>>,
59    /// The root was derived from the environment ([`Suite::resolve`]): an override variable, or
60    /// `HOME`/`XDG_DATA_HOME`/`LOCALAPPDATA`. Whoever launches a process controls those.
61    env_derived: bool,
62}
63
64impl fmt::Debug for Suite {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "Suite({})", self.root.display())
67    }
68}
69
70fn absolute(p: PathBuf) -> Result<PathBuf, SuiteError> {
71    if !p.is_absolute() || p.components().any(|c| matches!(c, Component::ParentDir)) {
72        return Err(SuiteError::NotAbsolute(p));
73    }
74    Ok(p)
75}
76
77#[cfg(feature = "test-mode")]
78fn env_nonempty(k: &str) -> Option<String> {
79    std::env::var(k).ok().filter(|v| !v.trim().is_empty())
80}
81
82impl Suite {
83    /// `RIGHTKIT_SUITE_ROOT` if set, else the platform per-user default
84    /// (`~/Library/Application Support/Orthic Labs/shared`, `%LOCALAPPDATA%\Orthic Labs\shared`).
85    /// Errors, never guesses, when the environment does not name an absolute root.
86    ///
87    /// Everything here comes from the environment, which is chosen by whoever launches the
88    /// process: `RIGHTKIT_SUITE_ROOT`, `RIGHT_SUITE_DIR` and `ORTHIC_SHARED_DIR` redirect the root
89    /// anywhere, and `HOME` (macOS, Linux), `XDG_DATA_HOME` (Linux) and `LOCALAPPDATA` (Windows)
90    /// redirect the default. That is right for tests and isolated dev profiles and wrong for a
91    /// release build that talks to its installed app: use [`Suite::per_user_default`] or
92    /// [`Suite::resolve_ignoring_env_overrides`] there. The returned suite reports
93    /// [`Suite::env_derived`] `true`.
94    ///
95    /// **Release builds (without the `test-mode` feature) read no override switch at all**: this
96    /// returns [`Suite::per_user_default`] (account database / known-folder table), reports
97    /// [`Suite::env_derived`] `false` because nothing came from the environment, and the binary
98    /// contains no code that reads the variables above. The environment paragraph applies only
99    /// to a build that compiles `test-mode`.
100    pub fn resolve() -> Result<Suite, SuiteError> {
101        #[cfg(feature = "test-mode")]
102        {
103            let mut suite = Self::resolve_from_env()?;
104            suite.env_derived = true;
105            Ok(suite)
106        }
107        #[cfg(not(feature = "test-mode"))]
108        {
109            Self::per_user_default()
110        }
111    }
112
113    /// The per-user default suite root from the operating system's account database, never from
114    /// the environment: no `RIGHTKIT_SUITE_ROOT`/`RIGHT_SUITE_DIR`/`ORTHIC_SHARED_DIR`, no `HOME`,
115    /// no `XDG_DATA_HOME`, no `LOCALAPPDATA`. This is what a release build of an app or of its
116    /// command-line engine should use, on the host side as well as the client side, so that a
117    /// launcher cannot point either at a different directory.
118    ///
119    /// * macOS: `<home>/Library/Application Support/Orthic Labs/shared`, `<home>` from
120    ///   `getpwuid_r(geteuid())`.
121    /// * Linux: `<home>/.local/share/Orthic Labs/shared`. `XDG_DATA_HOME` is an environment
122    ///   variable and is ignored, so a user who relocated it must host with this function too, or
123    ///   host and hardened client disagree on the root.
124    /// * Windows: `<local app data>\Orthic Labs\shared`, `<local app data>` from
125    ///   `SHGetKnownFolderPath(FOLDERID_LocalAppData)`, which reads the profile's registry, not
126    ///   `%LOCALAPPDATA%`.
127    /// * Other Unix: not implemented; fails closed.
128    pub fn per_user_default() -> Result<Suite, SuiteError> {
129        let base = platform_default_base()?;
130        Suite::at(absolute(base)?.join(SUITE_DIR_NAME).join(SHARED_DIR_NAME))
131    }
132
133    /// One switch for hosts and clients: with `ignore_env_overrides` the root is
134    /// [`Suite::per_user_default`], otherwise [`Suite::resolve`]. A release build passes `true`,
135    /// a QA build `false`; wire the flag to a cargo feature or `cfg!(debug_assertions)` in the
136    /// app, not to the environment.
137    ///
138    /// **Deprecated.** This is one boolean the caller must remember to pass; nothing checks that it
139    /// did, and a build that passes `false` still contains the code that reads the override
140    /// variables. [`crate::mode::roots`] with a [`crate::mode::Mode`] from [`crate::mode::decide`]
141    /// replaces it: a release build (without the `test-mode` feature) reads no override selector at
142    /// all, and a test-capable build fails closed on a validated test root. Kept, unchanged, for
143    /// existing HeardRight and CodeRight callers.
144    #[deprecated(
145        since = "0.1.6",
146        note = "use rightkit_daemon::mode::{decide, roots}: release builds read no override selector, test builds fail closed on a validated TestRoot"
147    )]
148    ///
149    /// Without the `test-mode` feature the flag is ignored: this is an alias of
150    /// [`Suite::per_user_default`] and reads no environment.
151    pub fn resolve_ignoring_env_overrides(ignore_env_overrides: bool) -> Result<Suite, SuiteError> {
152        #[cfg(feature = "test-mode")]
153        {
154            if ignore_env_overrides {
155                Self::per_user_default()
156            } else {
157                Self::resolve()
158            }
159        }
160        #[cfg(not(feature = "test-mode"))]
161        {
162            let _ = ignore_env_overrides;
163            Self::per_user_default()
164        }
165    }
166
167    /// Whether the root was derived from the environment ([`Suite::resolve`]). A suite from
168    /// [`Suite::at`], [`Suite::per_user_default`] or [`Suite::per_run_temp`] is not.
169    pub fn env_derived(&self) -> bool {
170        self.env_derived
171    }
172
173    #[cfg(feature = "test-mode")]
174    fn resolve_from_env() -> Result<Suite, SuiteError> {
175        // Legacy aliases used by existing apps' isolated test runs (HeardRight e2e_engine).
176        for key in [ENV_SUITE_ROOT, "RIGHT_SUITE_DIR", "ORTHIC_SHARED_DIR"] {
177            if let Some(v) = env_nonempty(key) {
178                return Suite::at(v);
179            }
180        }
181        let base = if cfg!(windows) {
182            env_nonempty("LOCALAPPDATA")
183                .map(PathBuf::from)
184                .ok_or_else(|| SuiteError::NoRoot("LOCALAPPDATA is not set".into()))?
185        } else if cfg!(target_os = "macos") {
186            env_nonempty("HOME")
187                .map(|h| PathBuf::from(h).join("Library").join("Application Support"))
188                .ok_or_else(|| SuiteError::NoRoot("HOME is not set".into()))?
189        } else {
190            env_nonempty("XDG_DATA_HOME")
191                .map(PathBuf::from)
192                .or_else(|| {
193                    env_nonempty("HOME").map(|h| PathBuf::from(h).join(".local").join("share"))
194                })
195                .ok_or_else(|| SuiteError::NoRoot("neither XDG_DATA_HOME nor HOME is set".into()))?
196        };
197        Suite::at(absolute(base)?.join(SUITE_DIR_NAME).join(SHARED_DIR_NAME))
198    }
199
200    /// An explicit absolute root (isolated dev profiles, tests).
201    pub fn at(root: impl Into<PathBuf>) -> Result<Suite, SuiteError> {
202        Ok(Suite {
203            root: absolute(root.into())?,
204            _temp: None,
205            env_derived: false,
206        })
207    }
208
209    /// A fresh private directory under the OS temp dir, deleted when the last
210    /// clone is dropped. The explicit alternative to a missing app-data root.
211    pub fn per_run_temp(label: &str) -> Result<Suite, SuiteError> {
212        static N: AtomicU64 = AtomicU64::new(0);
213        let label: String = label
214            .chars()
215            .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
216            .take(24)
217            .collect();
218        let nanos = std::time::SystemTime::now()
219            .duration_since(std::time::UNIX_EPOCH)
220            .map(|d| d.as_nanos())
221            .unwrap_or(0);
222        let dir = absolute(std::env::temp_dir())?.join(format!(
223            "rk-{label}-{}-{}-{nanos:x}",
224            std::process::id(),
225            N.fetch_add(1, Ordering::SeqCst)
226        ));
227        make_private_dir(&dir)?;
228        Ok(Suite {
229            root: dir.clone(),
230            _temp: Some(Arc::new(TempGuard(dir))),
231            env_derived: false,
232        })
233    }
234
235    pub fn root(&self) -> &Path {
236        &self.root
237    }
238
239    /// `<suite>/runtime/<service>`.
240    pub fn service_dir(&self, service: &str) -> PathBuf {
241        self.root.join("runtime").join(service)
242    }
243
244    /// A private (owner-only) directory under `<suite>/runtime/<service>`, e.g. `shots`.
245    pub fn private_service_subdir(&self, service: &str, name: &str) -> Result<PathBuf, SuiteError> {
246        let d = self.service_dir(service).join(name);
247        make_private_dir(&d)?;
248        Ok(d)
249    }
250
251    /// The directory that holds per-app roots: the sibling of `shared` when the suite root is the
252    /// canonical `.../Orthic Labs/shared`, otherwise `<root>/apps`, so an isolated test or
253    /// override root keeps every app root inside itself.
254    pub(crate) fn app_parent(&self) -> PathBuf {
255        let shared = self.root.file_name().is_some_and(|n| n == SHARED_DIR_NAME);
256        let parent = self.root.parent();
257        match parent {
258            Some(p) if shared && p.file_name().is_some_and(|n| n == SUITE_DIR_NAME) => {
259                p.to_path_buf()
260            }
261            _ => self.root.join("apps"),
262        }
263    }
264
265    /// `<suite>/<area>`: shared app data that must not be relative to the cwd
266    /// (screenshots, evidence, models). Created private to the user.
267    pub fn data_dir(&self, area: &str) -> Result<PathBuf, SuiteError> {
268        let d = self.root.join(area);
269        make_private_dir(&d)?;
270        Ok(d)
271    }
272}
273
274/// The base directory (before `Orthic Labs/shared`) of the per-user default, from the account
275/// database and never from the environment.
276#[cfg(any(target_os = "macos", target_os = "linux"))]
277fn platform_default_base() -> Result<PathBuf, SuiteError> {
278    let home = passwd_home_dir()?;
279    Ok(if cfg!(target_os = "macos") {
280        home.join("Library").join("Application Support")
281    } else {
282        home.join(".local").join("share")
283    })
284}
285
286/// Windows: the profile's local application data folder from the shell's known-folder table
287/// (`SHGetKnownFolderPath(FOLDERID_LocalAppData)`), which reads the profile's registry and never
288/// `%LOCALAPPDATA%`.
289#[cfg(windows)]
290fn platform_default_base() -> Result<PathBuf, SuiteError> {
291    use std::ffi::{c_void, OsString};
292    use std::os::windows::ffi::OsStringExt;
293
294    #[repr(C)]
295    #[allow(dead_code)]
296    struct Guid(u32, u16, u16, [u8; 8]);
297    /// `FOLDERID_LocalAppData`, {F1B32785-6FBA-4FCF-9D55-7B8E7F157091}.
298    const LOCAL_APP_DATA: Guid = Guid(
299        0xF1B3_2785,
300        0x6FBA,
301        0x4FCF,
302        [0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91],
303    );
304
305    #[link(name = "shell32")]
306    unsafe extern "system" {
307        fn SHGetKnownFolderPath(
308            id: *const Guid,
309            flags: u32,
310            token: *mut c_void,
311            path: *mut *mut u16,
312        ) -> i32;
313    }
314    #[link(name = "ole32")]
315    unsafe extern "system" {
316        fn CoTaskMemFree(memory: *mut c_void);
317    }
318
319    let mut text: *mut u16 = std::ptr::null_mut();
320    // SAFETY: a valid folder id and out-parameter; a null token means the current user.
321    let status =
322        unsafe { SHGetKnownFolderPath(&LOCAL_APP_DATA, 0, std::ptr::null_mut(), &mut text) };
323    let folder = if status >= 0 && !text.is_null() {
324        let mut len = 0;
325        // SAFETY: on success the function returned a NUL-terminated string.
326        while unsafe { *text.add(len) } != 0 {
327            len += 1;
328        }
329        // SAFETY: `len` units were just read from this allocation.
330        Some(PathBuf::from(OsString::from_wide(unsafe {
331            std::slice::from_raw_parts(text, len)
332        })))
333    } else {
334        None
335    };
336    // SAFETY: the caller frees the string in every case; null is allowed.
337    unsafe { CoTaskMemFree(text.cast()) };
338    folder.filter(|path| path.is_absolute()).ok_or_else(|| {
339        SuiteError::NoRoot(format!(
340            "the profile's local application data folder is unavailable (SHGetKnownFolderPath: 0x{status:08X})"
341        ))
342    })
343}
344
345#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
346fn platform_default_base() -> Result<PathBuf, SuiteError> {
347    Err(SuiteError::NoRoot(
348        "no environment-independent per-user root is implemented for this platform".into(),
349    ))
350}
351
352/// The current user's home directory from `getpwuid_r(geteuid())`, ignoring `HOME`.
353#[cfg(any(target_os = "macos", target_os = "linux"))]
354pub(crate) fn passwd_home_dir() -> Result<PathBuf, SuiteError> {
355    use std::ffi::{c_char, c_int, CStr, OsStr};
356    use std::os::unix::ffi::OsStrExt;
357
358    /// `struct passwd` in the layout of each OS (checked against `<pwd.h>`; on macOS also on
359    /// Darwin 27 by calling `getpwuid_r` and reading `pw_dir`). Only `pw_dir` is read.
360    #[repr(C)]
361    #[allow(dead_code)]
362    struct Passwd {
363        pw_name: *mut c_char,
364        pw_passwd: *mut c_char,
365        pw_uid: u32,
366        pw_gid: u32,
367        #[cfg(target_os = "macos")]
368        pw_change: i64,
369        #[cfg(target_os = "macos")]
370        pw_class: *mut c_char,
371        pw_gecos: *mut c_char,
372        pw_dir: *mut c_char,
373        pw_shell: *mut c_char,
374        #[cfg(target_os = "macos")]
375        pw_expire: i64,
376    }
377
378    extern "C" {
379        fn getpwuid_r(
380            uid: u32,
381            pwd: *mut Passwd,
382            buf: *mut c_char,
383            buflen: usize,
384            result: *mut *mut Passwd,
385        ) -> c_int;
386    }
387
388    /// ERANGE is 34 on macOS and Linux.
389    const ERANGE: c_int = 34;
390    let uid = crate::transport::current_uid();
391    let mut buf = vec![0u8; 1024];
392    loop {
393        let mut pwd = std::mem::MaybeUninit::<Passwd>::zeroed();
394        let mut result: *mut Passwd = std::ptr::null_mut();
395        // SAFETY: `pwd` and `buf` are writable for the sizes passed; on success `result` points
396        // at `pwd` and its strings point into `buf`, both alive until the end of this iteration.
397        let rc = unsafe {
398            getpwuid_r(
399                uid,
400                pwd.as_mut_ptr(),
401                buf.as_mut_ptr().cast::<c_char>(),
402                buf.len(),
403                &mut result,
404            )
405        };
406        if rc == ERANGE && buf.len() < (1 << 20) {
407            buf = vec![0u8; buf.len() * 4];
408            continue;
409        }
410        if rc != 0 {
411            return Err(SuiteError::NoRoot(format!(
412                "getpwuid_r failed: {}",
413                io::Error::from_raw_os_error(rc)
414            )));
415        }
416        if result.is_null() {
417            return Err(SuiteError::NoRoot(
418                "the account database has no entry for the current user".into(),
419            ));
420        }
421        // SAFETY: `result` is non-null and points at the `Passwd` that `pwd` holds.
422        let dir = unsafe { (*result).pw_dir };
423        if dir.is_null() {
424            return Err(SuiteError::NoRoot(
425                "the account database has no home directory for the current user".into(),
426            ));
427        }
428        // SAFETY: `pw_dir` is a NUL-terminated string inside `buf`.
429        let bytes = unsafe { CStr::from_ptr(dir) }.to_bytes();
430        return Ok(PathBuf::from(OsStr::from_bytes(bytes)));
431    }
432}
433
434/// The longest service name `host` accepts, in bytes.
435pub const MAX_SERVICE_NAME_LEN: usize = 48;
436
437/// Environment variables the build tool sets on the processes it starts (`run` and `test`). Their
438/// presence means the process is a development run, not an installed app.
439const BUILD_TOOL_ENV: &[&str] = &["CARGO", "CARGO_MANIFEST_DIR"];
440
441/// Refuse a development run that the build tool started against the real suite root (issue #384).
442///
443/// Call it at the start of a test or a dev run that reaches a service. When the build tool's
444/// environment is present and `suite` is neither a [`Suite::per_run_temp`] suite nor under the
445/// system temporary directory, it returns [`SuiteError::Insecure`] and the caller must not
446/// proceed; the fix is an isolated suite (`Suite::per_run_temp` or `RIGHTKIT_SUITE_ROOT` pointing
447/// at a temporary directory). An unresolvable root is already an error from [`Suite::resolve`].
448/// It does nothing for an installed app, which the build tool does not start.
449pub fn refuse_build_tool_real_data(suite: &Suite) -> Result<(), SuiteError> {
450    if !BUILD_TOOL_ENV.iter().any(|k| std::env::var_os(k).is_some()) {
451        return Ok(());
452    }
453    if suite._temp.is_some() {
454        return Ok(());
455    }
456    let temp = std::env::temp_dir();
457    let temp = std::fs::canonicalize(&temp).unwrap_or(temp);
458    let root = std::fs::canonicalize(&suite.root).unwrap_or_else(|_| suite.root.clone());
459    if root.starts_with(&temp) {
460        return Ok(());
461    }
462    Err(SuiteError::Insecure(format!(
463        "a build-tool run may not use the real suite root {}; use Suite::per_run_temp or an isolated RIGHTKIT_SUITE_ROOT",
464        suite.root.display()
465    )))
466}
467
468/// Whether `s` is a service name `host` accepts: non-empty, at most [`MAX_SERVICE_NAME_LEN`] bytes,
469/// and only ASCII lowercase letters, digits and `-`. This is the single rule `host` enforces.
470pub fn valid_service_name(s: &str) -> bool {
471    !s.is_empty()
472        && s.len() <= MAX_SERVICE_NAME_LEN
473        && s.chars()
474            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
475}
476
477/// Create `dir` (and parents) and make the leaf owner-only (0700 on Unix;
478/// Windows ACLs inherit from the per-user profile).
479pub(crate) fn make_private_dir(dir: &Path) -> Result<(), SuiteError> {
480    std::fs::create_dir_all(dir)?;
481    #[cfg(unix)]
482    {
483        use std::os::unix::fs::{MetadataExt, PermissionsExt};
484        let md = std::fs::metadata(dir)?;
485        if md.uid() != crate::transport::current_uid() {
486            return Err(SuiteError::Insecure(format!(
487                "{} is not owned by the current user",
488                dir.display()
489            )));
490        }
491        if md.permissions().mode() & 0o077 != 0 {
492            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
493        }
494    }
495    Ok(())
496}
497
498#[cfg(test)]
499#[allow(deprecated)] // exercises the deprecated legacy switch
500mod tests {
501    use super::*;
502
503    #[test]
504    fn only_resolve_is_marked_environment_derived() {
505        #[cfg(not(feature = "test-mode"))]
506        {
507            // Release: `resolve` is the canonical root and nothing in it came from the environment.
508            let suite = Suite::resolve().unwrap();
509            assert!(!suite.env_derived());
510            assert_eq!(suite.root(), Suite::per_user_default().unwrap().root());
511        }
512        let dir = tempfile::tempdir().unwrap();
513        assert!(!Suite::at(dir.path()).unwrap().env_derived());
514        assert!(!Suite::per_run_temp("env-flag").unwrap().env_derived());
515        // `resolve` fails only where the environment names no root at all; then there is no
516        // suite to inspect.
517        #[cfg(feature = "test-mode")]
518        if let Ok(suite) = Suite::resolve() {
519            assert!(suite.env_derived());
520            assert!(suite.clone().env_derived(), "the mark survives a clone");
521        }
522    }
523
524    #[cfg(any(target_os = "macos", target_os = "linux"))]
525    #[test]
526    fn the_per_user_default_comes_from_the_account_database_under_orthic_labs_shared() {
527        let suite = Suite::per_user_default().unwrap();
528        assert!(!suite.env_derived());
529        assert!(suite.root().is_absolute());
530        assert!(
531            suite
532                .root()
533                .ends_with(Path::new(SUITE_DIR_NAME).join(SHARED_DIR_NAME)),
534            "{:?}",
535            suite.root()
536        );
537        let home = passwd_home_dir().unwrap();
538        assert!(home.is_absolute());
539        assert!(suite.root().starts_with(&home));
540        // The switch picks the same suite when told to ignore the environment.
541        assert_eq!(
542            Suite::resolve_ignoring_env_overrides(true).unwrap().root(),
543            suite.root()
544        );
545    }
546
547    #[cfg(windows)]
548    #[test]
549    fn the_per_user_default_comes_from_the_known_folder_table_not_the_environment() {
550        let suite = Suite::per_user_default().unwrap();
551        assert!(!suite.env_derived());
552        assert!(suite.root().is_absolute());
553        assert!(
554            suite
555                .root()
556                .ends_with(Path::new(SUITE_DIR_NAME).join(SHARED_DIR_NAME)),
557            "{:?}",
558            suite.root()
559        );
560        let base = platform_default_base().unwrap();
561        assert!(suite.root().starts_with(&base));
562        assert!(base.is_dir(), "{base:?}");
563        assert_eq!(
564            Suite::resolve_ignoring_env_overrides(true).unwrap().root(),
565            suite.root()
566        );
567    }
568}