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
77impl Suite {
78    /// The real per-user suite root, always [`Suite::per_user_default`]: no environment variable
79    /// (`RIGHTKIT_SUITE_ROOT`, `HOME`, `LOCALAPPDATA` or any other) redirects it, in any build.
80    /// There is no test mode (Adrian, 2026-11-03: "dev mode, is mine ... real data. snapshot and
81    /// restore."); QA protects the real data with the `rightkit-qa` snapshot and restore.
82    pub fn resolve() -> Result<Suite, SuiteError> {
83        Self::per_user_default()
84    }
85
86    /// The per-user default suite root from the operating system's account database, never from
87    /// the environment: no `RIGHTKIT_SUITE_ROOT`/`RIGHT_SUITE_DIR`/`ORTHIC_SHARED_DIR`, no `HOME`,
88    /// no `XDG_DATA_HOME`, no `LOCALAPPDATA`. This is what a release build of an app or of its
89    /// command-line engine should use, on the host side as well as the client side, so that a
90    /// launcher cannot point either at a different directory.
91    ///
92    /// * macOS: `<home>/Library/Application Support/Orthic Labs/shared`, `<home>` from
93    ///   `getpwuid_r(geteuid())`.
94    /// * Linux: `<home>/.local/share/Orthic Labs/shared`. `XDG_DATA_HOME` is an environment
95    ///   variable and is ignored, so a user who relocated it must host with this function too, or
96    ///   host and hardened client disagree on the root.
97    /// * Windows: `<profile>\AppData\Local\Orthic Labs\shared`, `<profile>` from
98    ///   `GetUserProfileDirectoryW` on the process token. `%USERPROFILE%` and `%LOCALAPPDATA%` are
99    ///   ignored, so a launcher that sets them (a QA home) cannot move the real root.
100    /// * Other Unix: not implemented; fails closed.
101    pub fn per_user_default() -> Result<Suite, SuiteError> {
102        let base = platform_default_base()?;
103        Suite::at(absolute(base)?.join(SUITE_DIR_NAME).join(SHARED_DIR_NAME))
104    }
105
106    /// Kept for existing callers: the flag is ignored, because no build reads an override
107    /// variable any more. An alias of [`Suite::per_user_default`].
108    #[deprecated(since = "0.1.6", note = "use rightkit_daemon::mode::{decide, roots}")]
109    pub fn resolve_ignoring_env_overrides(ignore_env_overrides: bool) -> Result<Suite, SuiteError> {
110        let _ = ignore_env_overrides;
111        Self::per_user_default()
112    }
113
114    /// Whether the root was derived from the environment. Always `false`: no constructor reads one.
115    pub fn env_derived(&self) -> bool {
116        self.env_derived
117    }
118
119    /// An explicit absolute root (isolated dev profiles, tests).
120    pub fn at(root: impl Into<PathBuf>) -> Result<Suite, SuiteError> {
121        Ok(Suite {
122            root: absolute(root.into())?,
123            _temp: None,
124            env_derived: false,
125        })
126    }
127
128    /// A fresh private directory under the OS temp dir, deleted when the last
129    /// clone is dropped. The explicit alternative to a missing app-data root.
130    pub fn per_run_temp(label: &str) -> Result<Suite, SuiteError> {
131        static N: AtomicU64 = AtomicU64::new(0);
132        let label: String = label
133            .chars()
134            .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
135            .take(24)
136            .collect();
137        let nanos = std::time::SystemTime::now()
138            .duration_since(std::time::UNIX_EPOCH)
139            .map(|d| d.as_nanos())
140            .unwrap_or(0);
141        let dir = absolute(std::env::temp_dir())?.join(format!(
142            "rk-{label}-{}-{}-{nanos:x}",
143            std::process::id(),
144            N.fetch_add(1, Ordering::SeqCst)
145        ));
146        make_private_dir(&dir)?;
147        Ok(Suite {
148            root: dir.clone(),
149            _temp: Some(Arc::new(TempGuard(dir))),
150            env_derived: false,
151        })
152    }
153
154    pub fn root(&self) -> &Path {
155        &self.root
156    }
157
158    /// `<suite>/runtime/<service>`.
159    pub fn service_dir(&self, service: &str) -> PathBuf {
160        self.root.join("runtime").join(service)
161    }
162
163    /// A private (owner-only) directory under `<suite>/runtime/<service>`, e.g. `shots`.
164    pub fn private_service_subdir(&self, service: &str, name: &str) -> Result<PathBuf, SuiteError> {
165        let d = self.service_dir(service).join(name);
166        make_private_dir(&d)?;
167        Ok(d)
168    }
169
170    /// The directory that holds per-app roots: the sibling of `shared` when the suite root is the
171    /// canonical `.../Orthic Labs/shared`, otherwise `<root>/apps`, so an isolated test or
172    /// override root keeps every app root inside itself.
173    pub(crate) fn app_parent(&self) -> PathBuf {
174        let shared = self.root.file_name().is_some_and(|n| n == SHARED_DIR_NAME);
175        let parent = self.root.parent();
176        match parent {
177            Some(p) if shared && p.file_name().is_some_and(|n| n == SUITE_DIR_NAME) => {
178                p.to_path_buf()
179            }
180            _ => self.root.join("apps"),
181        }
182    }
183
184    /// `<suite>/<area>`: shared app data that must not be relative to the cwd
185    /// (screenshots, evidence, models). Created private to the user.
186    pub fn data_dir(&self, area: &str) -> Result<PathBuf, SuiteError> {
187        let d = self.root.join(area);
188        make_private_dir(&d)?;
189        Ok(d)
190    }
191}
192
193/// The base directory (before `Orthic Labs/shared`) of the per-user default, from the account
194/// database and never from the environment.
195#[cfg(any(target_os = "macos", target_os = "linux"))]
196fn platform_default_base() -> Result<PathBuf, SuiteError> {
197    let home = passwd_home_dir()?;
198    Ok(if cfg!(target_os = "macos") {
199        home.join("Library").join("Application Support")
200    } else {
201        home.join(".local").join("share")
202    })
203}
204
205/// Windows: `<profile>\AppData\Local`, with `<profile>` from the process token
206/// ([`account_profile_dir`]), never from `%USERPROFILE%` or `%LOCALAPPDATA%`.
207///
208/// `SHGetKnownFolderPath(FOLDERID_LocalAppData, token = NULL)` is not used: for the current user
209/// it expands the known folder's `%USERPROFILE%\AppData\Local` default against the process
210/// environment, so a launcher (`rightkit-qa` sets `USERPROFILE` for its child) redirects it.
211/// `LocalAppData` is a per-machine folder that Windows does not redirect to a server, so the
212/// profile-relative path is the real one.
213#[cfg(windows)]
214fn platform_default_base() -> Result<PathBuf, SuiteError> {
215    Ok(account_profile_dir()?.join("AppData").join("Local"))
216}
217
218/// Windows: the account's profile directory, from `GetUserProfileDirectoryW` on a
219/// `TOKEN_QUERY` handle of the current process token.
220///
221/// Microsoft documents that function as returning "the path to the root directory of the specified
222/// user's profile" for the user the token represents, and `OpenProcessToken` as a way to obtain
223/// that token (`hToken` "must have TOKEN_QUERY access"):
224/// <https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-getuserprofiledirectoryw>,
225/// <https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-openprocesstoken>.
226/// The lookup is keyed by the token's SID, so no environment variable takes part. The Microsoft
227/// page for `SHGetKnownFolderPath` only promises token-based resolution when a token is passed and
228/// does not say it stops expanding `%USERPROFILE%` from the environment, so that API is not relied on.
229#[cfg(windows)]
230pub(crate) fn account_profile_dir() -> Result<PathBuf, SuiteError> {
231    use std::ffi::{c_void, OsString};
232    use std::os::windows::ffi::OsStringExt;
233
234    const TOKEN_QUERY: u32 = 0x0008;
235    const ERROR_INSUFFICIENT_BUFFER: i32 = 122;
236
237    #[link(name = "kernel32")]
238    unsafe extern "system" {
239        fn GetCurrentProcess() -> *mut c_void;
240        fn CloseHandle(handle: *mut c_void) -> i32;
241    }
242    #[link(name = "advapi32")]
243    unsafe extern "system" {
244        fn OpenProcessToken(process: *mut c_void, access: u32, token: *mut *mut c_void) -> i32;
245    }
246    #[link(name = "userenv")]
247    unsafe extern "system" {
248        fn GetUserProfileDirectoryW(token: *mut c_void, buf: *mut u16, size: *mut u32) -> i32;
249    }
250
251    struct Token(*mut c_void);
252    impl Drop for Token {
253        fn drop(&mut self) {
254            // SAFETY: the handle came from a successful `OpenProcessToken` and is closed once.
255            unsafe { CloseHandle(self.0) };
256        }
257    }
258
259    let mut raw: *mut c_void = std::ptr::null_mut();
260    // SAFETY: the pseudo-handle needs no close; `raw` is a valid out-parameter.
261    if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw) } == 0 {
262        return Err(SuiteError::NoRoot(format!(
263            "the process token is unavailable: {}",
264            io::Error::last_os_error()
265        )));
266    }
267    let token = Token(raw);
268
269    let mut buf = vec![0u16; 260];
270    for _ in 0..2 {
271        let mut size = buf.len() as u32;
272        // SAFETY: `buf` is writable for `size` UTF-16 units and `token` is a live query handle.
273        let ok = unsafe { GetUserProfileDirectoryW(token.0, buf.as_mut_ptr(), &mut size) };
274        if ok != 0 {
275            let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
276            let dir = PathBuf::from(OsString::from_wide(&buf[..len]));
277            return if dir.is_absolute() {
278                Ok(dir)
279            } else {
280                Err(SuiteError::NoRoot(
281                    "the account's profile directory is not absolute".into(),
282                ))
283            };
284        }
285        let error = io::Error::last_os_error();
286        if error.raw_os_error() == Some(ERROR_INSUFFICIENT_BUFFER) && size as usize > buf.len() {
287            // `size` now holds the required length in UTF-16 units, terminator included.
288            buf = vec![0u16; size as usize];
289            continue;
290        }
291        return Err(SuiteError::NoRoot(format!(
292            "the account's profile directory is unavailable: {error}"
293        )));
294    }
295    Err(SuiteError::NoRoot(
296        "the account's profile directory changed size during the lookup".into(),
297    ))
298}
299
300#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
301fn platform_default_base() -> Result<PathBuf, SuiteError> {
302    Err(SuiteError::NoRoot(
303        "no environment-independent per-user root is implemented for this platform".into(),
304    ))
305}
306
307/// The current user's home directory from `getpwuid_r(geteuid())`, ignoring `HOME`.
308#[cfg(any(target_os = "macos", target_os = "linux"))]
309pub(crate) fn passwd_home_dir() -> Result<PathBuf, SuiteError> {
310    use std::ffi::{c_char, c_int, CStr, OsStr};
311    use std::os::unix::ffi::OsStrExt;
312
313    /// `struct passwd` in the layout of each OS (checked against `<pwd.h>`; on macOS also on
314    /// Darwin 27 by calling `getpwuid_r` and reading `pw_dir`). Only `pw_dir` is read.
315    #[repr(C)]
316    #[allow(dead_code)]
317    struct Passwd {
318        pw_name: *mut c_char,
319        pw_passwd: *mut c_char,
320        pw_uid: u32,
321        pw_gid: u32,
322        #[cfg(target_os = "macos")]
323        pw_change: i64,
324        #[cfg(target_os = "macos")]
325        pw_class: *mut c_char,
326        pw_gecos: *mut c_char,
327        pw_dir: *mut c_char,
328        pw_shell: *mut c_char,
329        #[cfg(target_os = "macos")]
330        pw_expire: i64,
331    }
332
333    extern "C" {
334        fn getpwuid_r(
335            uid: u32,
336            pwd: *mut Passwd,
337            buf: *mut c_char,
338            buflen: usize,
339            result: *mut *mut Passwd,
340        ) -> c_int;
341    }
342
343    /// ERANGE is 34 on macOS and Linux.
344    const ERANGE: c_int = 34;
345    let uid = crate::transport::current_uid();
346    let mut buf = vec![0u8; 1024];
347    loop {
348        let mut pwd = std::mem::MaybeUninit::<Passwd>::zeroed();
349        let mut result: *mut Passwd = std::ptr::null_mut();
350        // SAFETY: `pwd` and `buf` are writable for the sizes passed; on success `result` points
351        // at `pwd` and its strings point into `buf`, both alive until the end of this iteration.
352        let rc = unsafe {
353            getpwuid_r(
354                uid,
355                pwd.as_mut_ptr(),
356                buf.as_mut_ptr().cast::<c_char>(),
357                buf.len(),
358                &mut result,
359            )
360        };
361        if rc == ERANGE && buf.len() < (1 << 20) {
362            buf = vec![0u8; buf.len() * 4];
363            continue;
364        }
365        if rc != 0 {
366            return Err(SuiteError::NoRoot(format!(
367                "getpwuid_r failed: {}",
368                io::Error::from_raw_os_error(rc)
369            )));
370        }
371        if result.is_null() {
372            return Err(SuiteError::NoRoot(
373                "the account database has no entry for the current user".into(),
374            ));
375        }
376        // SAFETY: `result` is non-null and points at the `Passwd` that `pwd` holds.
377        let dir = unsafe { (*result).pw_dir };
378        if dir.is_null() {
379            return Err(SuiteError::NoRoot(
380                "the account database has no home directory for the current user".into(),
381            ));
382        }
383        // SAFETY: `pw_dir` is a NUL-terminated string inside `buf`.
384        let bytes = unsafe { CStr::from_ptr(dir) }.to_bytes();
385        return Ok(PathBuf::from(OsStr::from_bytes(bytes)));
386    }
387}
388
389/// The longest service name `host` accepts, in bytes.
390pub const MAX_SERVICE_NAME_LEN: usize = 48;
391
392/// Whether `s` is a service name `host` accepts: non-empty, at most [`MAX_SERVICE_NAME_LEN`] bytes,
393/// and only ASCII lowercase letters, digits and `-`. This is the single rule `host` enforces.
394pub fn valid_service_name(s: &str) -> bool {
395    !s.is_empty()
396        && s.len() <= MAX_SERVICE_NAME_LEN
397        && s.chars()
398            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
399}
400
401/// Create `dir` (and parents), make the leaf owner-only and prove it, through the
402/// `rightkit-fs` private primitives (one-way daemon -> fs dependency). On Windows this is the
403/// corrected existing-object repair: a pinned no-reparse handle, current-token ownership, a
404/// protected owner-only DACL, then a reverify; a foreign-owned or too-permissive directory is
405/// [`SuiteError::Insecure`], never silently inherited from whatever profile or explicit root
406/// happened to contain it. Unix behaviour is unchanged in effect: owner-only creation, the leaf
407/// narrowed, and a foreign owner refused as `Insecure`.
408pub(crate) fn make_private_dir(dir: &Path) -> Result<(), SuiteError> {
409    #[cfg(unix)]
410    {
411        // Preserve the `Insecure` classification for a foreign-owned existing leaf: the fs
412        // repair would otherwise surface the chmod refusal as plain I/O before the verify
413        // below could name the owner problem.
414        use std::os::unix::fs::MetadataExt;
415        if let Ok(md) = std::fs::symlink_metadata(dir) {
416            if md.uid() != crate::transport::current_uid() {
417                return Err(SuiteError::Insecure(format!(
418                    "{} is not owned by the current user",
419                    dir.display()
420                )));
421            }
422        }
423    }
424    rightkit_fs::private::ensure_private_dir(dir).map_err(suite_fs_error)?;
425    rightkit_fs::private::verify_owner_only(dir).map_err(suite_fs_error)
426}
427
428/// Privacy failures from the fs layer are `Insecure`; plain I/O failures stay `Io`.
429fn suite_fs_error(error: rightkit_fs::FsError) -> SuiteError {
430    match error {
431        rightkit_fs::FsError::Io { source, .. } => SuiteError::Io(source),
432        other => SuiteError::Insecure(other.to_string()),
433    }
434}