Skip to main content

codex_shell_command/
powershell.rs

1use std::path::PathBuf;
2
3use codex_utils_absolute_path::AbsolutePathBuf;
4
5use crate::command_safety::try_parse_powershell_ast_commands;
6use crate::shell_detect::ShellType;
7use crate::shell_detect::detect_shell_type;
8
9const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
10
11/// Prefixed command for powershell shell calls to request UTF-8 console output.
12pub const UTF8_OUTPUT_PREFIX: &str =
13    "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n";
14
15pub fn prefix_powershell_script_with_utf8(command: &[String]) -> Vec<String> {
16    let Some((_, script)) = extract_powershell_command(command) else {
17        return command.to_vec();
18    };
19
20    let trimmed = script.trim_start();
21    let script = if trimmed.starts_with(UTF8_OUTPUT_PREFIX) {
22        script.to_string()
23    } else {
24        format!("{UTF8_OUTPUT_PREFIX}{script}")
25    };
26
27    let mut command: Vec<String> = command[..(command.len() - 1)]
28        .iter()
29        .map(std::string::ToString::to_string)
30        .collect();
31    command.push(script);
32    command
33}
34
35/// Extract the PowerShell script body from an invocation such as:
36///
37/// - ["pwsh", "-NoProfile", "-Command", "Get-ChildItem -Recurse | Select-String foo"]
38/// - ["powershell.exe", "-Command", "Write-Host hi"]
39/// - ["powershell", "-NoLogo", "-NoProfile", "-Command", "...script..."]
40///
41/// Returns (`shell`, `script`) when the first arg is a PowerShell executable and a
42/// `-Command` (or `-c`) flag is present followed by a script string.
43pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
44    if command.len() < 3 {
45        return None;
46    }
47
48    let shell = &command[0];
49    if !matches!(
50        detect_shell_type(PathBuf::from(shell)),
51        Some(ShellType::PowerShell)
52    ) {
53        return None;
54    }
55
56    // Find the first occurrence of -Command (accept common short alias -c as well)
57    let mut i = 1usize;
58    while i + 1 < command.len() {
59        let flag = &command[i];
60        // Reject unknown flags
61        if !POWERSHELL_FLAGS.contains(&flag.to_ascii_lowercase().as_str()) {
62            return None;
63        }
64        if flag.eq_ignore_ascii_case("-Command") || flag.eq_ignore_ascii_case("-c") {
65            let script = &command[i + 1];
66            return Some((shell, script));
67        }
68        i += 1;
69    }
70    None
71}
72
73/// Parse the script body from a top-level PowerShell wrapper into argv-like commands.
74///
75/// This is intentionally narrower than the Windows safe-command parser: it only unwraps the
76/// `-Command`/`-c` body from a PowerShell invocation we already recognize, then delegates the
77/// script itself to the PowerShell AST parser.
78pub fn parse_powershell_command_into_plain_commands(
79    command: &[String],
80) -> Option<Vec<Vec<String>>> {
81    let (executable, script) = extract_powershell_command(command)?;
82    try_parse_powershell_ast_commands(executable, script)
83}
84
85/// This function attempts to find a powershell.exe executable on the system.
86pub fn try_find_powershell_executable_blocking() -> Option<AbsolutePathBuf> {
87    try_find_powershellish_executable_in_path(&["powershell.exe"])
88}
89
90/// This function attempts to find a pwsh.exe executable on the system.
91/// Note that pwsh.exe and powershell.exe are different executables:
92///
93/// - pwsh.exe is the cross-platform PowerShell Core (v6+) executable
94/// - powershell.exe is the Windows PowerShell (v5.1 and earlier) executable
95///
96/// Further, while powershell.exe is included by default on Windows systems,
97/// pwsh.exe must be installed separately by the user. And even when the user
98/// has installed pwsh.exe, it may not be available in the system PATH, in which
99/// case we attempt to locate it via other means.
100pub fn try_find_pwsh_executable_blocking() -> Option<AbsolutePathBuf> {
101    if let Some(ps_home) = std::process::Command::new("cmd")
102        .args(["/C", "pwsh", "-NoProfile", "-Command", "$PSHOME"])
103        .output()
104        .ok()
105        .and_then(|out| {
106            if !out.status.success() {
107                return None;
108            }
109            let stdout = String::from_utf8_lossy(&out.stdout);
110            let trimmed = stdout.trim();
111            (!trimmed.is_empty()).then(|| trimmed.to_string())
112        })
113    {
114        let candidate = AbsolutePathBuf::resolve_path_against_base("pwsh.exe", &ps_home);
115
116        if is_powershellish_executable_available(candidate.as_path()) {
117            return Some(candidate);
118        }
119    }
120
121    try_find_powershellish_executable_in_path(&["pwsh.exe"])
122}
123
124fn try_find_powershellish_executable_in_path(candidates: &[&str]) -> Option<AbsolutePathBuf> {
125    for candidate in candidates {
126        let Ok(resolved_path) = which::which(candidate) else {
127            continue;
128        };
129
130        if !is_powershellish_executable_available(&resolved_path) {
131            continue;
132        }
133
134        let Ok(abs_path) = AbsolutePathBuf::from_absolute_path(resolved_path) else {
135            continue;
136        };
137
138        return Some(abs_path);
139    }
140
141    None
142}
143
144fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Path) -> bool {
145    // This test works for both powershell.exe and pwsh.exe.
146    std::process::Command::new(powershell_or_pwsh_exe)
147        .args(["-NoLogo", "-NoProfile", "-Command", "Write-Output ok"])
148        .output()
149        .map(|output| output.status.success())
150        .unwrap_or(false)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::UTF8_OUTPUT_PREFIX;
156    use super::extract_powershell_command;
157    #[cfg(windows)]
158    use super::parse_powershell_command_into_plain_commands;
159    use super::prefix_powershell_script_with_utf8;
160
161    #[test]
162    fn extracts_basic_powershell_command() {
163        let cmd = vec![
164            "powershell".to_string(),
165            "-Command".to_string(),
166            "Write-Host hi".to_string(),
167        ];
168        let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
169        assert_eq!(script, "Write-Host hi");
170    }
171
172    #[test]
173    fn extracts_lowercase_flags() {
174        let cmd = vec![
175            "powershell".to_string(),
176            "-nologo".to_string(),
177            "-command".to_string(),
178            "Write-Host hi".to_string(),
179        ];
180        let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
181        assert_eq!(script, "Write-Host hi");
182    }
183
184    #[test]
185    fn extracts_full_path_powershell_command() {
186        let command = if cfg!(windows) {
187            "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".to_string()
188        } else {
189            "/usr/local/bin/powershell.exe".to_string()
190        };
191        let cmd = vec![command, "-Command".to_string(), "Write-Host hi".to_string()];
192        let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
193        assert_eq!(script, "Write-Host hi");
194    }
195
196    #[test]
197    fn extracts_with_noprofile_and_alias() {
198        let cmd = vec![
199            "pwsh".to_string(),
200            "-NoProfile".to_string(),
201            "-c".to_string(),
202            "Get-ChildItem | Select-String foo".to_string(),
203        ];
204        let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
205        assert_eq!(script, "Get-ChildItem | Select-String foo");
206    }
207
208    #[test]
209    fn prefixes_powershell_command_with_best_effort_utf8() {
210        let cmd = vec![
211            "powershell".to_string(),
212            "-Command".to_string(),
213            "Write-Host hi".to_string(),
214        ];
215
216        let prefixed = prefix_powershell_script_with_utf8(&cmd);
217
218        assert_eq!(
219            prefixed,
220            vec![
221                "powershell".to_string(),
222                "-Command".to_string(),
223                format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"),
224            ]
225        );
226    }
227
228    #[test]
229    fn does_not_duplicate_utf8_prefix() {
230        let cmd = vec![
231            "powershell".to_string(),
232            "-Command".to_string(),
233            format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"),
234        ];
235
236        assert_eq!(prefix_powershell_script_with_utf8(&cmd), cmd);
237    }
238
239    #[cfg(windows)]
240    #[test]
241    fn parses_plain_powershell_commands() {
242        let commands = parse_powershell_command_into_plain_commands(&[
243            "powershell.exe".to_string(),
244            "-NoProfile".to_string(),
245            "-Command".to_string(),
246            "echo hi".to_string(),
247        ])
248        .expect("parse");
249
250        assert_eq!(commands, vec![vec!["echo".to_string(), "hi".to_string()]]);
251    }
252
253    #[cfg(windows)]
254    #[test]
255    fn parses_multiple_plain_powershell_commands() {
256        let commands = parse_powershell_command_into_plain_commands(&[
257            "powershell.exe".to_string(),
258            "-NoProfile".to_string(),
259            "-Command".to_string(),
260            "Write-Output foo | Measure-Object".to_string(),
261        ])
262        .expect("parse");
263
264        assert_eq!(
265            commands,
266            vec![
267                vec!["Write-Output".to_string(), "foo".to_string()],
268                vec!["Measure-Object".to_string()],
269            ]
270        );
271    }
272}