Skip to main content

pacsea/install/
shell.rs

1use std::process::Command;
2
3#[cfg(not(target_os = "windows"))]
4use super::utils::{choose_terminal_index_prefer_path, command_on_path, shell_single_quote};
5
6#[cfg(not(target_os = "windows"))]
7/// What: Spawn a terminal to run a `&&`-joined series of shell commands with a hold tail.
8///
9/// Input:
10/// - `cmds`: Ordered list of shell snippets to execute.
11///
12/// Output:
13/// - Starts the preferred terminal (or `bash`) running the composed command sequence.
14///
15/// Details:
16/// - Defers to `spawn_shell_commands_in_terminal_with_hold` to add the default hold tail.
17/// - During tests, this is a no-op to avoid opening real terminal windows, unless `PACSEA_TEST_OUT` is set.
18pub fn spawn_shell_commands_in_terminal(cmds: &[String]) {
19    // Skip actual spawning during tests unless PACSEA_TEST_OUT is set (indicates a test with fake terminal)
20    #[cfg(test)]
21    if std::env::var("PACSEA_TEST_OUT").is_err() {
22        return;
23    }
24    // Default wrapper keeps the terminal open after commands complete
25    spawn_shell_commands_in_terminal_with_hold(cmds, true);
26}
27
28#[cfg(not(target_os = "windows"))]
29/// What: Write a log message to terminal.log file.
30///
31/// Input:
32/// - `message`: The log message to write.
33///
34/// Output:
35/// - Writes the message to terminal.log, creating the log directory if needed.
36///
37/// Details:
38/// - Silently ignores errors if the log file cannot be opened or written.
39fn log_to_terminal_log(message: &str) {
40    let mut lp = crate::theme::logs_dir();
41    lp.push("terminal.log");
42    if let Some(parent) = lp.parent() {
43        let _ = std::fs::create_dir_all(parent);
44    }
45    if let Ok(mut file) = std::fs::OpenOptions::new()
46        .create(true)
47        .append(true)
48        .open(&lp)
49    {
50        let _ = std::io::Write::write_all(&mut file, message.as_bytes());
51    }
52}
53
54#[cfg(not(target_os = "windows"))]
55/// What: Configure environment variables for a terminal command based on terminal type and environment.
56///
57/// Input:
58/// - `cmd`: The Command to configure.
59/// - `term`: Terminal binary name.
60/// - `is_wayland`: Whether running under Wayland.
61///
62/// Output:
63/// - Modifies the command with appropriate environment variables.
64///
65/// Details:
66/// - Sets `PACSEA_TEST_OUT` if present in environment.
67/// - Suppresses `Konsole` `Wayland` warnings on `Wayland`.
68/// - Forces software rendering for `GNOME Console` and `kgx`.
69fn configure_terminal_env(cmd: &mut Command, term: &str, is_wayland: bool) {
70    if let Ok(p) = std::env::var("PACSEA_TEST_OUT") {
71        if let Some(parent) = std::path::Path::new(&p).parent() {
72            let _ = std::fs::create_dir_all(parent);
73        }
74        cmd.env("PACSEA_TEST_OUT", p);
75    }
76    if term == "konsole" && is_wayland {
77        cmd.env("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false");
78    }
79    if term == "gnome-console" || term == "kgx" {
80        cmd.env("GSK_RENDERER", "cairo");
81        cmd.env("LIBGL_ALWAYS_SOFTWARE", "1");
82    }
83}
84
85#[cfg(not(target_os = "windows"))]
86/// What: Build and spawn a terminal command with logging.
87///
88/// Input:
89/// - `term`: Terminal binary name.
90/// - `args`: Terminal arguments.
91/// - `needs_xfce_command`: Whether to use xfce4-terminal special command format.
92/// - `script_exec`: The script execution command string.
93/// - `cmd_str`: The full command string for logging.
94/// - `is_wayland`: Whether running under Wayland.
95/// - `detach_stdio`: Whether to detach stdio streams.
96///
97/// Output:
98/// - Returns `Ok(true)` if spawn succeeded, `Ok(false)` if it failed, or `Err` on error.
99///
100/// Details:
101/// - Logs spawn attempt and result to terminal.log.
102/// - Configures terminal-specific environment variables.
103fn try_spawn_terminal(
104    term: &str,
105    args: &[&str],
106    needs_xfce_command: bool,
107    script_exec: &str,
108    cmd_str: &str,
109    is_wayland: bool,
110    detach_stdio: bool,
111) -> Result<bool, std::io::Error> {
112    let mut cmd = Command::new(term);
113    if needs_xfce_command && term == "xfce4-terminal" {
114        let quoted = shell_single_quote(script_exec);
115        cmd.arg("--command").arg(format!("bash -lc {quoted}"));
116    } else {
117        cmd.args(args.iter().copied()).arg(script_exec);
118    }
119    configure_terminal_env(&mut cmd, term, is_wayland);
120    let cmd_len = cmd_str.len();
121    log_to_terminal_log(&format!(
122        "spawn term={term} args={args:?} xfce_mode={needs_xfce_command} cmd_len={cmd_len}\n"
123    ));
124    if detach_stdio {
125        cmd.stdin(std::process::Stdio::null())
126            .stdout(std::process::Stdio::null())
127            .stderr(std::process::Stdio::null());
128    }
129    let res = cmd.spawn();
130    match &res {
131        Ok(child) => {
132            log_to_terminal_log(&format!("spawn result: ok pid={}\n", child.id()));
133        }
134        Err(e) => {
135            log_to_terminal_log(&format!("spawn result: err error={e}\n"));
136        }
137    }
138    res.map(|_| true)
139}
140
141#[cfg(not(target_os = "windows"))]
142/// What: Create a temporary script file with the command string.
143///
144/// Input:
145/// - `cmd_str`: The command string to write to the script.
146///
147/// Output:
148/// - `Ok(path)` when a script is fully written and synced, otherwise an error.
149///
150/// Details:
151/// - Creates a bash script with executable permissions.
152/// - Retries unique `create_new` paths to avoid races.
153/// - Treats partial write failures as retryable and removes broken files.
154fn create_temp_script(cmd_str: &str) -> Result<std::path::PathBuf, std::io::Error> {
155    use std::io::Write;
156
157    let mut last_error: Option<std::io::Error> = None;
158    for attempt in 0_u32..8 {
159        let mut p = std::env::temp_dir();
160        let ts = std::time::SystemTime::now()
161            .duration_since(std::time::UNIX_EPOCH)
162            .map_or(0, |d| d.as_nanos());
163        p.push(format!(
164            "pacsea_scan_{}_{}_{}.sh",
165            std::process::id(),
166            ts,
167            attempt
168        ));
169
170        #[cfg(unix)]
171        {
172            use std::os::unix::fs::OpenOptionsExt;
173
174            let file_res = std::fs::OpenOptions::new()
175                .write(true)
176                .create_new(true)
177                .mode(0o700)
178                .open(&p);
179
180            match file_res {
181                Ok(mut file) => {
182                    let write_res = file.write_all(format!("#!/bin/bash\n{cmd_str}\n").as_bytes());
183                    let flush_res = write_res.and_then(|()| file.flush());
184                    if flush_res.is_ok() {
185                        return Ok(p);
186                    }
187                    let _ = std::fs::remove_file(&p);
188                    last_error = flush_res.err();
189                }
190                Err(err) => {
191                    last_error = Some(err);
192                }
193            }
194        }
195
196        #[cfg(not(unix))]
197        {
198            let file_res = std::fs::OpenOptions::new()
199                .write(true)
200                .create_new(true)
201                .open(&p);
202
203            match file_res {
204                Ok(mut file) => {
205                    let write_res = file.write_all(format!("#!/bin/bash\n{cmd_str}\n").as_bytes());
206                    let flush_res = write_res.and_then(|()| file.flush());
207                    if flush_res.is_ok() {
208                        return Ok(p);
209                    }
210                    let _ = std::fs::remove_file(&p);
211                    last_error = flush_res.err();
212                }
213                Err(err) => {
214                    last_error = Some(err);
215                }
216            }
217        }
218    }
219
220    Err(last_error
221        .unwrap_or_else(|| std::io::Error::other("failed to create and write temporary script")))
222}
223
224#[cfg(not(target_os = "windows"))]
225/// What: Persist the command string to a log file for debugging.
226///
227/// Inputs:
228/// - `cmd_str`: The command string to log.
229///
230/// Output:
231/// - None (writes to log file).
232///
233/// Details:
234/// - Redacts password-bearing `printf '%s\n' ... | sudo -S` segments before persistence.
235/// - Enforces mode `0o600` on Unix for both newly created and pre-existing log files.
236fn persist_command_to_log(cmd_str: &str) {
237    let mut lp = crate::theme::logs_dir();
238    lp.push("last_terminal_cmd.log");
239    if let Some(parent) = lp.parent() {
240        let _ = std::fs::create_dir_all(parent);
241    }
242    persist_command_to_log_path(&lp, cmd_str);
243}
244
245#[cfg(not(target_os = "windows"))]
246/// What: Persist a command string to a specific log file path using strict owner-only permissions.
247///
248/// Inputs:
249/// - `log_path`: Destination path for the persisted command log.
250/// - `cmd_str`: Command string to redact and write.
251///
252/// Output:
253/// - None (best-effort file write).
254///
255/// Details:
256/// - Uses `mode(0o600)` for newly created files.
257/// - Calls `set_permissions(0o600)` after open so existing files are tightened as well.
258fn persist_command_to_log_path(log_path: &std::path::Path, cmd_str: &str) {
259    use std::io::Write;
260    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
261
262    let redacted = redact_password_pipe_for_log(cmd_str);
263    if let Ok(mut file) = std::fs::OpenOptions::new()
264        .create(true)
265        .truncate(true)
266        .write(true)
267        .mode(0o600)
268        .open(log_path)
269    {
270        let _ = std::fs::set_permissions(log_path, std::fs::Permissions::from_mode(0o600));
271        let _ = file.write_all(format!("{redacted}\n").as_bytes());
272    }
273}
274
275#[cfg(not(target_os = "windows"))]
276/// What: Redact password-bearing shell password pipes in command logs.
277///
278/// Inputs:
279/// - `cmd_str`: Command string that may contain `printf '%s\n' <password> | sudo -S ...`.
280///
281/// Output:
282/// - Copy of `cmd_str` where password arguments in matching `printf` pipes are replaced with `[REDACTED]`.
283///
284/// Details:
285/// - Targets the exact prefix used by privilege builders: `printf '%s\n'`.
286/// - Keeps the surrounding command structure intact for debugging while removing sensitive material.
287#[must_use]
288fn redact_password_pipe_for_log(cmd_str: &str) -> String {
289    let marker = "printf '%s\\n' ";
290    let mut out = String::with_capacity(cmd_str.len());
291    let mut rest = cmd_str;
292
293    while let Some(start_idx) = rest.find(marker) {
294        let (before, after_start) = rest.split_at(start_idx);
295        out.push_str(before);
296        out.push_str(marker);
297        let after_marker = &after_start[marker.len()..];
298        if let Some(pipe_idx) = after_marker.find(" | ") {
299            out.push_str("'[REDACTED]'");
300            rest = &after_marker[pipe_idx..];
301        } else {
302            out.push_str(after_marker);
303            rest = "";
304        }
305    }
306
307    out.push_str(rest);
308    out
309}
310
311#[cfg(not(target_os = "windows"))]
312/// What: Build the list of terminal candidates with preference ordering.
313///
314/// Input:
315/// - `is_gnome`: Whether running under GNOME desktop.
316///
317/// Output:
318/// - Vector of terminal candidates with (`name`, `args`, `needs_xfce_command`) tuples.
319///
320/// Details:
321/// - Prioritizes GNOME terminals when under GNOME, otherwise uses default order.
322/// - Moves user-preferred terminal to the front if configured.
323fn build_terminal_candidates(is_gnome: bool) -> Vec<(&'static str, &'static [&'static str], bool)> {
324    let terms_gnome_first: &[(&str, &[&str], bool)] = &[
325        ("gnome-terminal", &["--", "bash", "-lc"], false),
326        ("gnome-console", &["--", "bash", "-lc"], false),
327        ("kgx", &["--", "bash", "-lc"], false),
328        ("alacritty", &["-e", "bash", "-lc"], false),
329        ("ghostty", &["-e", "bash", "-lc"], false),
330        ("kitty", &["bash", "-lc"], false),
331        ("konsole", &["-e", "bash", "-lc"], false),
332        ("xterm", &["-hold", "-e", "bash", "-lc"], false),
333        ("xfce4-terminal", &[], true),
334        ("tilix", &["--", "bash", "-lc"], false),
335        ("mate-terminal", &["--", "bash", "-lc"], false),
336    ];
337    let terms_default: &[(&str, &[&str], bool)] = &[
338        ("alacritty", &["-e", "bash", "-lc"], false),
339        ("ghostty", &["-e", "bash", "-lc"], false),
340        ("kitty", &["bash", "-lc"], false),
341        ("konsole", &["-e", "bash", "-lc"], false),
342        ("gnome-terminal", &["--", "bash", "-lc"], false),
343        ("gnome-console", &["--", "bash", "-lc"], false),
344        ("kgx", &["--", "bash", "-lc"], false),
345        ("xterm", &["-hold", "-e", "bash", "-lc"], false),
346        ("xfce4-terminal", &[], true),
347        ("tilix", &["--", "bash", "-lc"], false),
348        ("mate-terminal", &["--", "bash", "-lc"], false),
349    ];
350    let mut terms_owned: Vec<(&str, &[&str], bool)> = if is_gnome {
351        terms_gnome_first.to_vec()
352    } else {
353        terms_default.to_vec()
354    };
355    let preferred = crate::theme::settings()
356        .preferred_terminal
357        .trim()
358        .to_string();
359    if !preferred.is_empty()
360        && let Some(pos) = terms_owned
361            .iter()
362            .position(|(name, _, _)| *name == preferred)
363    {
364        let entry = terms_owned.remove(pos);
365        terms_owned.insert(0, entry);
366    }
367    terms_owned
368}
369
370#[cfg(not(target_os = "windows"))]
371/// What: Attempt to spawn a terminal from the candidates list.
372///
373/// Input:
374/// - `terms_owned`: List of terminal candidates.
375/// - `script_exec`: Script execution command string.
376/// - `cmd_str`: Full command string for logging.
377/// - `is_wayland`: Whether running under Wayland.
378///
379/// Output:
380/// - `true` if a terminal was successfully spawned, `false` otherwise.
381fn attempt_terminal_spawn(
382    terms_owned: &[(&str, &[&str], bool)],
383    script_exec: &str,
384    cmd_str: &str,
385    is_wayland: bool,
386) -> bool {
387    if let Some(idx) = choose_terminal_index_prefer_path(terms_owned) {
388        let (term, args, needs_xfce_command) = terms_owned[idx];
389        return try_spawn_terminal(
390            term,
391            args,
392            needs_xfce_command,
393            script_exec,
394            cmd_str,
395            is_wayland,
396            true,
397        )
398        .unwrap_or(false);
399    }
400    for (term, args, needs_xfce_command) in terms_owned.iter().copied() {
401        if command_on_path(term)
402            && try_spawn_terminal(
403                term,
404                args,
405                needs_xfce_command,
406                script_exec,
407                cmd_str,
408                is_wayland,
409                false,
410            )
411            .unwrap_or(false)
412        {
413            return true;
414        }
415    }
416    false
417}
418
419#[cfg(not(target_os = "windows"))]
420/// What: Spawn a terminal to execute shell commands and optionally append a hold tail.
421///
422/// Input:
423/// - `cmds`: Ordered list of shell snippets to execute.
424/// - `hold`: When `true`, keeps the terminal open after command completion.
425///
426/// Output:
427/// - Launches a terminal (or `bash`) running a temporary script that encapsulates the commands.
428///
429/// Details:
430/// - Persists the command to a temp script to avoid argument-length issues.
431/// - Prefers user-configured terminals, applies desktop-specific environment tweaks, and logs spawn attempts.
432/// - During tests, this is a no-op to avoid opening real terminal windows.
433pub fn spawn_shell_commands_in_terminal_with_hold(cmds: &[String], hold: bool) {
434    // Skip actual spawning during tests unless PACSEA_TEST_OUT is set (indicates a test with fake terminal)
435    #[cfg(test)]
436    if std::env::var("PACSEA_TEST_OUT").is_err() {
437        return;
438    }
439
440    if cmds.is_empty() {
441        return;
442    }
443    let hold_tail = "echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
444    let joined = cmds.join(" && ");
445    let cmd_str = if hold {
446        format!("{joined}\n{hold_tail}")
447    } else {
448        joined
449    };
450    let (script_exec, script_ref_for_log) = match create_temp_script(&cmd_str) {
451        Ok(script_path) => {
452            let script_path_str = script_path.to_string_lossy().to_string();
453            (
454                format!("bash {}", shell_single_quote(&script_path_str)),
455                script_path_str,
456            )
457        }
458        Err(err) => {
459            log_to_terminal_log(&format!(
460                "temp script create failed, using inline command fallback: {err}\n"
461            ));
462            (cmd_str.clone(), "<inline>".to_string())
463        }
464    };
465
466    persist_command_to_log(&cmd_str);
467
468    let desktop_env = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
469    let is_gnome = desktop_env.to_uppercase().contains("GNOME");
470    let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
471    let terms_owned = build_terminal_candidates(is_gnome);
472
473    log_to_terminal_log(&format!(
474        "env desktop={} wayland={} script={} cmd_len={}\n",
475        desktop_env,
476        is_wayland,
477        script_ref_for_log,
478        cmd_str.len()
479    ));
480
481    let launched = attempt_terminal_spawn(&terms_owned, &script_exec, &cmd_str, is_wayland);
482    if !launched {
483        log_to_terminal_log(&format!(
484            "spawn term=bash args={:?} cmd_len={}\n",
485            ["-lc"],
486            cmd_str.len()
487        ));
488        let res = Command::new("bash").args(["-lc", &script_exec]).spawn();
489        match &res {
490            Ok(child) => {
491                log_to_terminal_log(&format!("spawn result: ok pid={}\n", child.id()));
492            }
493            Err(e) => {
494                log_to_terminal_log(&format!("spawn result: err error={e}\n"));
495            }
496        }
497    }
498}
499
500#[cfg(all(test, not(target_os = "windows")))]
501mod tests {
502    #[test]
503    /// What: Ensure `spawn_shell_commands_in_terminal` invokes GNOME Terminal with a double-dash separator.
504    ///
505    /// Inputs:
506    /// - `cmds`: Single echo command executed via a temporary mock `gnome-terminal` script.
507    ///
508    /// Output:
509    /// - Captured argv begins with `--`, `bash`, `-lc`, confirming safe argument ordering.
510    ///
511    /// Details:
512    /// - Rewrites `PATH` to point at a fake executable that records arguments, then restores env vars
513    ///   after spawning the terminal command.
514    fn shell_uses_gnome_terminal_double_dash() {
515        use std::fs;
516        use std::os::unix::fs::PermissionsExt;
517        use std::path::PathBuf;
518
519        let mut dir: PathBuf = std::env::temp_dir();
520        dir.push(format!(
521            "pacsea_test_shell_gnome_{}_{}",
522            std::process::id(),
523            std::time::SystemTime::now()
524                .duration_since(std::time::UNIX_EPOCH)
525                .expect("System time is before UNIX epoch")
526                .as_nanos()
527        ));
528        fs::create_dir_all(&dir).expect("create test directory");
529        let mut out_path = dir.clone();
530        out_path.push("args.txt");
531        let mut term_path = dir.clone();
532        term_path.push("gnome-terminal");
533        let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
534        fs::write(&term_path, script.as_bytes()).expect("failed to write test terminal script");
535        let mut perms = fs::metadata(&term_path)
536            .expect("failed to read test terminal script metadata")
537            .permissions();
538        perms.set_mode(0o755);
539        fs::set_permissions(&term_path, perms)
540            .expect("failed to set test terminal script permissions");
541
542        let orig_path = std::env::var_os("PATH");
543        unsafe {
544            std::env::set_var("PATH", dir.display().to_string());
545            std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
546        }
547
548        let cmds = vec!["echo hi".to_string()];
549        super::spawn_shell_commands_in_terminal(&cmds);
550        // Wait for file to be created with retries
551        let mut attempts = 0;
552        while !out_path.exists() && attempts < 50 {
553            std::thread::sleep(std::time::Duration::from_millis(10));
554            attempts += 1;
555        }
556        let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
557        let lines: Vec<&str> = body.lines().collect();
558        assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
559        assert_eq!(lines[0], "--");
560        assert_eq!(lines[1], "bash");
561        assert_eq!(lines[2], "-lc");
562
563        unsafe {
564            if let Some(v) = orig_path {
565                std::env::set_var("PATH", v);
566            } else {
567                std::env::remove_var("PATH");
568            }
569            std::env::remove_var("PACSEA_TEST_OUT");
570        }
571    }
572
573    #[test]
574    /// What: Ensure password-bearing privilege pipes are redacted before log persistence.
575    ///
576    /// Inputs:
577    /// - Command string containing the `printf '%s\n' '<password>' | sudo -S ...` pattern.
578    ///
579    /// Output:
580    /// - Returned log string includes `[REDACTED]` and omits the original password.
581    ///
582    /// Details:
583    /// - Guards against leaking sudo password fragments when command logs are written to disk.
584    fn shell_redacts_password_pipe_for_log() {
585        let input =
586            "printf '%s\\n' 'pa'\"'\"'ss' | sudo -S pacman -S --noconfirm 'ripgrep' && echo done";
587        let redacted = super::redact_password_pipe_for_log(input);
588        assert!(redacted.contains("printf '%s\\n' '[REDACTED]' | sudo -S"));
589        assert!(!redacted.contains("pa'\"'\"'ss"));
590        assert!(redacted.contains("pacman -S --noconfirm 'ripgrep' && echo done"));
591    }
592
593    #[test]
594    /// What: Ensure command log persistence tightens permissions on an existing file.
595    ///
596    /// Inputs:
597    /// - Temporary log file pre-created with mode `0o644`.
598    ///
599    /// Output:
600    /// - File mode is forced to `0o600` after persistence.
601    ///
602    /// Details:
603    /// - Verifies post-open chmod behavior for pre-existing logs where `mode(0o600)` alone
604    ///   does not retroactively adjust permissions.
605    fn shell_persist_command_log_forces_mode_on_existing_file() {
606        use std::fs;
607        use std::os::unix::fs::PermissionsExt;
608
609        let mut dir = std::env::temp_dir();
610        dir.push(format!(
611            "pacsea_test_shell_log_mode_{}_{}",
612            std::process::id(),
613            std::time::SystemTime::now()
614                .duration_since(std::time::UNIX_EPOCH)
615                .expect("System time is before UNIX epoch")
616                .as_nanos()
617        ));
618        fs::create_dir_all(&dir).expect("create test directory");
619        let log_path = dir.join("last_terminal_cmd.log");
620
621        fs::write(&log_path, b"old").expect("create test log file");
622        let mut perms = fs::metadata(&log_path)
623            .expect("read test log metadata")
624            .permissions();
625        perms.set_mode(0o644);
626        fs::set_permissions(&log_path, perms).expect("set broad test log permissions");
627
628        super::persist_command_to_log_path(&log_path, "echo test");
629
630        let mode = fs::metadata(&log_path)
631            .expect("read updated log metadata")
632            .permissions()
633            .mode()
634            & 0o777;
635        assert_eq!(mode, 0o600);
636
637        let _ = fs::remove_file(&log_path);
638        let _ = fs::remove_dir(&dir);
639    }
640
641    #[test]
642    /// What: Ensure temp script creation fails when temp dir is invalid.
643    ///
644    /// Inputs:
645    /// - `TMPDIR` pointing to a non-existent nested directory.
646    ///
647    /// Output:
648    /// - `create_temp_script` returns `Err`.
649    ///
650    /// Details:
651    /// - Verifies we do not return a non-existent script path after repeated failures.
652    fn create_temp_script_returns_error_when_open_fails() {
653        let original_tmpdir = std::env::var_os("TMPDIR");
654        let mut invalid_tmp = std::env::temp_dir();
655        invalid_tmp.push(format!(
656            "pacsea_missing_tmp_parent_{}/nested",
657            std::time::SystemTime::now()
658                .duration_since(std::time::UNIX_EPOCH)
659                .expect("System time is before UNIX epoch")
660                .as_nanos()
661        ));
662        unsafe {
663            std::env::set_var("TMPDIR", invalid_tmp.display().to_string());
664        }
665
666        let result = super::create_temp_script("echo hello");
667        assert!(result.is_err(), "expected temp script creation failure");
668
669        unsafe {
670            if let Some(v) = original_tmpdir {
671                std::env::set_var("TMPDIR", v);
672            } else {
673                std::env::remove_var("TMPDIR");
674            }
675        }
676    }
677
678    #[test]
679    /// What: Ensure multiline command hold-tail is appended with valid shell syntax.
680    ///
681    /// Inputs:
682    /// - A multiline shell snippet executed through `spawn_shell_commands_in_terminal_with_hold`.
683    ///
684    /// Output:
685    /// - Generated temp script does not contain a line starting with `;`.
686    ///
687    /// Details:
688    /// - Guards against malformed hold-tail composition that causes bash syntax errors near line end.
689    fn shell_hold_tail_for_multiline_script_has_no_leading_semicolon_line() {
690        use std::fs;
691        use std::os::unix::fs::PermissionsExt;
692        use std::path::PathBuf;
693
694        let mut dir: PathBuf = std::env::temp_dir();
695        dir.push(format!(
696            "pacsea_test_shell_hold_tail_{}_{}",
697            std::process::id(),
698            std::time::SystemTime::now()
699                .duration_since(std::time::UNIX_EPOCH)
700                .expect("System time is before UNIX epoch")
701                .as_nanos()
702        ));
703        fs::create_dir_all(&dir).expect("create test directory");
704        let out_path = dir.join("args.txt");
705        let term_path = dir.join("gnome-terminal");
706        let recorder = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
707        fs::write(&term_path, recorder.as_bytes()).expect("write fake gnome-terminal");
708        let mut perms = fs::metadata(&term_path)
709            .expect("read fake terminal metadata")
710            .permissions();
711        perms.set_mode(0o755);
712        fs::set_permissions(&term_path, perms).expect("set fake terminal executable");
713
714        let orig_path = std::env::var_os("PATH");
715        unsafe {
716            std::env::set_var("PATH", dir.display().to_string());
717            std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
718        }
719
720        let cmds = vec!["set -e\nprintf 'hello\\n'".to_string()];
721        super::spawn_shell_commands_in_terminal_with_hold(&cmds, true);
722
723        let mut attempts = 0;
724        while !out_path.exists() && attempts < 50 {
725            std::thread::sleep(std::time::Duration::from_millis(10));
726            attempts += 1;
727        }
728        let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
729        let lines: Vec<&str> = body.lines().collect();
730        assert!(lines.len() >= 4, "expected at least 4 args, got: {body}");
731        let script_exec = lines[3];
732        let script_path = script_exec
733            .split('\'')
734            .nth(1)
735            .expect("script path quoted in bash invocation");
736        let generated = fs::read_to_string(script_path).expect("read generated temp script");
737        assert!(
738            !generated
739                .lines()
740                .any(|line| line.trim_start().starts_with(';')),
741            "generated script must not include leading semicolon lines: {generated}"
742        );
743
744        unsafe {
745            if let Some(v) = orig_path {
746                std::env::set_var("PATH", v);
747            } else {
748                std::env::remove_var("PATH");
749            }
750            std::env::remove_var("PACSEA_TEST_OUT");
751        }
752    }
753}
754
755#[cfg(target_os = "windows")]
756/// What: Display the intended shell command sequence on Windows where execution is unsupported.
757///
758/// Input:
759/// - `cmds`: Command fragments to present to the user.
760///
761/// Output:
762/// - Launches a `PowerShell` window (if available and command contains "DRY RUN") for dry-run simulation, or `cmd` window otherwise.
763///
764/// Details:
765/// - When commands contain "DRY RUN" and `PowerShell` is available, uses `PowerShell` to simulate the operation.
766/// - Joins commands with `&&` for readability and uses `start` to detach the window.
767pub fn spawn_shell_commands_in_terminal(cmds: &[String]) {
768    let msg = if cmds.is_empty() {
769        "Nothing to run".to_string()
770    } else {
771        cmds.join(" && ")
772    };
773
774    // Check if this is a dry-run operation (for downgrade, etc.)
775    let is_dry_run = msg.contains("DRY RUN");
776
777    if is_dry_run && super::utils::is_powershell_available() {
778        // Use PowerShell to simulate the operation
779        let escaped_msg = msg.replace('\'', "''");
780        let powershell_cmd = format!(
781            "Write-Host '{escaped_msg}' -ForegroundColor Yellow; Write-Host ''; Write-Host 'Press any key to close...'; $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')"
782        );
783        let _ = Command::new("powershell.exe")
784            .args(["-NoProfile", "-Command", &powershell_cmd])
785            .spawn();
786    } else {
787        let _ = Command::new("cmd")
788            .args([
789                "/C",
790                "start",
791                "Pacsea Update",
792                "cmd",
793                "/K",
794                &super::utils::cmd_echo_command(&msg),
795            ])
796            .spawn();
797    }
798}