Skip to main content

rusty_stack/installers/common/
ui_helper.rs

1//! UI installer helper — CLI arg parsing, path validation.
2//!
3//! Ports functionality from `scripts/lib/ui_installer_helper.sh`:
4//! - Struct-based parsing for `--dry-run`, `--dir`, `--force`
5//! - System path validation (blocks sensitive directories)
6//! - Git clone/update helpers
7//!
8//! # Validation Assertions
9//!
10//! - **VAL-INFRA-015**: CLI arg parsing with path validation
11
12use crate::installers::common::utils;
13use std::path::Path;
14
15// ===========================================================================
16// Types
17// ===========================================================================
18
19/// Parsed common arguments for UI installer scripts.
20///
21/// Provides struct-based parsing for `--dry-run`, `--dir <path>`, `--force`,
22/// and `--help` flags, matching the behavior of `ui_parse_common_args` from
23/// the shell scripts.
24///
25/// # Validation
26///
27/// - **VAL-INFRA-015**: CLI arg parsing with path validation
28#[derive(Debug, Clone, Default)]
29pub struct UiArgs {
30    /// Whether dry-run mode is enabled.
31    pub dry_run: bool,
32    /// The installation directory (absolute path).
33    pub install_dir: Option<String>,
34    /// Whether --force was specified (no-op for UI installers, accepted for compatibility).
35    pub force: bool,
36}
37
38impl UiArgs {
39    /// Parse command-line arguments into `UiArgs`.
40    ///
41    /// Supports:
42    /// - `--dry-run` — enable dry-run mode
43    /// - `--dir <path>` — set installation directory (must be absolute)
44    /// - `--force` — accepted for compatibility (no-op for UI installers)
45    /// - `--help` / `-h` — print usage and return `Err(2)` (matching shell behavior)
46    ///
47    /// Returns an error if:
48    /// - `--dir` is provided without a path argument
49    /// - `--dir` path is not absolute
50    /// - `--dir` targets a sensitive system directory
51    pub fn parse(args: &[&str]) -> Result<Self, UiArgError> {
52        let mut dry_run = false;
53        let mut install_dir = None;
54        let mut force = false;
55
56        let mut i = 0;
57        while i < args.len() {
58            match args[i] {
59                "--dry-run" => {
60                    dry_run = true;
61                }
62                "--force" => {
63                    force = true;
64                }
65                "--dir" => {
66                    if i + 1 >= args.len() {
67                        return Err(UiArgError::MissingValue {
68                            arg: "--dir".to_string(),
69                        });
70                    }
71                    i += 1;
72                    let dir = args[i];
73
74                    // Must be absolute
75                    if !dir.starts_with('/') {
76                        return Err(UiArgError::NotAbsolute {
77                            path: dir.to_string(),
78                        });
79                    }
80
81                    // Resolve the path (canonicalize if exists, use as-is otherwise)
82                    let resolved = if Path::new(dir).exists() {
83                        std::fs::canonicalize(dir)
84                            .map(|p| p.to_string_lossy().to_string())
85                            .unwrap_or_else(|_| dir.to_string())
86                    } else {
87                        dir.to_string()
88                    };
89
90                    // Block sensitive system paths
91                    if is_system_path(&resolved) {
92                        return Err(UiArgError::SystemPath { path: resolved });
93                    }
94
95                    install_dir = Some(resolved);
96                }
97                "--help" | "-h" => {
98                    return Err(UiArgError::HelpRequested);
99                }
100                _ => {
101                    // Unknown args are silently ignored (matching shell behavior)
102                }
103            }
104            i += 1;
105        }
106
107        Ok(Self {
108            dry_run,
109            install_dir,
110            force,
111        })
112    }
113
114    /// Get the installation directory, or a default.
115    pub fn install_dir_or<'a>(&'a self, default: &'a str) -> &'a str {
116        match &self.install_dir {
117            Some(dir) => dir.as_str(),
118            None => default,
119        }
120    }
121}
122
123/// Errors that can occur during UI argument parsing.
124#[derive(Debug)]
125pub enum UiArgError {
126    /// `--dir` was provided without a following path argument.
127    MissingValue {
128        /// The flag that was missing a value.
129        arg: String,
130    },
131    /// `--dir` path is not absolute.
132    NotAbsolute {
133        /// The non-absolute path provided.
134        path: String,
135    },
136    /// `--dir` targets a sensitive system directory.
137    SystemPath {
138        /// The system path that was blocked.
139        path: String,
140    },
141    /// `--help` was requested (exit code 2 in shell scripts).
142    HelpRequested,
143}
144
145impl std::fmt::Display for UiArgError {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            Self::MissingValue { arg } => {
149                write!(f, "Error: {arg} requires a path argument")
150            }
151            Self::NotAbsolute { path } => {
152                write!(f, "Error: --dir requires an absolute path (got: {path})")
153            }
154            Self::SystemPath { path } => {
155                write!(f, "Error: --dir targets a system directory: {path}")
156            }
157            Self::HelpRequested => {
158                write!(f, "Usage: [--dry-run] [--dir <path>] [--help]")
159            }
160        }
161    }
162}
163
164impl std::error::Error for UiArgError {}
165
166// ===========================================================================
167// Path Validation
168// ===========================================================================
169
170/// System paths that should never be used as installation targets.
171const BLOCKED_SYSTEM_PATHS: &[&str] = &[
172    "/",
173    "/usr",
174    "/bin",
175    "/sbin",
176    "/etc",
177    "/var",
178    "/boot",
179    "/dev",
180    "/proc",
181    "/sys",
182    "/opt/rocm",
183];
184
185/// Check if a path targets a sensitive system directory.
186///
187/// Returns `true` if the resolved path matches any blocked system path.
188pub fn is_system_path(path: &str) -> bool {
189    let resolved = Path::new(path);
190
191    for blocked in BLOCKED_SYSTEM_PATHS {
192        let blocked_path = Path::new(blocked);
193        if resolved == blocked_path {
194            return true;
195        }
196    }
197
198    false
199}
200
201// ===========================================================================
202// Git Clone/Update Helper
203// ===========================================================================
204
205/// Clone a git repository or update it if it already exists.
206///
207/// If the directory exists and contains a `.git` folder, performs a fetch
208/// and hard reset. Otherwise, clones the repository.
209///
210/// Supports dry-run mode — logs commands without executing.
211pub fn git_clone_or_update(install_dir: &str, repo_url: &str, dry_run: bool) -> Result<(), String> {
212    let git_dir = Path::new(install_dir).join(".git");
213
214    if git_dir.exists() {
215        // Update existing clone
216        let fetch_cmd = format!("git -C \"{install_dir}\" fetch --all");
217        utils::execute_command(&fetch_cmd, "Fetching latest changes", dry_run)?;
218
219        // Get target branch
220        let target_branch = get_current_branch(install_dir, dry_run);
221
222        let reset_cmd = format!("git -C \"{install_dir}\" reset --hard \"origin/{target_branch}\"");
223        utils::execute_command(&reset_cmd, "Resetting to latest", dry_run)?;
224    } else {
225        // Fresh clone
226        let clone_cmd = format!("git clone \"{repo_url}\" \"{install_dir}\"");
227        utils::execute_command(&clone_cmd, "Cloning repository", dry_run)?;
228    }
229
230    Ok(())
231}
232
233/// Get the current branch name of a git repository.
234fn get_current_branch(repo_dir: &str, dry_run: bool) -> String {
235    if dry_run {
236        return "main".to_string();
237    }
238
239    // Try symbolic-ref first
240    let output = std::process::Command::new("git")
241        .args(["-C", repo_dir, "symbolic-ref", "refs/remotes/origin/HEAD"])
242        .output();
243
244    if let Ok(out) = output {
245        if out.status.success() {
246            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
247            if let Some(branch) = stdout.strip_prefix("refs/remotes/origin/") {
248                return branch.to_string();
249            }
250        }
251    }
252
253    // Fallback: try rev-parse
254    let output = std::process::Command::new("git")
255        .args(["-C", repo_dir, "rev-parse", "--abbrev-ref", "HEAD"])
256        .output();
257
258    if let Ok(out) = output {
259        if out.status.success() {
260            let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
261            if !branch.is_empty() {
262                return branch;
263            }
264        }
265    }
266
267    "main".to_string()
268}
269
270/// Fix file ownership when run with sudo.
271///
272/// If running as root with `SUDO_USER` set, changes ownership of the
273/// install directory to the sudo user.
274#[cfg(all(unix, feature = "unix-deps"))]
275pub fn fix_ownership(install_dir: &str) -> Result<(), String> {
276    let euid = unsafe { libc::geteuid() };
277    if euid != 0 {
278        return Ok(());
279    }
280
281    let sudo_user = match std::env::var("SUDO_USER") {
282        Ok(v) if !v.is_empty() => v,
283        _ => return Ok(()),
284    };
285
286    let cmd = format!("chown -R {sudo_user}:{sudo_user} \"{install_dir}\"");
287    let result = std::process::Command::new("bash")
288        .arg("-c")
289        .arg(&cmd)
290        .output();
291
292    match result {
293        Ok(output) if output.status.success() => Ok(()),
294        Ok(output) => {
295            let stderr = String::from_utf8_lossy(&output.stderr);
296            Err(format!("Failed to fix ownership: {stderr}"))
297        }
298        Err(e) => Err(format!("Failed to execute chown: {e}")),
299    }
300}
301
302#[cfg(not(all(unix, feature = "unix-deps")))]
303pub fn fix_ownership(_install_dir: &str) -> Result<(), String> {
304    Ok(())
305}
306
307// ===========================================================================
308// Tests
309// ===========================================================================
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    // --- UiArgs parsing tests (VAL-INFRA-015) ---
316
317    #[test]
318    fn test_parse_empty_args() {
319        let args = UiArgs::parse(&[]).unwrap();
320        assert!(!args.dry_run);
321        assert!(args.install_dir.is_none());
322        assert!(!args.force);
323    }
324
325    #[test]
326    fn test_parse_dry_run() {
327        let args = UiArgs::parse(&["--dry-run"]).unwrap();
328        assert!(args.dry_run);
329    }
330
331    #[test]
332    fn test_parse_force() {
333        let args = UiArgs::parse(&["--force"]).unwrap();
334        assert!(args.force);
335    }
336
337    #[test]
338    fn test_parse_dir_valid() {
339        let temp_dir = tempfile::tempdir().unwrap();
340        let temp_path = temp_dir.path().to_string_lossy().to_string();
341        let args = UiArgs::parse(&["--dir", &temp_path]).unwrap();
342        assert!(args.install_dir.is_some());
343    }
344
345    #[test]
346    fn test_parse_dir_nonexistent_absolute() {
347        // Nonexistent but absolute path should still work
348        let args = UiArgs::parse(&["--dir", "/tmp/nonexistent_test_dir_xyz"]).unwrap();
349        assert_eq!(
350            args.install_dir,
351            Some("/tmp/nonexistent_test_dir_xyz".to_string())
352        );
353    }
354
355    #[test]
356    fn test_parse_dir_missing_value() {
357        let result = UiArgs::parse(&["--dir"]);
358        assert!(result.is_err());
359        let err = result.unwrap_err();
360        assert!(matches!(err, UiArgError::MissingValue { .. }));
361        assert!(format!("{err}").contains("--dir requires a path argument"));
362    }
363
364    #[test]
365    fn test_parse_dir_not_absolute() {
366        let result = UiArgs::parse(&["--dir", "relative/path"]);
367        assert!(result.is_err());
368        let err = result.unwrap_err();
369        assert!(matches!(err, UiArgError::NotAbsolute { .. }));
370    }
371
372    #[test]
373    fn test_parse_dir_system_path_root() {
374        let result = UiArgs::parse(&["--dir", "/"]);
375        assert!(result.is_err());
376        let err = result.unwrap_err();
377        assert!(matches!(err, UiArgError::SystemPath { .. }));
378    }
379
380    #[test]
381    fn test_parse_dir_system_path_usr() {
382        let result = UiArgs::parse(&["--dir", "/usr"]);
383        assert!(result.is_err());
384    }
385
386    #[test]
387    fn test_parse_dir_system_path_bin() {
388        // /bin may be a symlink to /usr/bin on some systems, so test with
389        // the canonical path check instead
390        let result = is_system_path("/bin");
391        assert!(result);
392    }
393
394    #[test]
395    fn test_parse_dir_system_path_sbin() {
396        // /sbin may be a symlink on some systems
397        let result = is_system_path("/sbin");
398        assert!(result);
399    }
400
401    #[test]
402    fn test_parse_dir_system_path_etc() {
403        let result = UiArgs::parse(&["--dir", "/etc"]);
404        assert!(result.is_err());
405    }
406
407    #[test]
408    fn test_parse_dir_system_path_var() {
409        let result = UiArgs::parse(&["--dir", "/var"]);
410        assert!(result.is_err());
411    }
412
413    #[test]
414    fn test_parse_dir_system_path_rocm() {
415        let result = UiArgs::parse(&["--dir", "/opt/rocm"]);
416        assert!(result.is_err());
417    }
418
419    #[test]
420    fn test_parse_help_long() {
421        let result = UiArgs::parse(&["--help"]);
422        assert!(result.is_err());
423        assert!(matches!(result.unwrap_err(), UiArgError::HelpRequested));
424    }
425
426    #[test]
427    fn test_parse_help_short() {
428        let result = UiArgs::parse(&["-h"]);
429        assert!(result.is_err());
430        assert!(matches!(result.unwrap_err(), UiArgError::HelpRequested));
431    }
432
433    #[test]
434    fn test_parse_combined_args() {
435        let temp_dir = tempfile::tempdir().unwrap();
436        let temp_path = temp_dir.path().to_string_lossy().to_string();
437        let args = UiArgs::parse(&["--dry-run", "--force", "--dir", &temp_path]).unwrap();
438        assert!(args.dry_run);
439        assert!(args.force);
440        assert!(args.install_dir.is_some());
441    }
442
443    #[test]
444    fn test_parse_unknown_args_ignored() {
445        let args = UiArgs::parse(&["--unknown", "value"]).unwrap();
446        assert!(!args.dry_run);
447    }
448
449    #[test]
450    fn test_install_dir_or_default() {
451        let args = UiArgs::parse(&[]).unwrap();
452        assert_eq!(args.install_dir_or("/default"), "/default");
453
454        let temp_dir = tempfile::tempdir().unwrap();
455        let temp_path = temp_dir.path().to_string_lossy().to_string();
456        let args = UiArgs::parse(&["--dir", &temp_path]).unwrap();
457        assert_ne!(args.install_dir_or("/default"), "/default");
458    }
459
460    #[test]
461    fn test_ui_args_default() {
462        let args = UiArgs::default();
463        assert!(!args.dry_run);
464        assert!(args.install_dir.is_none());
465        assert!(!args.force);
466    }
467
468    // --- is_system_path tests ---
469
470    #[test]
471    fn test_is_system_path_blocked() {
472        assert!(is_system_path("/"));
473        assert!(is_system_path("/usr"));
474        assert!(is_system_path("/bin"));
475        assert!(is_system_path("/sbin"));
476        assert!(is_system_path("/etc"));
477        assert!(is_system_path("/var"));
478        assert!(is_system_path("/boot"));
479        assert!(is_system_path("/dev"));
480        assert!(is_system_path("/proc"));
481        assert!(is_system_path("/sys"));
482        assert!(is_system_path("/opt/rocm"));
483    }
484
485    #[test]
486    fn test_is_system_path_safe() {
487        assert!(!is_system_path("/home/user/apps"));
488        assert!(!is_system_path("/opt/mlstack"));
489        assert!(!is_system_path("/tmp/test"));
490        assert!(!is_system_path("/usr/local/apps/myapp"));
491    }
492
493    // --- UiArgError Display ---
494
495    #[test]
496    fn test_ui_arg_error_display() {
497        let err = UiArgError::MissingValue {
498            arg: "--dir".to_string(),
499        };
500        assert!(format!("{err}").contains("--dir requires a path argument"));
501
502        let err = UiArgError::NotAbsolute {
503            path: "relative".to_string(),
504        };
505        assert!(format!("{err}").contains("absolute path"));
506
507        let err = UiArgError::SystemPath {
508            path: "/usr".to_string(),
509        };
510        assert!(format!("{err}").contains("system directory"));
511
512        let err = UiArgError::HelpRequested;
513        assert!(format!("{err}").contains("Usage"));
514    }
515
516    #[test]
517    fn test_ui_arg_error_is_std_error() {
518        fn assert_error<E: std::error::Error>(_: &E) {}
519        let err = UiArgError::MissingValue {
520            arg: "test".to_string(),
521        };
522        assert_error(&err);
523    }
524
525    // --- git_clone_or_update tests ---
526
527    #[test]
528    fn test_git_clone_dry_run() {
529        // Dry run should succeed without actually cloning
530        let result = git_clone_or_update(
531            "/tmp/test_clone_dir",
532            "https://github.com/example/repo.git",
533            true,
534        );
535        assert!(result.is_ok());
536    }
537
538    // --- fix_ownership tests ---
539
540    #[test]
541    fn test_fix_ownership_no_sudo() {
542        // When not running as root, this should be a no-op
543        let result = fix_ownership("/tmp/test");
544        // May or may not succeed depending on permissions, but shouldn't panic
545        let _ = result;
546    }
547}