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)]
295fn platform_default_base() -> Result<PathBuf, SuiteError> {
296 Ok(account_profile_dir()?.join("AppData").join("Local"))
297}
298
299#[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 unsafe { CloseHandle(self.0) };
337 }
338 }
339
340 let mut raw: *mut c_void = std::ptr::null_mut();
341 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 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 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#[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 #[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 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 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 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 let bytes = unsafe { CStr::from_ptr(dir) }.to_bytes();
466 return Ok(PathBuf::from(OsStr::from_bytes(bytes)));
467 }
468}
469
470pub const MAX_SERVICE_NAME_LEN: usize = 48;
472
473const BUILD_TOOL_ENV: &[&str] = &["CARGO", "CARGO_MANIFEST_DIR"];
476
477pub 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
492pub 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
509fn 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
534pub 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
543pub(crate) fn make_private_dir(dir: &Path) -> Result<(), SuiteError> {
551 #[cfg(unix)]
552 {
553 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
570fn 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)] mod tests {
581 use super::*;
582
583 #[test]
584 fn only_resolve_is_marked_environment_derived() {
585 #[cfg(not(feature = "test-mode"))]
586 {
587 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 #[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(all(feature = "test-mode", any(target_os = "macos", target_os = "linux")))]
607 #[test]
608 fn test_mode_resolve_is_the_real_root_when_no_override_and_the_override_otherwise() {
609 let overridden = [ENV_SUITE_ROOT, "RIGHT_SUITE_DIR", "ORTHIC_SHARED_DIR"]
610 .iter()
611 .find_map(|k| env_nonempty(k));
612 let suite = Suite::resolve().unwrap();
613 assert!(suite.env_derived());
614 match overridden {
615 Some(root) => assert_eq!(suite.root(), Path::new(&root)),
616 None => {
617 let home_is_account_home = env_nonempty("HOME")
618 .map(PathBuf::from)
619 .zip(passwd_home_dir().ok())
620 .is_some_and(|(env, account)| env == account);
621 if home_is_account_home && env_nonempty("XDG_DATA_HOME").is_none() {
622 assert_eq!(suite.root(), Suite::per_user_default().unwrap().root());
623 }
624 }
625 }
626 }
627
628 #[cfg(any(target_os = "macos", target_os = "linux"))]
629 #[test]
630 fn the_per_user_default_comes_from_the_account_database_under_orthic_labs_shared() {
631 let suite = Suite::per_user_default().unwrap();
632 assert!(!suite.env_derived());
633 assert!(suite.root().is_absolute());
634 assert!(
635 suite
636 .root()
637 .ends_with(Path::new(SUITE_DIR_NAME).join(SHARED_DIR_NAME)),
638 "{:?}",
639 suite.root()
640 );
641 let home = passwd_home_dir().unwrap();
642 assert!(home.is_absolute());
643 assert!(suite.root().starts_with(&home));
644 assert_eq!(
646 Suite::resolve_ignoring_env_overrides(true).unwrap().root(),
647 suite.root()
648 );
649 }
650
651 #[cfg(windows)]
652 #[test]
653 fn the_per_user_default_comes_from_the_known_folder_table_not_the_environment() {
654 let suite = Suite::per_user_default().unwrap();
655 assert!(!suite.env_derived());
656 assert!(suite.root().is_absolute());
657 assert!(
658 suite
659 .root()
660 .ends_with(Path::new(SUITE_DIR_NAME).join(SHARED_DIR_NAME)),
661 "{:?}",
662 suite.root()
663 );
664 let base = platform_default_base().unwrap();
665 assert!(suite.root().starts_with(&base));
666 assert!(base.is_dir(), "{base:?}");
667 assert_eq!(
668 Suite::resolve_ignoring_env_overrides(true).unwrap().root(),
669 suite.root()
670 );
671 }
672
673 #[cfg(windows)]
674 #[test]
675 fn the_account_profile_is_absolute_and_holds_the_default_base() {
676 let profile = account_profile_dir().unwrap();
677 assert!(profile.is_absolute() && profile.is_dir(), "{profile:?}");
678 assert_eq!(
679 platform_default_base().unwrap(),
680 profile.join("AppData").join("Local")
681 );
682 }
683
684 #[test]
685 fn the_build_tool_guard_table() {
686 let temp = Suite::per_run_temp("guard").unwrap();
687 let outside = tempfile::tempdir().unwrap();
688 let outside = std::fs::canonicalize(outside.path()).unwrap();
689 let home = Suite::at(std::env::current_dir().unwrap()).unwrap();
691 let in_temp = Suite::at(outside.join("suite")).unwrap();
692
693 assert!(refuse_build_tool_real_data_inner(&home, None, false).is_ok());
695 assert!(refuse_build_tool_real_data_inner(&temp, None, true).is_ok());
697 assert!(refuse_build_tool_real_data_inner(&in_temp, None, true).is_ok());
698 assert!(matches!(
700 refuse_build_tool_real_data_inner(&home, None, true),
701 Err(SuiteError::Insecure(_))
702 ));
703 assert!(matches!(
704 refuse_build_tool_real_data_inner(&home, Some(&crate::mode::Mode::Release), true),
705 Err(SuiteError::Insecure(_))
706 ));
707 }
708
709 #[test]
710 fn a_validated_test_root_is_accepted_and_a_forged_suite_is_not() {
711 use crate::mode::{Mode, TestRoot};
712 let cwd = std::env::current_dir().unwrap();
714 let qa = cwd.join("qa-home");
715 let inside = Suite::at(qa.join("shared")).unwrap();
716 let mode = Mode::Test(TestRoot::for_test(qa.clone(), inside.clone()));
717 assert!(refuse_build_tool_real_data_inner(&inside, Some(&mode), true).is_ok());
718 for forged in [
721 cwd.join("qa-home-evil").join("shared"),
722 cwd.join("real"),
723 cwd.clone(),
724 ] {
725 let forged = Suite::at(forged).unwrap();
726 assert!(
727 matches!(
728 refuse_build_tool_real_data_inner(&forged, Some(&mode), true),
729 Err(SuiteError::Insecure(_))
730 ),
731 "{forged:?}"
732 );
733 }
734 assert!(refuse_build_tool_real_data_inner(&inside, None, false).is_ok());
736 assert!(refuse_build_tool_real_data_inner(&inside, None, true).is_err());
738 }
739}