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";
8pub const SHARED_DIR_NAME: &str = "shared";
12pub const ENV_SUITE_ROOT: &str = "RIGHTKIT_SUITE_ROOT";
15
16#[derive(Debug)]
17pub enum SuiteError {
18 NoRoot(String),
20 NotAbsolute(PathBuf),
21 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#[derive(Clone)]
56pub struct Suite {
57 root: PathBuf,
58 _temp: Option<Arc<TempGuard>>,
59 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 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 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 #[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 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 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 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 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 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 pub fn service_dir(&self, service: &str) -> PathBuf {
241 self.root.join("runtime").join(service)
242 }
243
244 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 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 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#[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#[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 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 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 while unsafe { *text.add(len) } != 0 {
327 len += 1;
328 }
329 Some(PathBuf::from(OsString::from_wide(unsafe {
331 std::slice::from_raw_parts(text, len)
332 })))
333 } else {
334 None
335 };
336 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#[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 #[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 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 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 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 let bytes = unsafe { CStr::from_ptr(dir) }.to_bytes();
430 return Ok(PathBuf::from(OsStr::from_bytes(bytes)));
431 }
432}
433
434pub const MAX_SERVICE_NAME_LEN: usize = 48;
436
437const BUILD_TOOL_ENV: &[&str] = &["CARGO", "CARGO_MANIFEST_DIR"];
440
441pub 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
468pub 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
477pub(crate) fn make_private_dir(dir: &Path) -> Result<(), SuiteError> {
485 #[cfg(unix)]
486 {
487 use std::os::unix::fs::MetadataExt;
491 if let Ok(md) = std::fs::symlink_metadata(dir) {
492 if md.uid() != crate::transport::current_uid() {
493 return Err(SuiteError::Insecure(format!(
494 "{} is not owned by the current user",
495 dir.display()
496 )));
497 }
498 }
499 }
500 rightkit_fs::private::ensure_private_dir(dir).map_err(suite_fs_error)?;
501 rightkit_fs::private::verify_owner_only(dir).map_err(suite_fs_error)
502}
503
504fn suite_fs_error(error: rightkit_fs::FsError) -> SuiteError {
506 match error {
507 rightkit_fs::FsError::Io { source, .. } => SuiteError::Io(source),
508 other => SuiteError::Insecure(other.to_string()),
509 }
510}
511
512#[cfg(test)]
513#[allow(deprecated)] mod tests {
515 use super::*;
516
517 #[test]
518 fn only_resolve_is_marked_environment_derived() {
519 #[cfg(not(feature = "test-mode"))]
520 {
521 let suite = Suite::resolve().unwrap();
523 assert!(!suite.env_derived());
524 assert_eq!(suite.root(), Suite::per_user_default().unwrap().root());
525 }
526 let dir = tempfile::tempdir().unwrap();
527 assert!(!Suite::at(dir.path()).unwrap().env_derived());
528 assert!(!Suite::per_run_temp("env-flag").unwrap().env_derived());
529 #[cfg(feature = "test-mode")]
532 if let Ok(suite) = Suite::resolve() {
533 assert!(suite.env_derived());
534 assert!(suite.clone().env_derived(), "the mark survives a clone");
535 }
536 }
537
538 #[cfg(any(target_os = "macos", target_os = "linux"))]
539 #[test]
540 fn the_per_user_default_comes_from_the_account_database_under_orthic_labs_shared() {
541 let suite = Suite::per_user_default().unwrap();
542 assert!(!suite.env_derived());
543 assert!(suite.root().is_absolute());
544 assert!(
545 suite
546 .root()
547 .ends_with(Path::new(SUITE_DIR_NAME).join(SHARED_DIR_NAME)),
548 "{:?}",
549 suite.root()
550 );
551 let home = passwd_home_dir().unwrap();
552 assert!(home.is_absolute());
553 assert!(suite.root().starts_with(&home));
554 assert_eq!(
556 Suite::resolve_ignoring_env_overrides(true).unwrap().root(),
557 suite.root()
558 );
559 }
560
561 #[cfg(windows)]
562 #[test]
563 fn the_per_user_default_comes_from_the_known_folder_table_not_the_environment() {
564 let suite = Suite::per_user_default().unwrap();
565 assert!(!suite.env_derived());
566 assert!(suite.root().is_absolute());
567 assert!(
568 suite
569 .root()
570 .ends_with(Path::new(SUITE_DIR_NAME).join(SHARED_DIR_NAME)),
571 "{:?}",
572 suite.root()
573 );
574 let base = platform_default_base().unwrap();
575 assert!(suite.root().starts_with(&base));
576 assert!(base.is_dir(), "{base:?}");
577 assert_eq!(
578 Suite::resolve_ignoring_env_overrides(true).unwrap().root(),
579 suite.root()
580 );
581 }
582}