Skip to main content

pacsea/install/
utils.rs

1#[cfg(target_os = "windows")]
2/// What: Resolve an executable on `PATH` (Windows).
3///
4/// Input:
5/// - `cmd`: Executable name to probe.
6///
7/// Output:
8/// - `Some(path)` when `which` resolves the command; otherwise `None`.
9///
10/// Details:
11/// - Uses the `which` crate so `PATHEXT` and Windows search rules match `command_on_path`.
12#[must_use]
13pub fn resolve_command_on_path(cmd: &str) -> Option<std::path::PathBuf> {
14    which::which(cmd).ok()
15}
16
17#[cfg(target_os = "windows")]
18/// What: Determine whether a command is available on the Windows `PATH`.
19///
20/// Input:
21/// - `cmd`: Executable name to probe.
22///
23/// Output:
24/// - `true` when the command resolves via the `which` crate; otherwise `false`.
25///
26/// Details:
27/// - Delegates to `resolve_command_on_path` so presence matches actual resolution.
28#[must_use]
29pub fn command_on_path(cmd: &str) -> bool {
30    resolve_command_on_path(cmd).is_some()
31}
32
33#[cfg(target_os = "windows")]
34/// What: Check if `PowerShell` is available on Windows.
35///
36/// Output:
37/// - `true` when `PowerShell` can be found on PATH; otherwise `false`.
38///
39/// Details:
40/// - Checks for `powershell.exe` or `pwsh.exe` (`PowerShell` Core) on the system.
41pub fn is_powershell_available() -> bool {
42    command_on_path("powershell.exe") || command_on_path("pwsh.exe")
43}
44
45#[cfg(target_os = "windows")]
46/// What: Build a safe `cmd.exe` echo command for arbitrary text.
47///
48/// Inputs:
49/// - `msg`: Message text displayed by `cmd /K`.
50///
51/// Output:
52/// - A command string beginning with `echo(` and escaped for `cmd.exe`.
53///
54/// Details:
55/// - Escapes command metacharacters so text cannot break out of the echo context.
56/// - Doubles `%` to prevent environment-variable expansion.
57/// - Escapes `!` to avoid delayed-expansion surprises.
58/// - Converts newline boundaries into chained `echo(` calls.
59#[must_use]
60pub fn cmd_echo_command(msg: &str) -> String {
61    let mut out = String::from("echo(");
62    for ch in msg.chars() {
63        match ch {
64            '\r' => {}
65            '\n' => out.push_str(" & echo("),
66            '^' | '&' | '|' | '<' | '>' | '(' | ')' => {
67                out.push('^');
68                out.push(ch);
69            }
70            '%' => out.push_str("%%"),
71            '!' => out.push_str("^!"),
72            _ => out.push(ch),
73        }
74    }
75    out
76}
77
78#[cfg(not(target_os = "windows"))]
79/// What: Return whether `p` is a regular file with at least one executable bit set (Unix).
80///
81/// Input:
82/// - `p`: Filesystem path to inspect.
83///
84/// Output:
85/// - `true` when the path is a file and mode includes any execute bit; otherwise `false`.
86///
87/// Details:
88/// - Used by `resolve_command_on_path` and terminal discovery so “on PATH” matches the shell.
89#[must_use]
90fn path_is_executable(p: &std::path::Path) -> bool {
91    if !p.is_file() {
92        return false;
93    }
94    #[cfg(unix)]
95    {
96        use std::os::unix::fs::PermissionsExt;
97        std::fs::metadata(p).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
98    }
99    #[cfg(not(unix))]
100    {
101        true
102    }
103}
104
105#[cfg(not(target_os = "windows"))]
106/// What: Resolve an executable on `PATH` or by explicit path (Unix).
107///
108/// Input:
109/// - `cmd`: Program basename or path containing `MAIN_SEPARATOR`.
110///
111/// Output:
112/// - `Some(path)` for the first executable match; otherwise `None`.
113///
114/// Details:
115/// - Honour Unix permission bits so a non-executable file on `PATH` is not treated as a tool.
116#[must_use]
117pub fn resolve_command_on_path(cmd: &str) -> Option<std::path::PathBuf> {
118    use std::path::Path;
119
120    if cmd.contains(std::path::MAIN_SEPARATOR) {
121        let p = Path::new(cmd);
122        return path_is_executable(p).then(|| p.to_path_buf());
123    }
124
125    let paths = std::env::var_os("PATH")?;
126    for dir in std::env::split_paths(&paths) {
127        let candidate = dir.join(cmd);
128        if path_is_executable(&candidate) {
129            return Some(candidate);
130        }
131    }
132    None
133}
134
135#[cfg(not(target_os = "windows"))]
136/// What: Determine whether a command is available on the Unix `PATH`.
137///
138/// Input:
139/// - `cmd`: Program name or explicit path to inspect.
140///
141/// Output:
142/// - `true` when an executable file is found and marked executable.
143///
144/// Details:
145/// - Same rules as `resolve_command_on_path`; kept as a convenience for boolean checks.
146#[must_use]
147pub fn command_on_path(cmd: &str) -> bool {
148    resolve_command_on_path(cmd).is_some()
149}
150
151#[cfg(not(target_os = "windows"))]
152/// What: Locate the first available terminal executable from a preference list.
153///
154/// Input:
155/// - `terms`: Tuples of `(binary, args, needs_xfce_command)` ordered by preference.
156///
157/// Output:
158/// - `Some(index)` pointing into `terms` when a binary is found; otherwise `None`.
159///
160/// Details:
161/// - Iterates directories in `PATH`, favouring the earliest match respecting executable bits.
162pub fn choose_terminal_index_prefer_path(terms: &[(&str, &[&str], bool)]) -> Option<usize> {
163    if let Some(paths) = std::env::var_os("PATH") {
164        for dir in std::env::split_paths(&paths) {
165            for (i, (name, _args, _hold)) in terms.iter().enumerate() {
166                let candidate = dir.join(name);
167                if path_is_executable(&candidate) {
168                    return Some(i);
169                }
170            }
171        }
172    }
173    None
174}
175
176/// What: Safely single-quote an arbitrary string for POSIX shells.
177///
178/// Input:
179/// - `s`: Text to quote.
180///
181/// Output:
182/// - New string wrapped in single quotes, escaping embedded quotes via the `'
183///   '"'"'` sequence.
184///
185/// Details:
186/// - Returns `''` for empty input so the shell treats it as an empty argument.
187#[must_use]
188pub fn shell_single_quote(s: &str) -> String {
189    if s.is_empty() {
190        return "''".to_string();
191    }
192    let mut out = String::with_capacity(s.len() + 2);
193    out.push('\'');
194    for ch in s.chars() {
195        if ch == '\'' {
196            out.push_str("'\"'\"'");
197        } else {
198            out.push(ch);
199        }
200    }
201    out.push('\'');
202    out
203}
204
205/// What: Check whether a package name matches the strict allowlist used for shell-bound install commands.
206///
207/// Input:
208/// - `name`: Candidate package name to validate.
209///
210/// Output:
211/// - `true` when `name` is non-empty and every byte is one of `a-z`, `0-9`, `@`, `.`, `_`, `+`, `-`.
212///
213/// Details:
214/// - This is a defense-in-depth gate before shell interpolation, matching security guidance from the audit.
215/// - The validator is intentionally strict and accepts only lowercase ASCII letters.
216#[must_use]
217pub fn is_safe_package_name(name: &str) -> bool {
218    !name.is_empty()
219        && name.bytes().all(|byte| {
220            byte.is_ascii_lowercase()
221                || byte.is_ascii_digit()
222                || matches!(byte, b'@' | b'.' | b'_' | b'+' | b'-')
223        })
224}
225
226/// What: Validate a list of package names against the strict install-command allowlist.
227///
228/// Inputs:
229/// - `names`: Package names that will be interpolated into shell command strings.
230/// - `context`: Human-readable operation context for actionable error messages.
231///
232/// Output:
233/// - `Ok(())` when all names are valid, otherwise `Err` with the first invalid package.
234///
235/// Details:
236/// - Centralises validation so all install builders apply the same safety policy.
237/// - Call this before quoting/interpolation and abort command construction when validation fails.
238pub fn validate_package_names(names: &[String], context: &str) -> Result<(), String> {
239    if let Some(invalid) = names
240        .iter()
241        .find(|name| !is_safe_package_name(name.as_str()))
242    {
243        return Err(format!(
244            "Invalid package name '{invalid}' for {context}. Allowed pattern: ^[a-z\\d@._+-]+$"
245        ));
246    }
247    Ok(())
248}
249
250#[cfg(not(target_os = "windows"))]
251/// Fallback message when no terminal editor is found. Must not contain single quotes (used inside `echo '...'` in shell).
252/// Reusable for i18n or logging if needed.
253const EDITOR_FALLBACK_MESSAGE: &str = "No terminal editor found (nvim/vim/emacsclient/emacs/hx/helix/nano). Set VISUAL or EDITOR to use your preferred editor.";
254
255#[cfg(not(target_os = "windows"))]
256/// What: Build a shell command string that opens a config file in the user's preferred terminal editor.
257///
258/// Inputs:
259/// - `path`: Path to the config file to open.
260///
261/// Output:
262/// - A single shell expression that tries, in order: `$VISUAL`, `$EDITOR`, then the built-in
263///   fallback chain (nvim, vim, hx, helix, emacsclient, emacs, nano), and finally a fallback
264///   message with `read -rn1 -s _`.
265///
266/// Details:
267/// - The script expects `VISUAL`/`EDITOR` to be runnable commands that accept a file path.
268/// - The path is passed through `shell_single_quote` so paths with spaces or single quotes are safe.
269/// - Order: VISUAL then EDITOR then nvim → vim → hx → helix → emacsclient -t → emacs -nw → nano.
270#[must_use]
271pub fn editor_open_config_command(path: &std::path::Path) -> String {
272    let path_str = path.display().to_string();
273    let path_quoted = shell_single_quote(&path_str);
274    // path_quoted is already single-quoted, so the full argument to echo is one safe string.
275    format!(
276        "( [ -n \"${{VISUAL}}\" ] && command -v \"${{VISUAL%% *}}\" >/dev/null 2>&1 && eval \"${{VISUAL}}\" {path_quoted} ) || \
277         ( [ -n \"${{EDITOR}}\" ] && command -v \"${{EDITOR%% *}}\" >/dev/null 2>&1 && eval \"${{EDITOR}}\" {path_quoted} ) || \
278         ((command -v nvim >/dev/null 2>&1 || pacman -Qi neovim >/dev/null 2>&1) && nvim {path_quoted}) || \
279         ((command -v vim >/dev/null 2>&1 || pacman -Qi vim >/dev/null 2>&1) && vim {path_quoted}) || \
280         ((command -v hx >/dev/null 2>&1 || pacman -Qi helix >/dev/null 2>&1) && hx {path_quoted}) || \
281         ((command -v helix >/dev/null 2>&1 || pacman -Qi helix >/dev/null 2>&1) && helix {path_quoted}) || \
282         ((command -v emacsclient >/dev/null 2>&1 || pacman -Qi emacs >/dev/null 2>&1) && emacsclient -t {path_quoted}) || \
283         ((command -v emacs >/dev/null 2>&1 || pacman -Qi emacs >/dev/null 2>&1) && emacs -nw {path_quoted}) || \
284         ((command -v nano >/dev/null 2>&1 || pacman -Qi nano >/dev/null 2>&1) && nano {path_quoted}) || \
285         (echo '{EDITOR_FALLBACK_MESSAGE}'; echo 'File: {path_quoted}'; read -rn1 -s _ || true)"
286    )
287}
288
289#[cfg(all(test, not(target_os = "windows")))]
290mod tests {
291    #[test]
292    /// What: Validate that `command_on_path` recognises executables present on the customised `PATH`.
293    ///
294    /// Inputs:
295    /// - Temporary directory containing a shim `mycmd` script made executable.
296    /// - Environment `PATH` overridden to reference only the temp directory.
297    ///
298    /// Output:
299    /// - Returns `true` for `mycmd` and `false` for a missing binary, confirming detection logic.
300    ///
301    /// Details:
302    /// - Restores the original `PATH` and cleans up the temporary directory after assertions.
303    fn utils_command_on_path_detects_executable() {
304        use std::fs;
305        use std::os::unix::fs::PermissionsExt;
306        use std::path::PathBuf;
307
308        let mut dir: PathBuf = std::env::temp_dir();
309        dir.push(format!(
310            "pacsea_test_utils_path_{}_{}",
311            std::process::id(),
312            std::time::SystemTime::now()
313                .duration_since(std::time::UNIX_EPOCH)
314                .expect("System time is before UNIX epoch")
315                .as_nanos()
316        ));
317        let _ = fs::create_dir_all(&dir);
318        let mut cmd_path = dir.clone();
319        cmd_path.push("mycmd");
320        fs::write(&cmd_path, b"#!/bin/sh\nexit 0\n").expect("Failed to write test command script");
321        let mut perms = fs::metadata(&cmd_path)
322            .expect("Failed to read test command script metadata")
323            .permissions();
324        perms.set_mode(0o755);
325        fs::set_permissions(&cmd_path, perms)
326            .expect("Failed to set test command script permissions");
327
328        let orig_path = std::env::var_os("PATH");
329        unsafe { std::env::set_var("PATH", dir.display().to_string()) };
330        assert!(super::command_on_path("mycmd"));
331        assert_eq!(
332            super::resolve_command_on_path("mycmd").as_deref(),
333            Some(cmd_path.as_path())
334        );
335        assert!(!super::command_on_path("notexist"));
336        assert!(super::resolve_command_on_path("notexist").is_none());
337        unsafe {
338            if let Some(v) = orig_path {
339                std::env::set_var("PATH", v);
340            } else {
341                std::env::remove_var("PATH");
342            }
343        }
344        let _ = fs::remove_dir_all(&dir);
345    }
346
347    #[test]
348    /// What: Ensure a non-executable file on `PATH` is not resolved as a command.
349    ///
350    /// Inputs:
351    /// - Temporary directory with a `stub` file mode `0o644` prepended to `PATH`.
352    ///
353    /// Output:
354    /// - `command_on_path` is `false` and `resolve_command_on_path` is `None`.
355    ///
356    /// Details:
357    /// - Guards parity with shell behaviour for PKGBUILD check tool discovery.
358    fn utils_resolve_command_on_path_skips_non_executable_file() {
359        use std::fs;
360        use std::os::unix::fs::PermissionsExt;
361        use std::path::PathBuf;
362
363        let mut dir: PathBuf = std::env::temp_dir();
364        dir.push(format!(
365            "pacsea_test_utils_notexec_{}_{}",
366            std::process::id(),
367            std::time::SystemTime::now()
368                .duration_since(std::time::UNIX_EPOCH)
369                .expect("System time is before UNIX epoch")
370                .as_nanos()
371        ));
372        let _ = fs::create_dir_all(&dir);
373        let mut stub = dir.clone();
374        stub.push("stub");
375        fs::write(&stub, b"not runnable\n").expect("Failed to write stub file");
376        let mut perms = fs::metadata(&stub)
377            .expect("Failed to read stub metadata")
378            .permissions();
379        perms.set_mode(0o644);
380        fs::set_permissions(&stub, perms).expect("Failed to set non-executable permissions");
381
382        let orig_path = std::env::var_os("PATH");
383        unsafe { std::env::set_var("PATH", dir.display().to_string()) };
384        assert!(!super::command_on_path("stub"));
385        assert!(super::resolve_command_on_path("stub").is_none());
386        unsafe {
387            if let Some(v) = orig_path {
388                std::env::set_var("PATH", v);
389            } else {
390                std::env::remove_var("PATH");
391            }
392        }
393        let _ = fs::remove_dir_all(&dir);
394    }
395
396    #[test]
397    /// What: Ensure `choose_terminal_index_prefer_path` honours the preference ordering when multiple terminals exist.
398    ///
399    /// Inputs:
400    /// - Temporary directory with an executable `kitty` shim placed on `PATH`.
401    /// - Preference list where `gnome-terminal` precedes `kitty` but is absent.
402    ///
403    /// Output:
404    /// - Function returns index `1`, selecting `kitty`, the first available terminal in the list.
405    ///
406    /// Details:
407    /// - Saves and restores the `PATH` environment variable while ensuring the temp directory is removed.
408    fn utils_choose_terminal_index_prefers_first_present_in_terms_order() {
409        use std::fs;
410        use std::os::unix::fs::PermissionsExt;
411        use std::path::PathBuf;
412
413        let mut dir: PathBuf = std::env::temp_dir();
414        dir.push(format!(
415            "pacsea_test_utils_terms_{}_{}",
416            std::process::id(),
417            std::time::SystemTime::now()
418                .duration_since(std::time::UNIX_EPOCH)
419                .expect("System time is before UNIX epoch")
420                .as_nanos()
421        ));
422        let _ = fs::create_dir_all(&dir);
423        let mut kitty = dir.clone();
424        kitty.push("kitty");
425        fs::write(&kitty, b"#!/bin/sh\nexit 0\n").expect("Failed to write test kitty script");
426        let mut perms = fs::metadata(&kitty)
427            .expect("Failed to read test kitty script metadata")
428            .permissions();
429        perms.set_mode(0o755);
430        fs::set_permissions(&kitty, perms).expect("Failed to set test kitty script permissions");
431
432        let terms: &[(&str, &[&str], bool)] =
433            &[("gnome-terminal", &[], false), ("kitty", &[], false)];
434        let orig_path = std::env::var_os("PATH");
435        unsafe { std::env::set_var("PATH", dir.display().to_string()) };
436        let idx = super::choose_terminal_index_prefer_path(terms).expect("index");
437        assert_eq!(idx, 1);
438        unsafe {
439            if let Some(v) = orig_path {
440                std::env::set_var("PATH", v);
441            } else {
442                std::env::remove_var("PATH");
443            }
444        }
445        let _ = fs::remove_dir_all(&dir);
446    }
447
448    #[test]
449    /// What: Check that `shell_single_quote` escapes edge cases safely.
450    ///
451    /// Inputs:
452    /// - Three sample strings: empty, plain ASCII, and text containing a single quote.
453    ///
454    /// Output:
455    /// - Returns properly quoted strings, using `''` for empty and the standard POSIX escape for embedded quotes.
456    ///
457    /// Details:
458    /// - Covers representative cases without filesystem interaction to guard future regressions.
459    fn utils_shell_single_quote_handles_edges() {
460        assert_eq!(super::shell_single_quote(""), "''");
461        assert_eq!(super::shell_single_quote("abc"), "'abc'");
462        assert_eq!(super::shell_single_quote("a'b"), "'a'\"'\"'b'");
463    }
464
465    #[test]
466    /// What: Verify strict package-name validator allows only the documented safe pattern.
467    ///
468    /// Inputs:
469    /// - Representative valid and invalid package names.
470    ///
471    /// Output:
472    /// - Returns `true` for valid lowercase names and `false` for disallowed characters/casing.
473    ///
474    /// Details:
475    /// - Guards the defense-in-depth allowlist used before shell interpolation.
476    fn utils_is_safe_package_name_strict_allowlist() {
477        assert!(super::is_safe_package_name("ripgrep"));
478        assert!(super::is_safe_package_name("lib32-foo+bar"));
479        assert!(super::is_safe_package_name("qt6-base@beta.1"));
480        assert!(!super::is_safe_package_name(""));
481        assert!(!super::is_safe_package_name("Ripgrep"));
482        assert!(!super::is_safe_package_name("bad;name"));
483        assert!(!super::is_safe_package_name("bad name"));
484    }
485
486    #[test]
487    /// What: Assert that `editor_open_config_command` builds a command with VISUAL then EDITOR then fallbacks.
488    ///
489    /// Inputs:
490    /// - A dummy path (e.g. `/tmp/settings.conf`).
491    ///
492    /// Output:
493    /// - The returned string contains VISUAL branch before EDITOR branch before nvim fallback.
494    ///
495    /// Details:
496    /// - Shell-only implementation; order is fixed in the string regardless of env.
497    fn utils_editor_open_config_command_order_visual_then_editor_then_fallbacks() {
498        use std::path::Path;
499        let path = Path::new("/tmp/settings.conf");
500        let cmd = super::editor_open_config_command(path);
501        let idx_visual = cmd.find("VISUAL").expect("command must mention VISUAL");
502        let idx_editor = cmd.find("EDITOR").expect("command must mention EDITOR");
503        let idx_nvim = cmd
504            .find("nvim")
505            .expect("command must mention nvim fallback");
506        assert!(idx_visual < idx_editor, "VISUAL must appear before EDITOR");
507        assert!(
508            idx_editor < idx_nvim,
509            "EDITOR must appear before nvim fallback"
510        );
511    }
512
513    #[test]
514    /// What: Assert that `editor_open_config_command` includes the full fallback chain and final message.
515    ///
516    /// Inputs:
517    /// - A dummy path.
518    ///
519    /// Output:
520    /// - The returned string contains nvim, vim, hx, helix, emacsclient, emacs, nano and "No terminal editor found".
521    ///
522    /// Details:
523    /// - Validates the built-in fallback list and fallback message without executing shell.
524    fn utils_editor_open_config_command_contains_fallback_chain_and_message() {
525        use std::path::Path;
526        let path = Path::new("/tmp/theme.conf");
527        let cmd = super::editor_open_config_command(path);
528        assert!(cmd.contains("nvim"), "fallback chain must include nvim");
529        assert!(cmd.contains("vim"), "fallback chain must include vim");
530        assert!(cmd.contains("hx"), "fallback chain must include hx");
531        assert!(cmd.contains("helix"), "fallback chain must include helix");
532        assert!(
533            cmd.contains("emacsclient"),
534            "fallback chain must include emacsclient"
535        );
536        assert!(cmd.contains("emacs"), "fallback chain must include emacs");
537        assert!(cmd.contains("nano"), "fallback chain must include nano");
538        assert!(
539            cmd.contains("No terminal editor found"),
540            "command must include fallback message"
541        );
542    }
543
544    #[test]
545    /// What: Assert that the path in `editor_open_config_command` is shell-single-quoted.
546    ///
547    /// Inputs:
548    /// - A path containing a single quote (e.g. `/tmp/foo'bar.conf`).
549    ///
550    /// Output:
551    /// - The returned string contains the safely quoted path (single-quote escape sequence), not raw path.
552    ///
553    /// Details:
554    /// - Paths with single quotes must be quoted via `shell_single_quote` so the shell sees one argument.
555    fn utils_editor_open_config_command_path_is_shell_single_quoted() {
556        use std::path::Path;
557        let path_with_quote = Path::new("/tmp/foo'bar.conf");
558        let path_str = path_with_quote.display().to_string();
559        let path_quoted = super::shell_single_quote(&path_str);
560        let cmd = super::editor_open_config_command(path_with_quote);
561        assert!(
562            cmd.contains(&path_quoted),
563            "command must contain shell-single-quoted path, got quoted: {path_quoted:?}"
564        );
565        // Raw unquoted path with single quote would break shell; must not appear as '/tmp/foo'bar.conf'
566        assert!(
567            !cmd.contains("/tmp/foo'bar.conf"),
568            "command must not contain raw path with unescaped single quote"
569        );
570    }
571}