Skip to content

Commit

Permalink
Wrap unsafe script shebangs in /bin/sh (astral-sh#2097)
Browse files Browse the repository at this point in the history
## Summary

This is based on Pradyun's installer branch
(https://github.com/pradyunsg/installer/blob/d01624e5f20963f046e67d58f5319e21a07aa03e/src/installer/scripts.py#L54),
which is itself based on pip
(https://github.com/pypa/pip/blob/0ad4c94be74cc24874c6feb5bb3c2152c398a18e/src/pip/_vendor/distlib/scripts.py#L136).

The gist of it is: on Posix platforms, if a path contains a space (or is
too long), we wrap the shebang in a `/bin/sh` invocation.

Closes astral-sh#2076.

## Test Plan

```
❯ cargo run venv "foo"
    Finished dev [unoptimized + debuginfo] target(s) in 0.14s
     Running `target/debug/uv venv foo`
Using Python 3.12.0 interpreter at: /Users/crmarsh/.local/share/rtx/installs/python/3.12.0/bin/python3
Creating virtualenv at: foo
Activate with: source foo/bin/activate

❯ source "foo bar/bin/activate"

❯ which black
black not found

❯ cargo run pip install black
Resolved 6 packages in 177ms
Installed 6 packages in 17ms
 + black==24.2.0
 + click==8.1.7
 + mypy-extensions==1.0.0
 + packaging==23.2
 + pathspec==0.12.1
 + platformdirs==4.2.0

❯ which black
/Users/crmarsh/workspace/uv/foo bar/bin/black

❯ black
Usage: black [OPTIONS] SRC ...

One of 'SRC' or 'code' is required.

❯ cat "foo bar/bin/black"
#!/bin/sh
'''exec' '/Users/crmarsh/workspace/uv/foo bar/bin/python' "$0" "$@"
' '''
# -*- coding: utf-8 -*-
import re
import sys
from black import patched_main
if __name__ == "__main__":
    sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
    sys.exit(patched_main())
```
  • Loading branch information
charliermarsh authored Feb 29, 2024
1 parent b62a815 commit e811070
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 5 deletions.
3 changes: 3 additions & 0 deletions crates/install-wheel-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ mod uninstall;
mod wheel;

/// The layout of the target environment into which a wheel can be installed.
#[derive(Debug, Clone)]
pub struct Layout {
/// The Python interpreter, as returned by `sys.executable`.
pub sys_executable: PathBuf,
Expand All @@ -39,6 +40,8 @@ pub struct Layout {
pub data: PathBuf,
/// The Python version, as returned by `sys.version_info`.
pub python_version: (u8, u8),
/// The `os.name` value for the current platform.
pub os_name: String,
}

/// Note: The caller is responsible for adding the path of the wheel we're installing.
Expand Down
75 changes: 70 additions & 5 deletions crates/install-wheel-rs/src/wheel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,33 @@ fn copy_and_hash(reader: &mut impl Read, writer: &mut impl Write) -> io::Result<
))
}

fn get_shebang(python_executable: impl AsRef<Path>) -> String {
format!("#!{}", python_executable.as_ref().simplified().display())
/// Format the shebang for a given Python executable.
///
/// Like pip, if a shebang is non-simple (too long or contains spaces), we use `/bin/sh` as the
/// executable.
///
/// See: <https://github.com/pypa/pip/blob/0ad4c94be74cc24874c6feb5bb3c2152c398a18e/src/pip/_vendor/distlib/scripts.py#L136-L165>
fn format_shebang(executable: impl AsRef<Path>, os_name: &str) -> String {
// Convert the executable to a simplified path.
let executable = executable.as_ref().simplified_display().to_string();

// Validate the shebang.
if os_name == "posix" {
// The length of the full line: the shebang, plus the leading `#` and `!`, and a trailing
// newline.
let shebang_length = 2 + executable.len() + 1;

// If the shebang is too long, or contains spaces, wrap it in `/bin/sh`.
if shebang_length > 127 || executable.contains(' ') {
// Like Python's `shlex.quote`:
// > Use single quotes, and put single quotes into double quotes
// > The string $'b is then quoted as '$'"'"'b'
let executable = format!("'{}'", executable.replace('\'', r#"'"'"'"#));
return format!("#!/bin/sh\n'''exec' {executable} \"$0\" \"$@\"\n' '''");
}
}

format!("#!{executable}")
}

/// A Windows script is a minimal .exe launcher binary with the python entrypoint script appended as
Expand Down Expand Up @@ -228,8 +253,10 @@ pub(crate) fn write_script_entrypoints(
})?;

// Generate the launcher script.
let launcher_python_script =
get_script_launcher(entrypoint, &get_shebang(&layout.sys_executable));
let launcher_python_script = get_script_launcher(
entrypoint,
&format_shebang(&layout.sys_executable, &layout.os_name),
);

// If necessary, wrap the launcher script in a Windows launcher binary.
if cfg!(windows) {
Expand Down Expand Up @@ -431,7 +458,9 @@ fn install_script(
let mut start = vec![0; placeholder_python.len()];
script.read_exact(&mut start)?;
let size_and_encoded_hash = if start == placeholder_python {
let start = get_shebang(&layout.sys_executable).as_bytes().to_vec();
let start = format_shebang(&layout.sys_executable, &layout.os_name)
.as_bytes()
.to_vec();
let mut target = File::create(&target_path)?;
let size_and_encoded_hash = copy_and_hash(&mut start.chain(script), &mut target)?;
fs::remove_file(&path)?;
Expand Down Expand Up @@ -695,6 +724,8 @@ mod test {

use indoc::{formatdoc, indoc};

use crate::wheel::format_shebang;

use super::{parse_key_value_file, parse_wheel_file, read_record_file, relative_to, Script};

#[test]
Expand Down Expand Up @@ -822,6 +853,40 @@ mod test {
);
}

#[test]
fn test_shebang() {
// By default, use a simple shebang.
let executable = Path::new("/usr/bin/python3");
let os_name = "posix";
assert_eq!(format_shebang(executable, os_name), "#!/usr/bin/python3");

// If the path contains spaces, we should use the `exec` trick.
let executable = Path::new("/usr/bin/path to python3");
let os_name = "posix";
assert_eq!(
format_shebang(executable, os_name),
"#!/bin/sh\n'''exec' '/usr/bin/path to python3' \"$0\" \"$@\"\n' '''"
);

// Except on Windows...
let executable = Path::new("/usr/bin/path to python3");
let os_name = "nt";
assert_eq!(
format_shebang(executable, os_name),
"#!/usr/bin/path to python3"
);

// Quotes, however, are ok.
let executable = Path::new("/usr/bin/'python3'");
let os_name = "posix";
assert_eq!(format_shebang(executable, os_name), "#!/usr/bin/'python3'");

// If the path is too long, we should not use the `exec` trick.
let executable = Path::new("/usr/bin/path/to/a/very/long/executable/executable/executable/executable/executable/executable/executable/executable/name/python3");
let os_name = "posix";
assert_eq!(format_shebang(executable, os_name), "#!/bin/sh\n'''exec' '/usr/bin/path/to/a/very/long/executable/executable/executable/executable/executable/executable/executable/executable/name/python3' \"$0\" \"$@\"\n' '''");
}

#[test]
#[cfg(all(windows, target_arch = "x86_64"))]
fn test_launchers_are_small() {
Expand Down
1 change: 1 addition & 0 deletions crates/uv-interpreter/src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ impl Interpreter {
} else {
self.include().to_path_buf()
},
os_name: self.markers.os_name.clone(),
}
}
}
Expand Down

0 comments on commit e811070

Please sign in to comment.