1use std::fmt;
30use std::io;
31use std::path::{Path, PathBuf};
32use std::sync::OnceLock;
33
34use crate::suite::{Suite, SuiteError, SUITE_DIR_NAME};
35
36pub const TEST_CAPABILITY: bool = cfg!(feature = "test-mode");
38
39#[cfg(feature = "test-mode")]
42pub const TEST_MODE_MARKER_TEXT: &str = "RIGHTKIT-TEST-MODE-COMPILED";
43
44#[cfg(feature = "test-mode")]
49#[used]
50#[no_mangle]
51#[cfg_attr(target_os = "macos", link_section = "__DATA,__rkit_tm")]
52#[cfg_attr(windows, link_section = ".rkittm")]
53#[cfg_attr(not(any(target_os = "macos", windows)), link_section = ".rkit_tm")]
54pub static TEST_MODE_MARKER: [u8; 27] = *b"RIGHTKIT-TEST-MODE-COMPILED";
55
56#[cfg(feature = "test-mode")]
58const SELECTOR_ACTIVATION: &str = "RIGHTKIT_UNDER_TEST";
59#[cfg(feature = "test-mode")]
61const SELECTOR_ROOT: &str = "RIGHTKIT_QA_DATA_DIR";
62#[cfg(feature = "test-mode")]
64const BINDING_FILE: &str = ".rightkit-app.json";
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct AppId {
70 id: &'static str,
71 dir_name: &'static str,
72}
73
74impl AppId {
75 pub const fn new(id: &'static str) -> AppId {
77 AppId { id, dir_name: id }
78 }
79
80 pub const fn with_dir(id: &'static str, dir_name: &'static str) -> AppId {
82 AppId { id, dir_name }
83 }
84
85 pub const fn id(&self) -> &'static str {
86 self.id
87 }
88
89 pub const fn dir_name(&self) -> &'static str {
90 self.dir_name
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum Refusal {
98 ActivationWithoutRoot,
100 RootWithoutActivation,
102 RootNotAbsolute,
103 RootNotDirectory,
105 RootHasSymlink,
107 RootOverlapsUserData,
109 RootOnRemovableOrCloud,
111 RootNotPrivate,
113 RootNotCreatedByRunner,
115 BindingAppMismatch,
117 UserRootUnknown,
119 Io(String),
121}
122
123impl Refusal {
124 pub fn rule(&self) -> &'static str {
126 match self {
127 Refusal::ActivationWithoutRoot => "activation-without-root",
128 Refusal::RootWithoutActivation => "root-without-activation",
129 Refusal::RootNotAbsolute => "root-not-absolute",
130 Refusal::RootNotDirectory => "root-not-directory",
131 Refusal::RootHasSymlink => "root-has-symlink",
132 Refusal::RootOverlapsUserData => "root-overlaps-user-data",
133 Refusal::RootOnRemovableOrCloud => "root-on-removable-or-cloud",
134 Refusal::RootNotPrivate => "root-not-private",
135 Refusal::RootNotCreatedByRunner => "root-not-created-by-runner",
136 Refusal::BindingAppMismatch => "binding-app-mismatch",
137 Refusal::UserRootUnknown => "user-root-unknown",
138 Refusal::Io(_) => "io",
139 }
140 }
141}
142
143impl fmt::Display for Refusal {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 match self {
146 Refusal::Io(e) => write!(f, "test mode refused ({}): {e}", self.rule()),
147 _ => write!(f, "test mode refused ({})", self.rule()),
148 }
149 }
150}
151
152impl std::error::Error for Refusal {}
153
154impl From<io::Error> for Refusal {
155 fn from(e: io::Error) -> Self {
156 Refusal::Io(e.to_string())
157 }
158}
159
160#[derive(Debug, Clone)]
164pub struct TestRoot {
165 path: PathBuf,
166 suite: Suite,
167 #[cfg_attr(not(feature = "test-mode"), allow(dead_code))]
168 app_id: &'static str,
169}
170
171impl TestRoot {
172 pub fn path(&self) -> &Path {
174 &self.path
175 }
176
177 pub fn suite(&self) -> Suite {
179 self.suite.clone()
180 }
181
182 #[cfg(test)]
184 pub(crate) fn for_test(path: PathBuf, suite: Suite) -> TestRoot {
185 TestRoot {
186 path,
187 suite,
188 app_id: "test",
189 }
190 }
191
192 pub fn app_dir(&self, app: &AppId) -> PathBuf {
194 self.path.join(app.dir_name)
195 }
196}
197
198#[derive(Debug, Clone)]
200pub enum Mode {
201 Release,
202 Test(TestRoot),
203}
204
205impl Mode {
206 pub fn is_test(&self) -> bool {
207 matches!(self, Mode::Test(_))
208 }
209
210 pub fn test_root(&self) -> Option<&TestRoot> {
212 match self {
213 Mode::Test(root) => Some(root),
214 Mode::Release => None,
215 }
216 }
217
218 #[cfg(feature = "test-mode")]
221 #[doc(hidden)]
222 pub fn selector_env(&self, name: &str) -> Option<String> {
223 match self {
224 Mode::Test(_) => std::env::var(name).ok().filter(|v| !v.trim().is_empty()),
225 Mode::Release => None,
226 }
227 }
228}
229
230#[cfg(feature = "test-mode")]
234#[macro_export]
235macro_rules! test_selector {
236 ($mode:expr, $name:literal) => {
237 $crate::mode::Mode::selector_env(&$mode, $name)
238 };
239}
240
241#[cfg(not(feature = "test-mode"))]
243#[macro_export]
244macro_rules! test_selector {
245 ($mode:expr, $name:literal) => {{
246 let _ = &$mode;
247 ::core::option::Option::<::std::string::String>::None
248 }};
249}
250
251pub trait ModeInput {
254 fn env(&self, name: &str) -> Option<String>;
256
257 fn per_user_default(&self) -> Result<Suite, SuiteError> {
259 Suite::per_user_default()
260 }
261
262 fn protected_paths(&self, real: &Suite) -> Vec<PathBuf> {
266 default_protected_paths(real)
267 }
268}
269
270#[derive(Debug, Clone, Copy, Default)]
272pub struct ProcessInput;
273
274impl ModeInput for ProcessInput {
275 fn env(&self, name: &str) -> Option<String> {
276 std::env::var(name).ok()
277 }
278}
279
280pub fn default_protected_paths(real: &Suite) -> Vec<PathBuf> {
288 let mut out = Vec::new();
289 if real_root_is_env_shaped(real.root()) {
290 return out;
291 }
292 let Some(orthic) = real.root().parent() else {
293 return out;
294 };
295 out.push(orthic.to_path_buf());
296 if orthic.file_name().and_then(|n| n.to_str()) == Some(SUITE_DIR_NAME) {
299 if let Some(home) = orthic.ancestors().nth(3) {
300 for dir in user_content_dirs() {
301 out.push(home.join(dir));
302 }
303 }
304 }
305 out
306}
307
308fn real_root_is_env_shaped(real: &Path) -> bool {
312 #[cfg(all(windows, feature = "test-mode"))]
313 {
314 let Ok(profile) = crate::suite::account_profile_dir() else {
315 return true;
316 };
317 let env = |key: &str| {
318 std::env::var_os(key)
319 .filter(|v| !v.is_empty())
320 .map(PathBuf::from)
321 };
322 let local = profile.join("AppData").join("Local");
323 root_under_differing_env(
324 real,
325 &[(env("USERPROFILE"), profile), (env("LOCALAPPDATA"), local)],
326 )
327 }
328 #[cfg(not(all(windows, feature = "test-mode")))]
329 {
330 let _ = real;
331 false
332 }
333}
334
335#[cfg_attr(not(all(windows, feature = "test-mode")), allow(dead_code))]
339pub(crate) fn root_under_differing_env(root: &Path, pairs: &[(Option<PathBuf>, PathBuf)]) -> bool {
340 fn norm(p: &Path) -> Vec<String> {
341 let text = p.to_string_lossy();
342 let text = text.strip_prefix(r"\\?\").unwrap_or(&text);
343 text.split(['/', '\\'])
344 .filter(|c| !c.is_empty())
345 .map(|c| {
346 if cfg!(windows) {
347 c.to_ascii_lowercase()
348 } else {
349 c.to_string()
350 }
351 })
352 .collect()
353 }
354 let root = norm(root);
355 pairs.iter().any(|(env, account)| {
356 env.as_deref().is_some_and(|env| {
357 let env = norm(env);
358 !env.is_empty() && env != norm(account) && root.starts_with(&env)
359 })
360 })
361}
362
363fn user_content_dirs() -> [&'static str; 7] {
364 [
365 "Documents",
366 "Pictures",
367 "Desktop",
368 "Downloads",
369 "Movies",
370 "Music",
371 "Videos",
372 ]
373}
374
375#[derive(Debug, Clone)]
377pub struct AppRoots {
378 pub suite: Suite,
380 pub app_dir: PathBuf,
382}
383
384pub fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
388 #[cfg(feature = "test-mode")]
389 {
390 capable::decide(app, input)
391 }
392 #[cfg(not(feature = "test-mode"))]
393 {
394 let _ = (app, input);
395 Ok(Mode::Release)
396 }
397}
398
399pub fn init_or_exit(app: &AppId) -> &'static Mode {
402 static MODE: OnceLock<Mode> = OnceLock::new();
403 if let Some(mode) = MODE.get() {
404 return mode;
405 }
406 match decide(app, &ProcessInput) {
407 Ok(mode) => MODE.get_or_init(|| mode),
408 Err(refusal) => {
409 eprintln!("{refusal}");
410 std::process::exit(2);
411 }
412 }
413}
414
415pub fn roots(app: &AppId, mode: &Mode) -> Result<AppRoots, SuiteError> {
417 roots_with(app, mode, &ProcessInput)
418}
419
420pub fn roots_with(app: &AppId, mode: &Mode, input: &dyn ModeInput) -> Result<AppRoots, SuiteError> {
422 match mode {
423 Mode::Release => {
424 let suite = input.per_user_default()?;
425 let orthic = suite
426 .root()
427 .parent()
428 .ok_or_else(|| SuiteError::NoRoot("the per-user suite root has no parent".into()))?
429 .to_path_buf();
430 Ok(AppRoots {
431 suite,
432 app_dir: orthic.join(app.dir_name),
433 })
434 }
435 Mode::Test(root) => Ok(AppRoots {
436 suite: root.suite(),
437 app_dir: root.app_dir(app),
438 }),
439 }
440}
441
442#[cfg(feature = "test-mode")]
444mod capable {
445 use super::*;
446 use std::fs;
447
448 fn truthy(v: &str) -> bool {
449 let t = v.trim();
450 !(t.is_empty() || t == "0" || t.eq_ignore_ascii_case("false"))
451 }
452
453 pub(super) fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
454 let activated = input
455 .env(SELECTOR_ACTIVATION)
456 .filter(|v| truthy(v))
457 .is_some();
458 let root = input.env(SELECTOR_ROOT).filter(|v| !v.trim().is_empty());
459 match (activated, root) {
460 (false, None) => Ok(Mode::Release),
461 (false, Some(_)) => Err(Refusal::RootWithoutActivation),
462 (true, None) => Err(Refusal::ActivationWithoutRoot),
463 (true, Some(root)) => verify(app, input, &root).map(Mode::Test),
464 }
465 }
466
467 fn map_refusal(refusal: rightkit_fs::test_mode::Refusal) -> Refusal {
468 use rightkit_fs::test_mode::Refusal as FsRefusal;
469 match refusal {
470 FsRefusal::ActivationWithoutRoot => Refusal::ActivationWithoutRoot,
471 FsRefusal::RootWithoutActivation => Refusal::RootWithoutActivation,
472 FsRefusal::RootNotAbsolute => Refusal::RootNotAbsolute,
473 FsRefusal::RootNotDirectory => Refusal::RootNotDirectory,
474 FsRefusal::RootHasSymlink => Refusal::RootHasSymlink,
475 FsRefusal::RootOverlapsUserData => Refusal::RootOverlapsUserData,
476 FsRefusal::RootOnRemovableOrCloud => Refusal::RootOnRemovableOrCloud,
477 FsRefusal::RootNotPrivate => Refusal::RootNotPrivate,
478 FsRefusal::RootNotCreatedByRunner => Refusal::RootNotCreatedByRunner,
479 FsRefusal::BindingAppMismatch => Refusal::BindingAppMismatch,
480 FsRefusal::UserRootUnknown => Refusal::UserRootUnknown,
481 FsRefusal::Io(e) => Refusal::Io(e),
482 other => Refusal::Io(other.rule().to_string()),
483 }
484 }
485
486 fn check_binding(app: &AppId, canon: &Path) -> Result<(), Refusal> {
487 let bytes =
488 fs::read(canon.join(BINDING_FILE)).map_err(|_| Refusal::RootNotCreatedByRunner)?;
489 let value: serde_json::Value =
490 serde_json::from_slice(&bytes).map_err(|_| Refusal::RootNotCreatedByRunner)?;
491 match value.get("app").and_then(|v| v.as_str()) {
492 None => Err(Refusal::RootNotCreatedByRunner),
493 Some(bound) if bound == app.id => Ok(()),
494 Some(_) => Err(Refusal::BindingAppMismatch),
495 }
496 }
497
498 fn verify(app: &AppId, input: &dyn ModeInput, raw: &str) -> Result<TestRoot, Refusal> {
499 let real = input
500 .per_user_default()
501 .map_err(|_| Refusal::UserRootUnknown)?;
502 let mut protected = input.protected_paths(&real);
503 if protected.is_empty() {
504 return Err(Refusal::UserRootUnknown);
506 }
507 protected.push(real.root().to_path_buf());
508 let validated =
509 rightkit_fs::test_mode::validate_root(app.id, Path::new(raw.trim()), &protected)
510 .map_err(map_refusal)?;
511 let suite_path = validated.child(Path::new("shared")).map_err(map_refusal)?;
512 let suite = Suite::at(suite_path).map_err(|e| Refusal::Io(e.to_string()))?;
513 Ok(TestRoot {
514 path: validated.path().to_path_buf(),
515 suite,
516 app_id: app.id,
517 })
518 }
519
520 impl TestRoot {
521 pub fn reset(&self) -> Result<(), Refusal> {
525 let app = AppId {
526 id: self.app_id,
527 dir_name: self.app_id,
528 };
529 check_binding(&app, &self.path)?;
530 for entry in fs::read_dir(&self.path)? {
531 let entry = entry?;
532 if entry.file_name().to_string_lossy().starts_with('.') {
533 continue;
534 }
535 let ty = entry.file_type()?;
536 if ty.is_dir() && !ty.is_symlink() {
537 fs::remove_dir_all(entry.path())?;
538 } else {
539 fs::remove_file(entry.path())?;
540 }
541 }
542 Ok(())
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 fn pair(env: Option<&str>, account: &str) -> (Option<PathBuf>, PathBuf) {
552 (env.map(PathBuf::from), PathBuf::from(account))
553 }
554
555 #[test]
556 fn a_root_under_a_differing_env_profile_is_env_shaped() {
557 let root = Path::new("/qa/home/AppData/Local/Orthic Labs/shared");
558 assert!(root_under_differing_env(
560 root,
561 &[pair(Some("/qa/home"), "/users/real")]
562 ));
563 assert!(root_under_differing_env(
564 root,
565 &[
566 pair(None, "/users/real"),
567 pair(Some("/qa/home/AppData/Local"), "/x")
568 ]
569 ));
570 assert!(!root_under_differing_env(
572 root,
573 &[pair(Some("/qa/home"), "/qa/home")]
574 ));
575 assert!(!root_under_differing_env(root, &[pair(None, "/qa/home")]));
576 assert!(!root_under_differing_env(
577 root,
578 &[pair(Some("/elsewhere"), "/qa/home")]
579 ));
580 assert!(!root_under_differing_env(
582 root,
583 &[pair(Some("/qa/hom"), "/users/real")]
584 ));
585 }
586
587 #[cfg(windows)]
588 #[test]
589 fn env_comparison_ignores_case_separators_and_the_verbatim_prefix_on_windows() {
590 let root = Path::new(r"\\?\C:\Users\QA\AppData\Local\Orthic Labs\shared");
591 assert!(root_under_differing_env(
592 root,
593 &[pair(Some("c:/users/qa"), r"C:\Users\Real")]
594 ));
595 assert!(!root_under_differing_env(
596 root,
597 &[pair(Some(r"C:\USERS\QA"), r"c:\users\qa")]
598 ));
599 }
600
601 #[test]
602 fn the_default_protected_paths_keep_the_orthic_folder_and_content_dirs() {
603 let real = Suite::at(Path::new(if cfg!(windows) {
604 r"C:\Users\acct\AppData\Local\Orthic Labs\shared"
605 } else {
606 "/users/acct/AppData/Local/Orthic Labs/shared"
607 }))
608 .unwrap();
609 let paths = default_protected_paths(&real);
610 assert!(paths.iter().any(|p| p.ends_with("Orthic Labs")));
611 assert!(paths.iter().any(|p| p.ends_with("Documents")));
612 }
613}