Skip to main content

bnto_shell/
powershell.rs

1// PowerShell script mode: a literal script run as `<abs path> -NoLogo
2// -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand <base64>`,
3// with nothing after it. `-Command <script> <args>` would append the args to the code, so values
4// reach the script only as $env:BNTO_ARG_1..N (SEC-32..37). See
5// decisions/powershell-script-mode.md.
6
7use std::path::{Path, PathBuf};
8
9use base64::Engine as _;
10use base64::engine::general_purpose::STANDARD;
11use bnto_core::HostPlatform;
12
13/// Shells that run a PowerShell script.
14pub(crate) const SHELLS: &[&str] = &["powershell", "pwsh"];
15
16/// The prefix of the variables that carry a step's values.
17pub(crate) const ARG_PREFIX: &str = "BNTO_ARG_";
18
19/// Fixed engine text run before every script: no progress records on
20/// stderr, UTF-8 stdout like every other step, and no ANSI colors in error
21/// text (`$PSStyle` exists from pwsh 7.2).
22/// One line, because Windows PowerShell's error view quotes the command text.
23const PRELUDE: &str = "$ProgressPreference = 'SilentlyContinue'; \
24                       [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); \
25                       if ($PSStyle) { $PSStyle.OutputRendering = 'PlainText' }\n";
26
27/// Flags before the encoded script: no banner, no profile, no prompts.
28/// `-OutputFormat Text` must be explicit: an encoded, non-interactive
29/// command with stderr redirected otherwise writes its errors as `#< CLIXML`.
30const FLAGS: &[&str] = &[
31    "-NoLogo",
32    "-NoProfile",
33    "-NonInteractive",
34    "-OutputFormat",
35    "Text",
36    "-EncodedCommand",
37];
38
39/// The longest command line allowed, under Windows' 32,767 characters with
40/// room for the program path.
41const MAX_COMMAND_LINE: usize = 30_000;
42
43/// Whether `shell` runs PowerShell.
44pub fn is_powershell(shell: &str) -> bool {
45    SHELLS.contains(&shell)
46}
47
48/// The program and every arg for a PowerShell script: `<abs path> -NoLogo
49/// -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand <script>`.
50/// Nothing may follow.
51pub(crate) fn invocation(shell: &str, script: &str) -> Result<(PathBuf, Vec<String>), String> {
52    let system_root = std::env::var("SystemRoot").ok();
53    let candidates = candidates(shell, HostPlatform::current(), system_root.as_deref())?;
54    let path = crate::script::first_installed(shell, &candidates, Path::exists)?;
55    Ok((path, args(script)))
56}
57
58fn args(script: &str) -> Vec<String> {
59    let mut args: Vec<String> = FLAGS.iter().map(|flag| flag.to_string()).collect();
60    args.push(encode(script));
61    args
62}
63
64/// The script as `-EncodedCommand` takes it: base64 of UTF-16LE, prelude first.
65fn encode(script: &str) -> String {
66    let utf16: Vec<u8> = PRELUDE
67        .encode_utf16()
68        .chain(script.encode_utf16())
69        .flat_map(u16::to_le_bytes)
70        .collect();
71    STANDARD.encode(utf16)
72}
73
74/// A script too long for a Windows command line. Checked on every OS, so a
75/// recipe acts the same everywhere.
76pub(crate) fn length_problem(script: &str) -> Option<String> {
77    let length: usize = args(script).iter().map(|arg| arg.len() + 1).sum();
78    (length > MAX_COMMAND_LINE).then(|| {
79        format!(
80            "the PowerShell script is too long for a command line once encoded \
81             ({length} of {MAX_COMMAND_LINE} characters); make it shorter"
82        )
83    })
84}
85
86/// Windows PowerShell on a step that can run where it doesn't exist: the
87/// platforms (`{{ctx.platform}}` values) other than Windows it can reach.
88pub(crate) fn platform_problem(shell: &str, platforms: &[&str]) -> Option<String> {
89    let elsewhere: Vec<&str> = platforms
90        .iter()
91        .filter(|p| **p != "windows")
92        .map(|p| platform_label(p))
93        .collect();
94    (shell == "powershell" && !elsewhere.is_empty()).then(|| {
95        format!(
96            "'powershell' is Windows PowerShell, which only Windows has, but this step \
97             can also run on {}; guard it with when {{{{ctx.platform}}}} equals windows, \
98             or use 'pwsh'",
99            elsewhere.join(", ")
100        )
101    })
102}
103
104/// A `{{ctx.platform}}` value as people read it.
105fn platform_label(platform: &str) -> &str {
106    match platform {
107        "macos" => "macOS",
108        "linux" => "Linux",
109        "windows" => "Windows",
110        _ => "other OSes",
111    }
112}
113
114/// A step's values as the variables its script reads: `BNTO_ARG_1`…`N`, then
115/// `BNTO_ARG_COUNT`.
116pub(crate) fn value_env(values: &[String]) -> Vec<(String, String)> {
117    let numbered = values
118        .iter()
119        .enumerate()
120        .map(|(i, value)| (format!("{ARG_PREFIX}{}", i + 1), value.clone()));
121    let count = (format!("{ARG_PREFIX}COUNT"), values.len().to_string());
122    numbered.chain([count]).collect()
123}
124
125/// Where `shell` may be on `os`, by absolute path only — never PATH.
126fn candidates(
127    shell: &str,
128    os: Option<HostPlatform>,
129    system_root: Option<&str>,
130) -> Result<Vec<PathBuf>, String> {
131    match (shell, os) {
132        ("powershell", Some(HostPlatform::Windows)) => Ok(windows_powershell(system_root)),
133        ("powershell", _) => Err("'powershell' is Windows PowerShell, which only Windows \
134                                  has; use 'pwsh' (PowerShell 7) elsewhere"
135            .to_string()),
136        ("pwsh", Some(os)) => Ok(pwsh_paths(os).iter().map(PathBuf::from).collect()),
137        ("pwsh", None) => Err("bnto doesn't know where pwsh lives on this OS".to_string()),
138        _ => Err(format!("unknown shell '{shell}'")),
139    }
140}
141
142/// `%SystemRoot%`'s copy first, then the default Windows folder's.
143fn windows_powershell(system_root: Option<&str>) -> Vec<PathBuf> {
144    let under =
145        |root: &str| PathBuf::from(root).join(r"System32\WindowsPowerShell\v1.0\powershell.exe");
146    let mut paths: Vec<PathBuf> = system_root.into_iter().map(under).collect();
147    paths.push(under(r"C:\Windows"));
148    paths.dedup();
149    paths
150}
151
152fn pwsh_paths(os: HostPlatform) -> &'static [&'static str] {
153    match os {
154        HostPlatform::Windows => &[r"C:\Program Files\PowerShell\7\pwsh.exe"],
155        HostPlatform::MacOs => &[
156            "/usr/local/bin/pwsh",
157            "/opt/homebrew/bin/pwsh",
158            "/usr/local/microsoft/powershell/7/pwsh",
159        ],
160        HostPlatform::Linux => &[
161            "/usr/bin/pwsh",
162            "/opt/microsoft/powershell/7/pwsh",
163            "/snap/bin/pwsh",
164        ],
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn decode(encoded: &str) -> String {
173        let bytes = STANDARD.decode(encoded).unwrap();
174        let units: Vec<u16> = bytes
175            .chunks(2)
176            .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
177            .collect();
178        String::from_utf16(&units).unwrap()
179    }
180
181    #[test]
182    fn the_script_is_the_last_arg_and_nothing_follows_it() {
183        let args = args("Write-Output $env:BNTO_ARG_1");
184        assert_eq!(
185            args[..6],
186            [
187                "-NoLogo",
188                "-NoProfile",
189                "-NonInteractive",
190                "-OutputFormat",
191                "Text",
192                "-EncodedCommand"
193            ]
194        );
195        assert_eq!(args.len(), 7);
196        assert!(!args.iter().any(|a| a == "-Command" || a == "-File"));
197    }
198
199    #[test]
200    fn the_encoded_script_is_utf16le_base64_of_the_prelude_then_the_script() {
201        let script = "Write-Output \"héllo ✓\"";
202        let decoded = decode(&encode(script));
203        assert_eq!(decoded, format!("{PRELUDE}{script}"));
204        assert!(decoded.starts_with("$ProgressPreference = 'SilentlyContinue'; "));
205        assert_eq!(PRELUDE.lines().count(), 1);
206    }
207
208    #[test]
209    fn values_become_numbered_variables_and_a_count() {
210        let values = vec!["a b".to_string(), String::new(), "$(rm -r /)".to_string()];
211        assert_eq!(
212            value_env(&values),
213            [
214                ("BNTO_ARG_1".to_string(), "a b".to_string()),
215                ("BNTO_ARG_2".to_string(), String::new()),
216                ("BNTO_ARG_3".to_string(), "$(rm -r /)".to_string()),
217                ("BNTO_ARG_COUNT".to_string(), "3".to_string()),
218            ]
219        );
220        assert_eq!(
221            value_env(&[]),
222            [("BNTO_ARG_COUNT".to_string(), "0".to_string())]
223        );
224    }
225
226    #[test]
227    fn windows_powershell_off_windows_names_where_it_can_run() {
228        let all = ["macos", "linux", "windows", "unknown"];
229        let problem = platform_problem("powershell", &all).unwrap();
230        assert!(
231            problem.contains("can also run on macOS, Linux, other OSes"),
232            "{problem}"
233        );
234        assert_eq!(platform_problem("powershell", &["windows"]), None);
235        assert_eq!(platform_problem("pwsh", &all), None);
236        assert_eq!(platform_problem("sh", &all), None);
237    }
238
239    #[test]
240    fn a_script_too_long_for_a_windows_command_line_is_refused() {
241        assert!(length_problem(&"x".repeat(10_000)).is_none());
242        let problem = length_problem(&"x".repeat(12_000)).unwrap();
243        assert!(problem.contains("too long"), "{problem}");
244    }
245
246    #[test]
247    fn windows_powershell_runs_only_on_windows_from_system_root() {
248        let found = candidates("powershell", Some(HostPlatform::Windows), Some(r"D:\Win")).unwrap();
249        assert_eq!(
250            found,
251            [
252                PathBuf::from(r"D:\Win").join(r"System32\WindowsPowerShell\v1.0\powershell.exe"),
253                PathBuf::from(r"C:\Windows")
254                    .join(r"System32\WindowsPowerShell\v1.0\powershell.exe"),
255            ]
256        );
257        for os in [HostPlatform::MacOs, HostPlatform::Linux] {
258            let err = candidates("powershell", Some(os), None).unwrap_err();
259            assert!(err.contains("use 'pwsh'"), "{err}");
260        }
261    }
262
263    #[test]
264    fn pwsh_is_looked_for_at_fixed_absolute_paths_on_every_os() {
265        for os in HostPlatform::ALL {
266            let found = candidates("pwsh", Some(os), None).unwrap();
267            assert!(!found.is_empty(), "{os:?}");
268            for path in found {
269                let text = path.to_string_lossy().into_owned();
270                assert!(text.starts_with('/') || text.starts_with(r"C:\"), "{text}");
271            }
272        }
273        assert!(candidates("pwsh", None, None).is_err());
274        assert!(candidates("sh", Some(HostPlatform::Linux), None).is_err());
275    }
276}