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
77impl Suite {
78 pub fn resolve() -> Result<Suite, SuiteError> {
83 Self::per_user_default()
84 }
85
86 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 #[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 pub fn env_derived(&self) -> bool {
116 self.env_derived
117 }
118
119 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 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 pub fn service_dir(&self, service: &str) -> PathBuf {
160 self.root.join("runtime").join(service)
161 }
162
163 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 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 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#[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#[cfg(windows)]
214fn platform_default_base() -> Result<PathBuf, SuiteError> {
215 Ok(account_profile_dir()?.join("AppData").join("Local"))
216}
217
218#[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 unsafe { CloseHandle(self.0) };
256 }
257 }
258
259 let mut raw: *mut c_void = std::ptr::null_mut();
260 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 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 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#[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 #[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 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 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 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 let bytes = unsafe { CStr::from_ptr(dir) }.to_bytes();
385 return Ok(PathBuf::from(OsStr::from_bytes(bytes)));
386 }
387}
388
389pub const MAX_SERVICE_NAME_LEN: usize = 48;
391
392pub 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
401pub(crate) fn make_private_dir(dir: &Path) -> Result<(), SuiteError> {
409 #[cfg(unix)]
410 {
411 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
428fn 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}