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 pub fn app_dir(&self, app: &AppId) -> PathBuf {
184 self.path.join(app.dir_name)
185 }
186}
187
188#[derive(Debug, Clone)]
190pub enum Mode {
191 Release,
192 Test(TestRoot),
193}
194
195impl Mode {
196 pub fn is_test(&self) -> bool {
197 matches!(self, Mode::Test(_))
198 }
199
200 pub fn test_root(&self) -> Option<&TestRoot> {
202 match self {
203 Mode::Test(root) => Some(root),
204 Mode::Release => None,
205 }
206 }
207
208 #[cfg(feature = "test-mode")]
211 #[doc(hidden)]
212 pub fn selector_env(&self, name: &str) -> Option<String> {
213 match self {
214 Mode::Test(_) => std::env::var(name).ok().filter(|v| !v.trim().is_empty()),
215 Mode::Release => None,
216 }
217 }
218}
219
220#[cfg(feature = "test-mode")]
224#[macro_export]
225macro_rules! test_selector {
226 ($mode:expr, $name:literal) => {
227 $crate::mode::Mode::selector_env(&$mode, $name)
228 };
229}
230
231#[cfg(not(feature = "test-mode"))]
233#[macro_export]
234macro_rules! test_selector {
235 ($mode:expr, $name:literal) => {{
236 let _ = &$mode;
237 ::core::option::Option::<::std::string::String>::None
238 }};
239}
240
241pub trait ModeInput {
244 fn env(&self, name: &str) -> Option<String>;
246
247 fn per_user_default(&self) -> Result<Suite, SuiteError> {
249 Suite::per_user_default()
250 }
251
252 fn protected_paths(&self, real: &Suite) -> Vec<PathBuf> {
256 default_protected_paths(real)
257 }
258}
259
260#[derive(Debug, Clone, Copy, Default)]
262pub struct ProcessInput;
263
264impl ModeInput for ProcessInput {
265 fn env(&self, name: &str) -> Option<String> {
266 std::env::var(name).ok()
267 }
268}
269
270pub fn default_protected_paths(real: &Suite) -> Vec<PathBuf> {
273 let mut out = Vec::new();
274 let Some(orthic) = real.root().parent() else {
275 return out;
276 };
277 out.push(orthic.to_path_buf());
278 if orthic.file_name().and_then(|n| n.to_str()) == Some(SUITE_DIR_NAME) {
281 if let Some(home) = orthic.ancestors().nth(3) {
282 for dir in user_content_dirs() {
283 out.push(home.join(dir));
284 }
285 }
286 }
287 out
288}
289
290fn user_content_dirs() -> [&'static str; 7] {
291 [
292 "Documents",
293 "Pictures",
294 "Desktop",
295 "Downloads",
296 "Movies",
297 "Music",
298 "Videos",
299 ]
300}
301
302#[derive(Debug, Clone)]
304pub struct AppRoots {
305 pub suite: Suite,
307 pub app_dir: PathBuf,
309}
310
311pub fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
315 #[cfg(feature = "test-mode")]
316 {
317 capable::decide(app, input)
318 }
319 #[cfg(not(feature = "test-mode"))]
320 {
321 let _ = (app, input);
322 Ok(Mode::Release)
323 }
324}
325
326pub fn init_or_exit(app: &AppId) -> &'static Mode {
329 static MODE: OnceLock<Mode> = OnceLock::new();
330 if let Some(mode) = MODE.get() {
331 return mode;
332 }
333 match decide(app, &ProcessInput) {
334 Ok(mode) => MODE.get_or_init(|| mode),
335 Err(refusal) => {
336 eprintln!("{refusal}");
337 std::process::exit(2);
338 }
339 }
340}
341
342pub fn roots(app: &AppId, mode: &Mode) -> Result<AppRoots, SuiteError> {
344 roots_with(app, mode, &ProcessInput)
345}
346
347pub fn roots_with(app: &AppId, mode: &Mode, input: &dyn ModeInput) -> Result<AppRoots, SuiteError> {
349 match mode {
350 Mode::Release => {
351 let suite = input.per_user_default()?;
352 let orthic = suite
353 .root()
354 .parent()
355 .ok_or_else(|| SuiteError::NoRoot("the per-user suite root has no parent".into()))?
356 .to_path_buf();
357 Ok(AppRoots {
358 suite,
359 app_dir: orthic.join(app.dir_name),
360 })
361 }
362 Mode::Test(root) => Ok(AppRoots {
363 suite: root.suite(),
364 app_dir: root.app_dir(app),
365 }),
366 }
367}
368
369#[cfg(feature = "test-mode")]
371mod capable {
372 use super::*;
373 use std::fs;
374
375 fn truthy(v: &str) -> bool {
376 let t = v.trim();
377 !(t.is_empty() || t == "0" || t.eq_ignore_ascii_case("false"))
378 }
379
380 pub(super) fn decide(app: &AppId, input: &dyn ModeInput) -> Result<Mode, Refusal> {
381 let activated = input
382 .env(SELECTOR_ACTIVATION)
383 .filter(|v| truthy(v))
384 .is_some();
385 let root = input.env(SELECTOR_ROOT).filter(|v| !v.trim().is_empty());
386 match (activated, root) {
387 (false, None) => Ok(Mode::Release),
388 (false, Some(_)) => Err(Refusal::RootWithoutActivation),
389 (true, None) => Err(Refusal::ActivationWithoutRoot),
390 (true, Some(root)) => verify(app, input, &root).map(Mode::Test),
391 }
392 }
393
394 fn map_refusal(refusal: rightkit_fs::test_mode::Refusal) -> Refusal {
395 use rightkit_fs::test_mode::Refusal as FsRefusal;
396 match refusal {
397 FsRefusal::ActivationWithoutRoot => Refusal::ActivationWithoutRoot,
398 FsRefusal::RootWithoutActivation => Refusal::RootWithoutActivation,
399 FsRefusal::RootNotAbsolute => Refusal::RootNotAbsolute,
400 FsRefusal::RootNotDirectory => Refusal::RootNotDirectory,
401 FsRefusal::RootHasSymlink => Refusal::RootHasSymlink,
402 FsRefusal::RootOverlapsUserData => Refusal::RootOverlapsUserData,
403 FsRefusal::RootOnRemovableOrCloud => Refusal::RootOnRemovableOrCloud,
404 FsRefusal::RootNotPrivate => Refusal::RootNotPrivate,
405 FsRefusal::RootNotCreatedByRunner => Refusal::RootNotCreatedByRunner,
406 FsRefusal::BindingAppMismatch => Refusal::BindingAppMismatch,
407 FsRefusal::UserRootUnknown => Refusal::UserRootUnknown,
408 FsRefusal::Io(e) => Refusal::Io(e),
409 other => Refusal::Io(other.rule().to_string()),
410 }
411 }
412
413 fn check_binding(app: &AppId, canon: &Path) -> Result<(), Refusal> {
414 let bytes =
415 fs::read(canon.join(BINDING_FILE)).map_err(|_| Refusal::RootNotCreatedByRunner)?;
416 let value: serde_json::Value =
417 serde_json::from_slice(&bytes).map_err(|_| Refusal::RootNotCreatedByRunner)?;
418 match value.get("app").and_then(|v| v.as_str()) {
419 None => Err(Refusal::RootNotCreatedByRunner),
420 Some(bound) if bound == app.id => Ok(()),
421 Some(_) => Err(Refusal::BindingAppMismatch),
422 }
423 }
424
425 fn verify(app: &AppId, input: &dyn ModeInput, raw: &str) -> Result<TestRoot, Refusal> {
426 let real = input
427 .per_user_default()
428 .map_err(|_| Refusal::UserRootUnknown)?;
429 let mut protected = input.protected_paths(&real);
430 protected.push(real.root().to_path_buf());
431 let validated =
432 rightkit_fs::test_mode::validate_root(app.id, Path::new(raw.trim()), &protected)
433 .map_err(map_refusal)?;
434 let suite_path = validated.child(Path::new("shared")).map_err(map_refusal)?;
435 let suite = Suite::at(suite_path).map_err(|e| Refusal::Io(e.to_string()))?;
436 Ok(TestRoot {
437 path: validated.path().to_path_buf(),
438 suite,
439 app_id: app.id,
440 })
441 }
442
443 impl TestRoot {
444 pub fn reset(&self) -> Result<(), Refusal> {
448 let app = AppId {
449 id: self.app_id,
450 dir_name: self.app_id,
451 };
452 check_binding(&app, &self.path)?;
453 for entry in fs::read_dir(&self.path)? {
454 let entry = entry?;
455 if entry.file_name().to_string_lossy().starts_with('.') {
456 continue;
457 }
458 let ty = entry.file_type()?;
459 if ty.is_dir() && !ty.is_symlink() {
460 fs::remove_dir_all(entry.path())?;
461 } else {
462 fs::remove_file(entry.path())?;
463 }
464 }
465 Ok(())
466 }
467 }
468}