Skip to main content

rightkit_qa/
installed.rs

1//! QA never launches an installed app. [`refuse_installed`] refuses any app bundle or executable
2//! under an installed-apps location unless installed-QA mode is on.
3//!
4//! Locations: `/Applications` and `~/Applications` (macOS); `ProgramFiles`, `ProgramFiles(x86)`,
5//! `ProgramW6432` and `%LOCALAPPDATA%\Programs` (Windows), read from the environment the way
6//! rightkit-fs `AppDirs` reads the per-user bases. Paths are canonicalised first, so a symlink or
7//! `..` cannot bypass the check.
8//!
9//! Installed-QA mode is the signal `right-release qa --stage installed` already sets for the runner
10//! it starts: `RIGHT_RELEASE_QA_STAGE=installed` ([`STAGE_ENV`]). There is no second switch.
11
12use crate::util::{canonical_plain, err, Result};
13use std::path::{Path, PathBuf};
14
15/// The environment variable `right-release qa --stage <stage>` sets for the QA runner.
16pub const STAGE_ENV: &str = "RIGHT_RELEASE_QA_STAGE";
17/// The `STAGE_ENV` value that is installed-QA mode.
18pub const STAGE_INSTALLED: &str = "installed";
19
20/// Which OS's installed-app locations to use (injected in tests).
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum Os {
23    Mac,
24    Windows,
25    Other,
26}
27
28impl Os {
29    pub fn current() -> Os {
30        if cfg!(target_os = "macos") {
31            Os::Mac
32        } else if cfg!(windows) {
33            Os::Windows
34        } else {
35            Os::Other
36        }
37    }
38}
39
40/// True when `lookup(STAGE_ENV)` is `installed` (any case, trimmed).
41pub fn installed_mode_with(lookup: &dyn Fn(&str) -> Option<String>) -> bool {
42    lookup(STAGE_ENV).is_some_and(|v| v.trim().eq_ignore_ascii_case(STAGE_INSTALLED))
43}
44
45/// Installed-QA mode in the process environment.
46pub fn installed_mode() -> bool {
47    installed_mode_with(&|k| std::env::var(k).ok())
48}
49
50fn absolute_text(os: Os, text: &str) -> bool {
51    match os {
52        Os::Windows => {
53            let b = text.as_bytes();
54            (b.len() >= 3
55                && b[0].is_ascii_alphabetic()
56                && b[1] == b':'
57                && (b[2] == b'\\' || b[2] == b'/'))
58                || text.starts_with("\\\\")
59        }
60        _ => text.starts_with('/'),
61    }
62}
63
64/// The installed-app locations of `os`, as text. `lookup` reads the environment; `home` is the
65/// user's home folder. Unset, empty or relative values are skipped.
66pub fn installed_roots(os: Os, lookup: &dyn Fn(&str) -> Option<String>, home: &str) -> Vec<String> {
67    let mut roots = Vec::new();
68    match os {
69        Os::Mac => {
70            roots.push("/Applications".to_owned());
71            if absolute_text(os, home) {
72                roots.push(format!("{}/Applications", home.trim_end_matches('/')));
73            }
74        }
75        Os::Windows => {
76            for key in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
77                if let Some(v) = lookup(key).filter(|v| absolute_text(os, v)) {
78                    roots.push(v);
79                }
80            }
81            if let Some(v) = lookup("LOCALAPPDATA").filter(|v| absolute_text(os, v)) {
82                roots.push(format!("{}\\Programs", v.trim_end_matches(['\\', '/'])));
83            }
84        }
85        Os::Other => {}
86    }
87    roots
88}
89
90/// Lexical form for comparison: verbatim prefix dropped, separators unified (Windows), `.` and
91/// `..` resolved, case folded (both macOS and Windows file systems are case-insensitive by default).
92fn fold(os: Os, text: &str) -> Vec<String> {
93    let text = match os {
94        Os::Windows => crate::util::plain_path_text(text).replace('\\', "/"),
95        _ => text.to_owned(),
96    };
97    let mut parts: Vec<String> = Vec::new();
98    for seg in text.split('/') {
99        match seg {
100            "" | "." => {}
101            ".." => {
102                parts.pop();
103            }
104            s => parts.push(if os == Os::Other {
105                s.to_owned()
106            } else {
107                s.to_lowercase()
108            }),
109        }
110    }
111    parts
112}
113
114/// Pure classification: the root `candidate` sits under (or equals), if any.
115pub fn installed_root_of<'a>(os: Os, candidate: &str, roots: &'a [String]) -> Option<&'a str> {
116    let c = fold(os, candidate);
117    roots.iter().map(String::as_str).find(|root| {
118        let r = fold(os, root);
119        !r.is_empty() && c.len() >= r.len() && c[..r.len()] == r[..]
120    })
121}
122
123/// Refusal decision with everything injected: `Err` when `candidate` is under a root and
124/// installed-QA mode is off. The message names the path and the way to opt in.
125pub fn decide(
126    os: Os,
127    what: &str,
128    candidate: &str,
129    roots: &[String],
130    installed_qa: bool,
131) -> Result<()> {
132    if installed_qa {
133        return Ok(());
134    }
135    match installed_root_of(os, candidate, roots) {
136        None => Ok(()),
137        Some(root) => err(format!(
138            "QA refuses {what} {candidate}: it is inside the installed-apps location {root}. QA drives dev or QA builds, never an installed app. \
139             To run against an installed app on purpose, use installed-QA mode: run `right-release qa --stage installed`, which sets {STAGE_ENV}={STAGE_INSTALLED}; nothing was launched"
140        )),
141    }
142}
143
144fn canonical_text(path: &Path) -> String {
145    // Resolve symlinks in the longest existing prefix and append the rest.
146    let mut base = path.to_path_buf();
147    let mut tail: Vec<std::ffi::OsString> = Vec::new();
148    loop {
149        if let Ok(real) = canonical_plain(&base) {
150            let mut out: PathBuf = real;
151            for part in tail.iter().rev() {
152                out.push(part);
153            }
154            return out.display().to_string();
155        }
156        match (
157            base.file_name().map(|n| n.to_owned()),
158            base.parent().map(Path::to_path_buf),
159        ) {
160            (Some(name), Some(parent)) if !parent.as_os_str().is_empty() => {
161                tail.push(name);
162                base = parent;
163            }
164            _ => return path.display().to_string(),
165        }
166    }
167}
168
169/// Refuse `path` (a bundle or executable about to be launched) when it is under an installed-apps
170/// location and installed-QA mode is off. `what` names the thing in the message.
171pub fn refuse_installed(path: &Path, what: &str) -> Result<()> {
172    if installed_mode() {
173        return Ok(());
174    }
175    let os = Os::current();
176    let home = std::env::var("HOME")
177        .ok()
178        .or_else(|| std::env::var("USERPROFILE").ok())
179        .unwrap_or_default();
180    let roots: Vec<String> = installed_roots(os, &|k| std::env::var(k).ok(), &home)
181        .into_iter()
182        .map(|r| canonical_text(Path::new(&r)))
183        .collect();
184    decide(os, what, &canonical_text(path), &roots, false)
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn win_env(k: &str) -> Option<String> {
192        match k {
193            "ProgramFiles" => Some(r"C:\Program Files".into()),
194            "ProgramFiles(x86)" => Some(r"C:\Program Files (x86)".into()),
195            "LOCALAPPDATA" => Some(r"C:\Users\a\AppData\Local".into()),
196            _ => None,
197        }
198    }
199
200    #[test]
201    fn mac_roots_and_classification() {
202        let roots = installed_roots(Os::Mac, &|_| None, "/Users/a");
203        assert_eq!(roots, ["/Applications", "/Users/a/Applications"]);
204        let hit = |p: &str| installed_root_of(Os::Mac, p, &roots);
205        assert_eq!(
206            hit("/Applications/CutRight.app/Contents/MacOS/CutRight"),
207            Some("/Applications")
208        );
209        assert_eq!(hit("/applications/CutRight.app"), Some("/Applications"));
210        assert_eq!(
211            hit("/Users/a/Applications/X.app"),
212            Some("/Users/a/Applications")
213        );
214        assert_eq!(
215            hit("/Users/a/dev/../../../Applications/X.app"),
216            Some("/Applications")
217        );
218        assert_eq!(hit("/ApplicationsExtra/X.app"), None);
219        assert_eq!(hit("/Users/a/dev/target/debug/X.app"), None);
220    }
221
222    #[test]
223    fn windows_roots_and_classification() {
224        let roots = installed_roots(Os::Windows, &win_env, r"C:\Users\a");
225        assert_eq!(
226            roots,
227            [
228                r"C:\Program Files",
229                r"C:\Program Files (x86)",
230                r"C:\Users\a\AppData\Local\Programs"
231            ]
232        );
233        let hit = |p: &str| installed_root_of(Os::Windows, p, &roots);
234        assert_eq!(
235            hit(r"C:\Program Files\CutRight\cutright.exe"),
236            Some(r"C:\Program Files")
237        );
238        assert_eq!(
239            hit("c:/program files (x86)/CutRight/x.exe"),
240            Some(r"C:\Program Files (x86)")
241        );
242        assert_eq!(
243            hit(r"\\?\C:\Users\a\AppData\Local\Programs\X\x.exe"),
244            Some(r"C:\Users\a\AppData\Local\Programs")
245        );
246        assert_eq!(
247            hit(r"C:\work\..\Program Files\X\x.exe"),
248            Some(r"C:\Program Files")
249        );
250        assert_eq!(hit(r"C:\Program Files Extra\x.exe"), None);
251        assert_eq!(hit(r"C:\Users\a\AppData\Local\Temp\x.exe"), None);
252    }
253
254    #[test]
255    fn unset_or_relative_windows_values_give_no_root() {
256        assert!(installed_roots(Os::Windows, &|_| Some("relative".into()), "").is_empty());
257        assert!(installed_roots(Os::Windows, &|_| None, "").is_empty());
258    }
259
260    #[test]
261    fn installed_qa_mode_reads_the_release_stage_signal() {
262        let with = |v: Option<&'static str>| {
263            installed_mode_with(&move |k| (k == STAGE_ENV).then(|| v.map(String::from)).flatten())
264        };
265        assert!(with(Some("installed")));
266        assert!(with(Some(" Installed ")));
267        assert!(!with(Some("source")));
268        assert!(!with(None));
269    }
270
271    #[test]
272    fn refuses_installed_path_unless_opted_in_and_names_path_and_opt_in() {
273        let roots = vec!["/Applications".to_owned()];
274        let e = decide(
275            Os::Mac,
276            "UI app bundle",
277            "/Applications/CutRight.app",
278            &roots,
279            false,
280        )
281        .unwrap_err();
282        assert!(e.0.contains("/Applications/CutRight.app"), "{e}");
283        assert!(e.0.contains("RIGHT_RELEASE_QA_STAGE=installed"), "{e}");
284        assert!(decide(
285            Os::Mac,
286            "UI app bundle",
287            "/Applications/CutRight.app",
288            &roots,
289            true
290        )
291        .is_ok());
292        assert!(decide(
293            Os::Mac,
294            "UI app bundle",
295            "/tmp/dev/CutRight.app",
296            &roots,
297            false
298        )
299        .is_ok());
300    }
301}