1use std::path::{Path, PathBuf};
8
9use base64::Engine as _;
10use base64::engine::general_purpose::STANDARD;
11use bnto_core::HostPlatform;
12
13pub(crate) const SHELLS: &[&str] = &["powershell", "pwsh"];
15
16pub(crate) const ARG_PREFIX: &str = "BNTO_ARG_";
18
19const PRELUDE: &str = "$ProgressPreference = 'SilentlyContinue'; \
24 [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); \
25 if ($PSStyle) { $PSStyle.OutputRendering = 'PlainText' }\n";
26
27const FLAGS: &[&str] = &[
31 "-NoLogo",
32 "-NoProfile",
33 "-NonInteractive",
34 "-OutputFormat",
35 "Text",
36 "-EncodedCommand",
37];
38
39const MAX_COMMAND_LINE: usize = 30_000;
42
43pub fn is_powershell(shell: &str) -> bool {
45 SHELLS.contains(&shell)
46}
47
48pub(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
64fn 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
74pub(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
86pub(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
104fn platform_label(platform: &str) -> &str {
106 match platform {
107 "macos" => "macOS",
108 "linux" => "Linux",
109 "windows" => "Windows",
110 _ => "other OSes",
111 }
112}
113
114pub(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
125fn 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
142fn 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}