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")]
241#[macro_export]
242macro_rules! test_selector {
243 ($mode:expr, $name:literal) => {
244 $crate::mode::Mode::selector_env(&$mode, $name)
245 };
246}
247
248#[cfg(not(feature = "test-mode"))]
250#[macro_export]
251macro_rules! test_selector {
252 ($mode:expr, $name:literal) => {{
253 let _ = &$mode;
254 ::core::option::Option::<::std::string::String>::None
255 }};
256}
257
258pub trait ModeInput {
261 fn env(&self, name: &str) -> Option<String>;
263
264 fn per_user_default(&self) -> Result<Suite, SuiteError> {
266 Suite::per_user_default()
267 }
268
269 fn protected_paths(&self, real: &Suite) -> Vec<PathBuf> {
273 default_protected_paths(real)
274 }
275}
276
277#[derive(Debug, Clone, Copy, Default)]
279pub struct ProcessInput;
280
281impl ModeInput for ProcessInput {
282 fn env(&self, name: &str) -> Option<String> {
283 std::env::var(name).ok()
284 }
285}
286
287pub fn default_protected_paths(real: &Suite) -> Vec<PathBuf> {
295 let mut out = Vec::new();
296 if real_root_is_env_shaped(real.root()) {
297 return out;
298 }
299 let Some(orthic) = real.root().parent() else {
300 return out;
301 };
302 out.push(orthic.to_path_buf());
303 if orthic.file_name().and_then(|n| n.to_str()) == Some(SUITE_DIR_NAME) {
306 if let Some(home) = orthic.ancestors().nth(3) {
307 for dir in user_content_dirs() {
308 out.push(home.join(dir));
309 }
310 }
311 }
312 out
313}
314
315fn real_root_is_env_shaped(real: &Path) -> bool {
319 #[cfg(all(windows, feature = "test-mode"))]
320 {
321 let Ok(profile) = crate::suite::account_profile_dir() else {
322 return true;
323 };
324 let env = |key: &str| {
325 std::env::var_os(key)
326 .filter(|v| !v.is_empty())
327 .map(PathBuf::from)
328 };
329 let local = profile.join("AppData").join("Local");
330 root_under_differing_env(
331 real,
332 &[(env("USERPROFILE"), profile), (env("LOCALAPPDATA"), local)],
333 )
334 }
335 #[cfg(not(all(windows, feature = "test-mode")))]
336 {
337 let _ = real;
338 false
339 }
340}
341
342#[cfg_attr(not(all(windows, feature = "test-mode")), allow(dead_code))]
346pub(crate) fn root_under_differing_env(root: &Path, pairs: &[(Option<PathBuf>, PathBuf)]) -> bool {
347 fn norm(p: &Path) -> Vec<String> {
348 let text = p.to_string_lossy();
349 let text = text.strip_prefix(r"\\?\").unwrap_or(&text);
350 text.split(['/', '\\'])
351 .filter(|c| !c.is_empty())
352 .map(|c| {
353 if cfg!(windows) {
354 c.to_ascii_lowercase()
355 } else {
356 c.to_string()
357 }
358 })
359 .collect()
360 }
361 let root = norm(root);
362 pairs.iter().any(|(env, account)| {
363 env.as_deref().is_some_and(|env| {
364 let env = norm(env);
365 !env.is_empty() && env != norm(account) && root.starts_with(&env)
366 })
367 })
368}
369
370fn user_content_dirs() -> [&'static str; 7] {
371 [
372 "Documents",
373 "Pictures",
374 "Desktop",
375 "Downloads",
376 "Movies",
377 "Music",
378 "Videos",
379 ]
380}
381
382#[derive(Debug, Clone)]
384pub struct AppRoots {
385 pub suite: Suite,
387 pub app_dir: PathBuf,
389}
390
391pub fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
395 #[cfg(feature = "test-mode")]
396 {
397 capable::decide(app, input)
398 }
399 #[cfg(not(feature = "test-mode"))]
400 {
401 let _ = (app, input);
402 Ok(Mode::Release)
403 }
404}
405
406pub fn init_or_exit(app: &AppId) -> &'static Mode {
409 static MODE: OnceLock<Mode> = OnceLock::new();
410 if let Some(mode) = MODE.get() {
411 return mode;
412 }
413 match decide(app, &ProcessInput) {
414 Ok(mode) => MODE.get_or_init(|| mode),
415 Err(refusal) => {
416 eprintln!("{refusal}");
417 std::process::exit(2);
418 }
419 }
420}
421
422pub fn roots(app: &AppId, mode: &Mode) -> Result<AppRoots, SuiteError> {
424 roots_with(app, mode, &ProcessInput)
425}
426
427pub fn roots_with(app: &AppId, mode: &Mode, input: &dyn ModeInput) -> Result<AppRoots, SuiteError> {
429 match mode {
430 Mode::Release => {
431 let suite = input.per_user_default()?;
432 let orthic = suite
433 .root()
434 .parent()
435 .ok_or_else(|| SuiteError::NoRoot("the per-user suite root has no parent".into()))?
436 .to_path_buf();
437 Ok(AppRoots {
438 suite,
439 app_dir: orthic.join(app.dir_name),
440 })
441 }
442 Mode::Test(root) => Ok(AppRoots {
443 suite: root.suite(),
444 app_dir: root.app_dir(app),
445 }),
446 }
447}
448
449#[cfg(feature = "test-mode")]
451mod capable {
452 use super::*;
453 use std::fs;
454
455 fn truthy(v: &str) -> bool {
456 let t = v.trim();
457 !(t.is_empty() || t == "0" || t.eq_ignore_ascii_case("false"))
458 }
459
460 pub(super) fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
461 let activated = input
462 .env(SELECTOR_ACTIVATION)
463 .filter(|v| truthy(v))
464 .is_some();
465 let root = input.env(SELECTOR_ROOT).filter(|v| !v.trim().is_empty());
466 match (activated, root) {
467 (false, None) => Ok(Mode::Release),
468 (false, Some(_)) => Err(Refusal::RootWithoutActivation),
469 (true, None) => Err(Refusal::ActivationWithoutRoot),
470 (true, Some(root)) => verify(app, input, &root).map(Mode::Test),
471 }
472 }
473
474 fn map_refusal(refusal: rightkit_fs::test_mode::Refusal) -> Refusal {
475 use rightkit_fs::test_mode::Refusal as FsRefusal;
476 match refusal {
477 FsRefusal::ActivationWithoutRoot => Refusal::ActivationWithoutRoot,
478 FsRefusal::RootWithoutActivation => Refusal::RootWithoutActivation,
479 FsRefusal::RootNotAbsolute => Refusal::RootNotAbsolute,
480 FsRefusal::RootNotDirectory => Refusal::RootNotDirectory,
481 FsRefusal::RootHasSymlink => Refusal::RootHasSymlink,
482 FsRefusal::RootOverlapsUserData => Refusal::RootOverlapsUserData,
483 FsRefusal::RootOnRemovableOrCloud => Refusal::RootOnRemovableOrCloud,
484 FsRefusal::RootNotPrivate => Refusal::RootNotPrivate,
485 FsRefusal::RootNotCreatedByRunner => Refusal::RootNotCreatedByRunner,
486 FsRefusal::BindingAppMismatch => Refusal::BindingAppMismatch,
487 FsRefusal::UserRootUnknown => Refusal::UserRootUnknown,
488 FsRefusal::Io(e) => Refusal::Io(e),
489 other => Refusal::Io(other.rule().to_string()),
490 }
491 }
492
493 fn check_binding(app: &AppId, canon: &Path) -> Result<(), Refusal> {
494 let bytes =
495 fs::read(canon.join(BINDING_FILE)).map_err(|_| Refusal::RootNotCreatedByRunner)?;
496 let value: serde_json::Value =
497 serde_json::from_slice(&bytes).map_err(|_| Refusal::RootNotCreatedByRunner)?;
498 match value.get("app").and_then(|v| v.as_str()) {
499 None => Err(Refusal::RootNotCreatedByRunner),
500 Some(bound) if bound == app.id => Ok(()),
501 Some(_) => Err(Refusal::BindingAppMismatch),
502 }
503 }
504
505 fn verify(app: &AppId, input: &dyn ModeInput, raw: &str) -> Result<TestRoot, Refusal> {
506 let real = input
507 .per_user_default()
508 .map_err(|_| Refusal::UserRootUnknown)?;
509 let mut protected = input.protected_paths(&real);
510 if protected.is_empty() {
511 return Err(Refusal::UserRootUnknown);
513 }
514 protected.push(real.root().to_path_buf());
515 let validated =
516 rightkit_fs::test_mode::validate_root(app.id, Path::new(raw.trim()), &protected)
517 .map_err(map_refusal)?;
518 let suite_path = validated.child(Path::new("shared")).map_err(map_refusal)?;
519 let suite = Suite::at(suite_path).map_err(|e| Refusal::Io(e.to_string()))?;
520 Ok(TestRoot {
521 path: validated.path().to_path_buf(),
522 suite,
523 app_id: app.id,
524 })
525 }
526
527 impl TestRoot {
528 pub fn reset(&self) -> Result<(), Refusal> {
532 let app = AppId {
533 id: self.app_id,
534 dir_name: self.app_id,
535 };
536 check_binding(&app, &self.path)?;
537 for entry in fs::read_dir(&self.path)? {
538 let entry = entry?;
539 if entry.file_name().to_string_lossy().starts_with('.') {
540 continue;
541 }
542 let ty = entry.file_type()?;
543 if ty.is_dir() && !ty.is_symlink() {
544 fs::remove_dir_all(entry.path())?;
545 } else {
546 fs::remove_file(entry.path())?;
547 }
548 }
549 Ok(())
550 }
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 fn pair(env: Option<&str>, account: &str) -> (Option<PathBuf>, PathBuf) {
559 (env.map(PathBuf::from), PathBuf::from(account))
560 }
561
562 #[test]
563 fn a_root_under_a_differing_env_profile_is_env_shaped() {
564 let root = Path::new("/qa/home/AppData/Local/Orthic Labs/shared");
565 assert!(root_under_differing_env(
567 root,
568 &[pair(Some("/qa/home"), "/users/real")]
569 ));
570 assert!(root_under_differing_env(
571 root,
572 &[
573 pair(None, "/users/real"),
574 pair(Some("/qa/home/AppData/Local"), "/x")
575 ]
576 ));
577 assert!(!root_under_differing_env(
579 root,
580 &[pair(Some("/qa/home"), "/qa/home")]
581 ));
582 assert!(!root_under_differing_env(root, &[pair(None, "/qa/home")]));
583 assert!(!root_under_differing_env(
584 root,
585 &[pair(Some("/elsewhere"), "/qa/home")]
586 ));
587 assert!(!root_under_differing_env(
589 root,
590 &[pair(Some("/qa/hom"), "/users/real")]
591 ));
592 }
593
594 #[cfg(windows)]
595 #[test]
596 fn env_comparison_ignores_case_separators_and_the_verbatim_prefix_on_windows() {
597 let root = Path::new(r"\\?\C:\Users\QA\AppData\Local\Orthic Labs\shared");
598 assert!(root_under_differing_env(
599 root,
600 &[pair(Some("c:/users/qa"), r"C:\Users\Real")]
601 ));
602 assert!(!root_under_differing_env(
603 root,
604 &[pair(Some(r"C:\USERS\QA"), r"c:\users\qa")]
605 ));
606 }
607
608 #[test]
609 fn the_default_protected_paths_keep_the_orthic_folder_and_content_dirs() {
610 let real = Suite::at(Path::new(if cfg!(windows) {
611 r"C:\Users\acct\AppData\Local\Orthic Labs\shared"
612 } else {
613 "/users/acct/AppData/Local/Orthic Labs/shared"
614 }))
615 .unwrap();
616 let paths = default_protected_paths(&real);
617 assert!(paths.iter().any(|p| p.ends_with("Orthic Labs")));
618 assert!(paths.iter().any(|p| p.ends_with("Documents")));
619 }
620}