oro_shim_bin/
lib.rs

1//! Creates shims for package bins on Windows. Basically a Rust port of
2//! <https://github.com/npm/cmd-shim>.
3
4// The original project is licensed as follows:
5//
6// The ISC License
7//
8// Copyright (c) npm, Inc. and Contributors
9//
10// Permission to use, copy, modify, and/or distribute this software for any
11// purpose with or without fee is hereby granted, provided that the above
12// copyright notice and this permission notice appear in all copies.
13//
14// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
15// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
16// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
17// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
18// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
19// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
20// IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
21
22use std::path::Path;
23
24use once_cell::sync::Lazy;
25use regex::Regex;
26
27static SHEBANG_REGEX: Lazy<Regex> = Lazy::new(|| {
28    Regex::new(r"^#!\s*(?:/usr/bin/env\s+(?:-S\s+)?(?P<vars>(?:[^ \t=]+=[^ \t=]+\s+)*))?(?P<prog>[^ \t]+)(?P<args>.*)$")
29        .unwrap()
30});
31
32static DOLLAR_EXPR_REGEX: Lazy<Regex> =
33    Lazy::new(|| Regex::new(r"\$\{?(?P<var>[^$@#?\- \t{}:]+)\}?").unwrap());
34
35pub fn shim_bin(source: &Path, to: &Path) -> std::io::Result<()> {
36    // First, we blow away anything that already exists there.
37    // TODO: get rid of .expect()s?
38    let from = pathdiff::diff_paths(source, to.parent().expect("must have parent"))
39        .expect("paths should be diffable");
40    cleanup_existing(to)?;
41    if let Ok(contents) = std::fs::read_to_string(source) {
42        let mut lines = contents.lines();
43        if let Some(first_line) = lines.next() {
44            if let Some(captures) = SHEBANG_REGEX.captures(first_line.trim_end()) {
45                let vars = captures.name("vars").map(|m| m.as_str());
46                let prog = captures.name("prog").map(|m| m.as_str());
47                let args = captures.name("args").map(|m| m.as_str());
48                return write_shim(&from, to, vars, prog, args);
49            }
50        }
51    }
52    write_shim(&from, to, None, None, None)
53}
54
55fn cleanup_existing(to: &Path) -> std::io::Result<()> {
56    if let Ok(meta) = to.metadata() {
57        if meta.is_dir() {
58            std::fs::remove_dir_all(to)?;
59        } else {
60            std::fs::remove_file(to)?;
61        }
62    }
63    let cmd = to.with_extension("cmd");
64    if let Ok(meta) = cmd.metadata() {
65        if meta.is_dir() {
66            std::fs::remove_dir_all(cmd)?;
67        } else {
68            std::fs::remove_file(cmd)?;
69        }
70    }
71    let ps1 = to.with_extension("ps1");
72    if let Ok(meta) = ps1.metadata() {
73        if meta.is_dir() {
74            std::fs::remove_dir_all(ps1)?;
75        } else {
76            std::fs::remove_file(ps1)?;
77        }
78    }
79    Ok(())
80}
81
82fn write_shim(
83    from: &Path,
84    to: &Path,
85    vars: Option<&str>,
86    prog: Option<&str>,
87    args: Option<&str>,
88) -> std::io::Result<()> {
89    write_cmd_shim(from, to, vars, prog, args)?;
90    write_sh_shim(from, to, vars, prog, args)?;
91    write_pwsh_shim(from, to, vars, prog, args)?;
92    Ok(())
93}
94
95fn write_cmd_shim(
96    from: &Path,
97    to: &Path,
98    vars: Option<&str>,
99    prog: Option<&str>,
100    args: Option<&str>,
101) -> std::io::Result<()> {
102    let mut cmd = concat!(
103        "@ECHO off\r\n",
104        "GOTO start\r\n",
105        ":find_dp0\r\n",
106        "SET dp0=%~dp0\r\n",
107        "EXIT /b\r\n",
108        ":start\r\n",
109        "SETLOCAL\r\n",
110        "CALL :find_dp0\r\n"
111    )
112    .to_string();
113
114    let target = format!(
115        "\"%dp0%\\{target}\"",
116        target = from.display().to_string().replace('/', "\\")
117    );
118    if let Some(prog) = prog {
119        let args = if let Some(args) = args {
120            args.trim()
121        } else {
122            ""
123        };
124        cmd.push_str(&convert_to_set_commands(vars.unwrap_or("")));
125        cmd.push_str("\r\n");
126        cmd.push_str(&format!("IF EXIST \"%dp0%\\{prog}.exe\" (\r\n"));
127        cmd.push_str(&format!("  SET \"_prog=%dp0%\\{prog}.exe\"\r\n"));
128        cmd.push_str(") ELSE (\r\n");
129        cmd.push_str(&format!(
130            "  SET \"_prog={}\"\r\n",
131            prog.trim_start_matches('"').trim_end_matches('"')
132        ));
133        cmd.push_str("  SET PATHEXT=%PATHEXT:;.JS;=;%\r\n");
134        cmd.push_str(")\r\n");
135        cmd.push_str("\r\n");
136        cmd.push_str("endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & ");
137        cmd.push_str(&format!("\"%_prog%\" {args} {target} %*\r\n",));
138    } else {
139        cmd.push_str(&format!("{target} %*\r\n",));
140    }
141
142    std::fs::write(to.with_extension("cmd"), cmd)?;
143
144    Ok(())
145}
146
147fn write_sh_shim(
148    from: &Path,
149    to: &Path,
150    vars: Option<&str>,
151    prog: Option<&str>,
152    args: Option<&str>,
153) -> std::io::Result<()> {
154    let mut sh = concat!(
155        "#!/bin/sh\n",
156        r#"basedir = $(dirname "$(echo "$0" | sed -e 's,\\,/,g')")"#,
157        "\n\n",
158        "case `uname` in\n",
159        "    *CYGWIN*|*MINGW*|*MSYS*) basedir=`cygpath -w \"$basedir\"`;;\n",
160        "esac\n\n"
161    )
162    .to_string();
163
164    let args = args.unwrap_or("");
165    let vars = vars.unwrap_or("");
166    let target = from.display().to_string().replace('\\', "/");
167    if let Some(prog) = prog {
168        let long_prog = format!("\"$basedir/{prog}\"");
169        let prog = prog.replace('\\', "/");
170        sh.push_str(&format!("if [ -x {long_prog} ]; then\n"));
171        sh.push_str(&format!(
172            "  exec {vars}{long_prog} {args} \"$basedir/{target}\" \"$@\"\n"
173        ));
174        sh.push_str("else \n");
175        sh.push_str(&format!(
176            "  exec {vars}{prog} {args} \"$basedir/{target}\" \"$@\"\n"
177        ));
178        sh.push_str("fi\n");
179    } else {
180        sh.push_str(&format!("exec \"$basedir/{target}\" {args} \"$@\"\n"));
181    }
182
183    std::fs::write(to, sh)?;
184
185    Ok(())
186}
187
188fn write_pwsh_shim(
189    from: &Path,
190    to: &Path,
191    vars: Option<&str>,
192    prog: Option<&str>,
193    args: Option<&str>,
194) -> std::io::Result<()> {
195    let mut pwsh = concat!(
196        "#!/usr/bin/env pwsh\n",
197        "$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n",
198        "\n",
199        "$exe=\"\"\n",
200        "if ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {\n",
201        "  # Fix case when both the Windows and Linux builds of Node\n",
202        "  # are installed in the same directory\n",
203        "  $exe=\".exe\"\n",
204        "}\n"
205    )
206    .to_string();
207
208    let args = args.unwrap_or("");
209    let target = from.display().to_string().replace('\\', "/");
210    if let Some(prog) = prog {
211        let long_prog = format!("\"$basedir/{prog}$exe\"");
212        let prog = format!("\"{}\"$exe", prog.replace('\\', "/"));
213        pwsh.push_str(&convert_to_env_commands(vars.unwrap_or("")));
214        pwsh.push_str("$ret=0\n");
215        pwsh.push_str(&format!("if (Test-Path {long_prog}) {{\n"));
216        pwsh.push_str("  # Support pipeline input\n");
217        pwsh.push_str("  if ($MyInvocation.ExpectingInput) {\n");
218        pwsh.push_str(&format!(
219            "    $input | & {long_prog} {args} \"$basedir/{target}\" $args\n"
220        ));
221        pwsh.push_str("  } else {\n");
222        pwsh.push_str(&format!(
223            "    & {long_prog} {args} \"$basedir/{target}\" $args\n"
224        ));
225        pwsh.push_str("  }\n");
226        pwsh.push_str("  $ret=$LASTEXITCODE\n");
227        pwsh.push_str("} else {\n");
228        pwsh.push_str("  # Support pipeline input\n");
229        pwsh.push_str("  if ($MyInvocation.ExpectingInput) {\n");
230        pwsh.push_str(&format!(
231            "    $input | & {prog} {args} \"$basedir/{target}\" $args\n"
232        ));
233        pwsh.push_str("  } else {\n");
234        pwsh.push_str(&format!(
235            "    & {prog} {args} \"$basedir/{target}\" $args\n"
236        ));
237        pwsh.push_str("  }\n");
238        pwsh.push_str("  $ret=$LASTEXITCODE\n");
239        pwsh.push_str("}\n");
240        pwsh.push_str("exit $ret\n");
241    } else {
242        pwsh.push_str("# Support pipeline input\n");
243        pwsh.push_str("if ($MyInvocation.ExpectingInput) {\n");
244        pwsh.push_str(&format!("  $input | & \"$basedir/{target}\" $args\n"));
245        pwsh.push_str("} else {\n");
246        pwsh.push_str(&format!("  & \"$basedir/{target}\" $args\n"));
247        pwsh.push_str("}\n");
248        pwsh.push_str("exit $LASTEXITCODE\n");
249    }
250
251    std::fs::write(to.with_extension("ps1"), pwsh)?;
252
253    Ok(())
254}
255
256fn convert_to_set_commands(variables: &str) -> String {
257    let mut var_declarations_as_batch = String::new();
258    for var_str in variables.split_whitespace() {
259        let mut parts = var_str.splitn(2, '=');
260        if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
261            var_declarations_as_batch.push_str(&convert_to_set_command(key, value));
262        }
263    }
264    var_declarations_as_batch
265}
266
267fn convert_to_set_command(key: &str, value: &str) -> String {
268    let key = key.trim();
269    let value = value.trim();
270    if key.is_empty() || value.is_empty() {
271        String::new()
272    } else {
273        format!("@SET {key}={}\r\n", replace_dollar_with_percent_pair(value))
274    }
275}
276
277fn replace_dollar_with_percent_pair(value: &str) -> String {
278    let mut result = String::new();
279    let mut start_idx = 0;
280    for capture in DOLLAR_EXPR_REGEX.captures_iter(value) {
281        let mat = capture
282            .get(0)
283            .expect("If we had a capture, there should be a 0-match");
284        result.push_str(&value[start_idx..mat.start()]);
285        result.push('%');
286        result.push_str(&capture["var"]);
287        result.push('%');
288        start_idx = mat.end();
289    }
290    result.push_str(&value[start_idx..]);
291    result
292}
293
294fn convert_to_env_commands(variables: &str) -> String {
295    let mut var_declarations_as_batch = String::new();
296    for var_str in variables.split_whitespace() {
297        let mut parts = var_str.splitn(2, '=');
298        if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
299            var_declarations_as_batch.push_str(&convert_to_env_command(key, value));
300        }
301    }
302    var_declarations_as_batch
303}
304
305fn convert_to_env_command(key: &str, value: &str) -> String {
306    let key = key.trim();
307    let value = value.trim();
308    if key.is_empty() || value.is_empty() {
309        String::new()
310    } else {
311        format!(
312            "$env:{key}=\"{}\"\n",
313            replace_with_string_interpolation(value)
314        )
315    }
316}
317
318fn replace_with_string_interpolation(value: &str) -> String {
319    let mut result = String::new();
320    let mut start_idx = 0;
321    for capture in DOLLAR_EXPR_REGEX.captures_iter(value) {
322        let mat = capture
323            .get(0)
324            .expect("If we had a capture, there should be a 0-match");
325        result.push_str(&value[start_idx..mat.start()]);
326        // This doesn't _necessarily_ have to be env:, but it's the most
327        // likely/sensible one, so we just go with it.
328        result.push_str("${env:");
329        result.push_str(&capture["var"]);
330        result.push('}');
331        start_idx = mat.end();
332    }
333    result.push_str(&value[start_idx..]);
334    result.replace('\"', "`\"")
335}