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(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 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 let home = Suite::at(std::env::current_dir().unwrap()).unwrap();
667 let in_temp = Suite::at(outside.join("suite")).unwrap();
668
669 assert!(refuse_build_tool_real_data_inner(&home, None, false).is_ok());
671 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 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 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 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 assert!(refuse_build_tool_real_data_inner(&inside, None, false).is_ok());
712 assert!(refuse_build_tool_real_data_inner(&inside, None, true).is_err());
714 }
715}