1use crate::util::{canonical_plain, err, Result};
13use std::path::{Path, PathBuf};
14
15pub const STAGE_ENV: &str = "RIGHT_RELEASE_QA_STAGE";
17pub const STAGE_INSTALLED: &str = "installed";
19
20#[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
40pub 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
45pub 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
64pub 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
90fn 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
114pub 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
123pub 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 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
169pub 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}