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: `<profile>\AppData\Local\Orthic Labs\shared`, `<profile>` from
125    ///   `GetUserProfileDirectoryW` on the process token. `%USERPROFILE%` and `%LOCALAPPDATA%` are
126    ///   ignored, so a launcher that sets them (a QA home) cannot move the real root.
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: `<profile>\AppData\Local`, with `<profile>` from the process token
287/// ([`account_profile_dir`]), never from `%USERPROFILE%` or `%LOCALAPPDATA%`.
288///
289/// `SHGetKnownFolderPath(FOLDERID_LocalAppData, token = NULL)` is not used: for the current user
290/// it expands the known folder's `%USERPROFILE%\AppData\Local` default against the process
291/// environment, so a launcher (`rightkit-qa` sets `USERPROFILE` for its child) redirects it.
292/// `LocalAppData` is a per-machine folder that Windows does not redirect to a server, so the
293/// profile-relative path is the real one.
294#[cfg(windows)]
295fn platform_default_base() -> Result<PathBuf, SuiteError> {
296    Ok(account_profile_dir()?.join("AppData").join("Local"))
297}
298
299/// Windows: the account's profile directory, from `GetUserProfileDirectoryW` on a
300/// `TOKEN_QUERY` handle of the current process token.
301///
302/// Microsoft documents that function as returning "the path to the root directory of the specified
303/// user's profile" for the user the token represents, and `OpenProcessToken` as a way to obtain
304/// that token (`hToken` "must have TOKEN_QUERY access"):
305/// <https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-getuserprofiledirectoryw>,
306/// <https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-openprocesstoken>.
307/// The lookup is keyed by the token's SID, so no environment variable takes part. The Microsoft
308/// page for `SHGetKnownFolderPath` only promises token-based resolution when a token is passed and
309/// does not say it stops expanding `%USERPROFILE%` from the environment, so that API is not relied on.
310#[cfg(windows)]
311pub(crate) fn account_profile_dir() -> Result<PathBuf, SuiteError> {
312    use std::ffi::{c_void, OsString};
313    use std::os::windows::ffi::OsStringExt;
314
315    const TOKEN_QUERY: u32 = 0x0008;
316    const ERROR_INSUFFICIENT_BUFFER: i32 = 122;
317
318    #[link(name = "kernel32")]
319    unsafe extern "system" {
320        fn GetCurrentProcess() -> *mut c_void;
321        fn CloseHandle(handle: *mut c_void) -> i32;
322    }
323    #[link(name = "advapi32")]
324    unsafe extern "system" {
325        fn OpenProcessToken(process: *mut c_void, access: u32, token: *mut *mut c_void) -> i32;
326    }
327    #[link(name = "userenv")]
328    unsafe extern "system" {
329        fn GetUserProfileDirectoryW(token: *mut c_void, buf: *mut u16, size: *mut u32) -> i32;
330    }
331
332    struct Token(*mut c_void);
333    impl Drop for Token {
334        fn drop(&mut self) {
335            // SAFETY: the handle came from a successful `OpenProcessToken` and is closed once.
336            unsafe { CloseHandle(self.0) };
337        }
338    }
339
340    let mut raw: *mut c_void = std::ptr::null_mut();
341    // SAFETY: the pseudo-handle needs no close; `raw` is a valid out-parameter.
342    if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw) } == 0 {
343        return Err(SuiteError::NoRoot(format!(
344            "the process token is unavailable: {}",
345            io::Error::last_os_error()
346        )));
347    }
348    let token = Token(raw);
349
350    let mut buf = vec![0u16; 260];
351    for _ in 0..2 {
352        let mut size = buf.len() as u32;
353        // SAFETY: `buf` is writable for `size` UTF-16 units and `token` is a live query handle.
354        let ok = unsafe { GetUserProfileDirectoryW(token.0, buf.as_mut_ptr(), &mut size) };
355        if ok != 0 {
356            let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
357            let dir = PathBuf::from(OsString::from_wide(&buf[..len]));
358            return if dir.is_absolute() {
359                Ok(dir)
360            } else {
361                Err(SuiteError::NoRoot(
362                    "the account's profile directory is not absolute".into(),
363                ))
364            };
365        }
366        let error = io::Error::last_os_error();
367        if error.raw_os_error() == Some(ERROR_INSUFFICIENT_BUFFER) && size as usize > buf.len() {
368            // `size` now holds the required length in UTF-16 units, terminator included.
369            buf = vec![0u16; size as usize];
370            continue;
371        }
372        return Err(SuiteError::NoRoot(format!(
373            "the account's profile directory is unavailable: {error}"
374        )));
375    }
376    Err(SuiteError::NoRoot(
377        "the account's profile directory changed size during the lookup".into(),
378    ))
379}
380
381#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
382fn platform_default_base() -> Result<PathBuf, SuiteError> {
383    Err(SuiteError::NoRoot(
384        "no environment-independent per-user root is implemented for this platform".into(),
385    ))
386}
387
388/// The current user's home directory from `getpwuid_r(geteuid())`, ignoring `HOME`.
389#[cfg(any(target_os = "macos", target_os = "linux"))]
390pub(crate) fn passwd_home_dir() -> Result<PathBuf, SuiteError> {
391    use std::ffi::{c_char, c_int, CStr, OsStr};
392    use std::os::unix::ffi::OsStrExt;
393
394    /// `struct passwd` in the layout of each OS (checked against `<pwd.h>`; on macOS also on
395    /// Darwin 27 by calling `getpwuid_r` and reading `pw_dir`). Only `pw_dir` is read.
396    #[repr(C)]
397    #[allow(dead_code)]
398    struct Passwd {
399        pw_name: *mut c_char,
400        pw_passwd: *mut c_char,
401        pw_uid: u32,
402        pw_gid: u32,
403        #[cfg(target_os = "macos")]
404        pw_change: i64,
405        #[cfg(target_os = "macos")]
406        pw_class: *mut c_char,
407        pw_gecos: *mut c_char,
408        pw_dir: *mut c_char,
409        pw_shell: *mut c_char,
410        #[cfg(target_os = "macos")]
411        pw_expire: i64,
412    }
413
414    extern "C" {
415        fn getpwuid_r(
416            uid: u32,
417            pwd: *mut Passwd,
418            buf: *mut c_char,
419            buflen: usize,
420            result: *mut *mut Passwd,
421        ) -> c_int;
422    }
423
424    /// ERANGE is 34 on macOS and Linux.
425    const ERANGE: c_int = 34;
426    let uid = crate::transport::current_uid();
427    let mut buf = vec![0u8; 1024];
428    loop {
429        let mut pwd = std::mem::MaybeUninit::<Passwd>::zeroed();
430        let mut result: *mut Passwd = std::ptr::null_mut();
431        // SAFETY: `pwd` and `buf` are writable for the sizes passed; on success `result` points
432        // at `pwd` and its strings point into `buf`, both alive until the end of this iteration.
433        let rc = unsafe {
434            getpwuid_r(
435                uid,
436                pwd.as_mut_ptr(),
437                buf.as_mut_ptr().cast::<c_char>(),
438                buf.len(),
439                &mut result,
440            )
441        };
442        if rc == ERANGE && buf.len() < (1 << 20) {
443            buf = vec![0u8; buf.len() * 4];
444            continue;
445        }
446        if rc != 0 {
447            return Err(SuiteError::NoRoot(format!(
448                "getpwuid_r failed: {}",
449                io::Error::from_raw_os_error(rc)
450            )));
451        }
452        if result.is_null() {
453            return Err(SuiteError::NoRoot(
454                "the account database has no entry for the current user".into(),
455            ));
456        }
457        // SAFETY: `result` is non-null and points at the `Passwd` that `pwd` holds.
458        let dir = unsafe { (*result).pw_dir };
459        if dir.is_null() {
460            return Err(SuiteError::NoRoot(
461                "the account database has no home directory for the current user".into(),
462            ));
463        }
464        // SAFETY: `pw_dir` is a NUL-terminated string inside `buf`.
465        let bytes = unsafe { CStr::from_ptr(dir) }.to_bytes();
466        return Ok(PathBuf::from(OsStr::from_bytes(bytes)));
467    }
468}
469
470/// The longest service name `host` accepts, in bytes.
471pub const MAX_SERVICE_NAME_LEN: usize = 48;
472
473/// Environment variables the build tool sets on the processes it starts (`run` and `test`). Their
474/// presence means the process is a development run, not an installed app.
475const BUILD_TOOL_ENV: &[&str] = &["CARGO", "CARGO_MANIFEST_DIR"];
476
477/// Refuse a development run that the build tool started against the real suite root (issue #384).
478///
479/// Call it at the start of a test or a dev run that reaches a service. When the build tool's
480/// environment is present and `suite` is neither a [`Suite::per_run_temp`] suite nor under the
481/// system temporary directory, it returns [`SuiteError::Insecure`] and the caller must not
482/// proceed; the fix is an isolated suite (`Suite::per_run_temp` or `RIGHTKIT_SUITE_ROOT` pointing
483/// at a temporary directory). An unresolvable root is already an error from [`Suite::resolve`].
484/// It does nothing for an installed app, which the build tool does not start.
485///
486/// A QA run whose suite sits in a validated test root that is not under the temporary directory
487/// (macOS: the QA home is under the user's home) uses [`refuse_build_tool_real_data_for_mode`].
488pub fn refuse_build_tool_real_data(suite: &Suite) -> Result<(), SuiteError> {
489    refuse_build_tool_real_data_inner(suite, None, build_tool_env_present())
490}
491
492/// [`refuse_build_tool_real_data`] for a run whose [`crate::mode::Mode`] came from
493/// [`crate::mode::decide`]. A suite inside the test root of a [`crate::mode::Mode::Test`] is
494/// accepted: `decide` already proved that root is bound to the app, private and disjoint from the
495/// real per-user data. `Mode::Test` exists only in a `test-mode` build and only `decide` makes
496/// one, so no environment variable or flag can grant this. A [`crate::mode::Mode::Release`], or a
497/// suite outside the test root (a forged or unrelated suite), gets the plain rule.
498pub fn refuse_build_tool_real_data_for_mode(
499    suite: &Suite,
500    mode: &crate::mode::Mode,
501) -> Result<(), SuiteError> {
502    refuse_build_tool_real_data_inner(suite, Some(mode), build_tool_env_present())
503}
504
505fn build_tool_env_present() -> bool {
506    BUILD_TOOL_ENV.iter().any(|k| std::env::var_os(k).is_some())
507}
508
509/// The rule with the build-tool signal and the mode passed in, so tests need no `set_var`.
510fn refuse_build_tool_real_data_inner(
511    suite: &Suite,
512    mode: Option<&crate::mode::Mode>,
513    build_tool: bool,
514) -> Result<(), SuiteError> {
515    if !build_tool || suite._temp.is_some() {
516        return Ok(());
517    }
518    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
519    let root = canon(&suite.root);
520    if root.starts_with(canon(&std::env::temp_dir())) {
521        return Ok(());
522    }
523    if let Some(test) = mode.and_then(crate::mode::Mode::test_root) {
524        if root.starts_with(canon(test.path())) {
525            return Ok(());
526        }
527    }
528    Err(SuiteError::Insecure(format!(
529        "a build-tool run may not use the real suite root {}; use Suite::per_run_temp or an isolated RIGHTKIT_SUITE_ROOT",
530        suite.root.display()
531    )))
532}
533
534/// Whether `s` is a service name `host` accepts: non-empty, at most [`MAX_SERVICE_NAME_LEN`] bytes,
535/// and only ASCII lowercase letters, digits and `-`. This is the single rule `host` enforces.
536pub fn valid_service_name(s: &str) -> bool {
537    !s.is_empty()
538        && s.len() <= MAX_SERVICE_NAME_LEN
539        && s.chars()
540            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
541}
542
543/// Create `dir` (and parents), make the leaf owner-only and prove it, through the
544/// `rightkit-fs` private primitives (one-way daemon -> fs dependency). On Windows this is the
545/// corrected existing-object repair: a pinned no-reparse handle, current-token ownership, a
546/// protected owner-only DACL, then a reverify; a foreign-owned or too-permissive directory is
547/// [`SuiteError::Insecure`], never silently inherited from whatever profile or explicit root
548/// happened to contain it. Unix behaviour is unchanged in effect: owner-only creation, the leaf
549/// narrowed, and a foreign owner refused as `Insecure`.
550pub(crate) fn make_private_dir(dir: &Path) -> Result<(), SuiteError> {
551    #[cfg(unix)]
552    {
553        // Preserve the `Insecure` classification for a foreign-owned existing leaf: the fs
554        // repair would otherwise surface the chmod refusal as plain I/O before the verify
555        // below could name the owner problem.
556        use std::os::unix::fs::MetadataExt;
557        if let Ok(md) = std::fs::symlink_metadata(dir) {
558            if md.uid() != crate::transport::current_uid() {
559                return Err(SuiteError::Insecure(format!(
560                    "{} is not owned by the current user",
561                    dir.display()
562                )));
563            }
564        }
565    }
566    rightkit_fs::private::ensure_private_dir(dir).map_err(suite_fs_error)?;
567    rightkit_fs::private::verify_owner_only(dir).map_err(suite_fs_error)
568}
569
570/// Privacy failures from the fs layer are `Insecure`; plain I/O failures stay `Io`.
571fn suite_fs_error(error: rightkit_fs::FsError) -> SuiteError {
572    match error {
573        rightkit_fs::FsError::Io { source, .. } => SuiteError::Io(source),
574        other => SuiteError::Insecure(other.to_string()),
575    }
576}
577
578#[cfg(test)]
579#[allow(deprecated)] // exercises the deprecated legacy switch
580mod tests {
581    use super::*;
582
583    #[test]
584    fn only_resolve_is_marked_environment_derived() {
585        #[cfg(not(feature = "test-mode"))]
586        {
587            // Release: `resolve` is the canonical root and nothing in it came from the environment.
588            let suite = Suite::resolve().unwrap();
589            assert!(!suite.env_derived());
590            assert_eq!(suite.root(), Suite::per_user_default().unwrap().root());
591        }
592        let dir = tempfile::tempdir().unwrap();
593        assert!(!Suite::at(dir.path()).unwrap().env_derived());
594        assert!(!Suite::per_run_temp("env-flag").unwrap().env_derived());
595        // `resolve` fails only where the environment names no root at all; then there is no
596        // suite to inspect.
597        #[cfg(feature = "test-mode")]
598        if let Ok(suite) = Suite::resolve() {
599            assert!(suite.env_derived());
600            assert!(suite.clone().env_derived(), "the mark survives a clone");
601        }
602    }
603
604    #[cfg(any(target_os = "macos", target_os = "linux"))]
605    #[test]
606    fn the_per_user_default_comes_from_the_account_database_under_orthic_labs_shared() {
607        let suite = Suite::per_user_default().unwrap();
608        assert!(!suite.env_derived());
609        assert!(suite.root().is_absolute());
610        assert!(
611            suite
612                .root()
613                .ends_with(Path::new(SUITE_DIR_NAME).join(SHARED_DIR_NAME)),
614            "{:?}",
615            suite.root()
616        );
617        let home = passwd_home_dir().unwrap();
618        assert!(home.is_absolute());
619        assert!(suite.root().starts_with(&home));
620        // The switch picks the same suite when told to ignore the environment.
621        assert_eq!(
622            Suite::resolve_ignoring_env_overrides(true).unwrap().root(),
623            suite.root()
624        );
625    }
626
627    #[cfg(windows)]
628    #[test]
629    fn the_per_user_default_comes_from_the_known_folder_table_not_the_environment() {
630        let suite = Suite::per_user_default().unwrap();
631        assert!(!suite.env_derived());
632        assert!(suite.root().is_absolute());
633        assert!(
634            suite
635                .root()
636                .ends_with(Path::new(SUITE_DIR_NAME).join(SHARED_DIR_NAME)),
637            "{:?}",
638            suite.root()
639        );
640        let base = platform_default_base().unwrap();
641        assert!(suite.root().starts_with(&base));
642        assert!(base.is_dir(), "{base:?}");
643        assert_eq!(
644            Suite::resolve_ignoring_env_overrides(true).unwrap().root(),
645            suite.root()
646        );
647    }
648
649    #[cfg(windows)]
650    #[test]
651    fn the_account_profile_is_absolute_and_holds_the_default_base() {
652        let profile = account_profile_dir().unwrap();
653        assert!(profile.is_absolute() && profile.is_dir(), "{profile:?}");
654        assert_eq!(
655            platform_default_base().unwrap(),
656            profile.join("AppData").join("Local")
657        );
658    }
659
660    #[test]
661    fn the_build_tool_guard_table() {
662        let temp = Suite::per_run_temp("guard").unwrap();
663        let outside = tempfile::tempdir().unwrap();
664        let outside = std::fs::canonicalize(outside.path()).unwrap();
665        // A real-looking root that is not under the temporary directory: the user's own directory.
666        let home = Suite::at(std::env::current_dir().unwrap()).unwrap();
667        let in_temp = Suite::at(outside.join("suite")).unwrap();
668
669        // No build tool: nothing is refused.
670        assert!(refuse_build_tool_real_data_inner(&home, None, false).is_ok());
671        // Build tool: a per-run temp suite and a suite under the temporary directory pass.
672        assert!(refuse_build_tool_real_data_inner(&temp, None, true).is_ok());
673        assert!(refuse_build_tool_real_data_inner(&in_temp, None, true).is_ok());
674        // Build tool, release, a real-looking root: refused.
675        assert!(matches!(
676            refuse_build_tool_real_data_inner(&home, None, true),
677            Err(SuiteError::Insecure(_))
678        ));
679        assert!(matches!(
680            refuse_build_tool_real_data_inner(&home, Some(&crate::mode::Mode::Release), true),
681            Err(SuiteError::Insecure(_))
682        ));
683    }
684
685    #[test]
686    fn a_validated_test_root_is_accepted_and_a_forged_suite_is_not() {
687        use crate::mode::{Mode, TestRoot};
688        // The macOS case: the QA home is not under the temporary directory (`/private/tmp`).
689        let cwd = std::env::current_dir().unwrap();
690        let qa = cwd.join("qa-home");
691        let inside = Suite::at(qa.join("shared")).unwrap();
692        let mode = Mode::Test(TestRoot::for_test(qa.clone(), inside.clone()));
693        assert!(refuse_build_tool_real_data_inner(&inside, Some(&mode), true).is_ok());
694        // A sibling that merely shares the name prefix, the real root, and the root itself above
695        // the test root are all outside it.
696        for forged in [
697            cwd.join("qa-home-evil").join("shared"),
698            cwd.join("real"),
699            cwd.clone(),
700        ] {
701            let forged = Suite::at(forged).unwrap();
702            assert!(
703                matches!(
704                    refuse_build_tool_real_data_inner(&forged, Some(&mode), true),
705                    Err(SuiteError::Insecure(_))
706                ),
707                "{forged:?}"
708            );
709        }
710        // Without the build tool nothing changes either way.
711        assert!(refuse_build_tool_real_data_inner(&inside, None, false).is_ok());
712        // The same suite without a Test mode is refused.
713        assert!(refuse_build_tool_real_data_inner(&inside, None, true).is_err());
714    }
715}