diff --git a/Cargo.lock b/Cargo.lock index 417c40c83..30a8adc25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5025,6 +5025,7 @@ dependencies = [ "uv-pep440", "uv-platform-tags", "uv-pypi-types", + "uv-shell", "uv-trampoline-builder", "uv-warnings", "walkdir", diff --git a/Cargo.toml b/Cargo.toml index 9d02a64ca..7e2ac58fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,7 +162,7 @@ tempfile = { version = "3.12.0" } textwrap = { version = "0.16.1" } thiserror = { version = "1.0.63" } tl = { git = "https://github.com/charliermarsh/tl.git", rev = "6e25b2ee2513d75385101a8ff9f591ef51f314ec" } -tokio = { version = "1.40.0", features = ["fs", "io-util", "macros", "process", "signal", "sync"] } +tokio = { version = "1.40.0", features = ["fs", "io-util", "macros", "process", "rt", "signal", "sync"] } tokio-stream = { version = "0.1.16" } tokio-util = { version = "0.7.12", features = ["compat"] } toml = { version = "0.8.19" } diff --git a/crates/uv-install-wheel/Cargo.toml b/crates/uv-install-wheel/Cargo.toml index 9ce308160..f44e96afa 100644 --- a/crates/uv-install-wheel/Cargo.toml +++ b/crates/uv-install-wheel/Cargo.toml @@ -28,6 +28,7 @@ uv-normalize = { workspace = true } uv-pep440 = { workspace = true } uv-platform-tags = { workspace = true } uv-pypi-types = { workspace = true } +uv-shell = { workspace = true } uv-trampoline-builder = { workspace = true } uv-warnings = { workspace = true } diff --git a/crates/uv-install-wheel/src/wheel.rs b/crates/uv-install-wheel/src/wheel.rs index 055a3d3d2..f5fb9ab96 100644 --- a/crates/uv-install-wheel/src/wheel.rs +++ b/crates/uv-install-wheel/src/wheel.rs @@ -16,6 +16,7 @@ use uv_cache_info::CacheInfo; use uv_fs::{relative_to, Simplified}; use uv_normalize::PackageName; use uv_pypi_types::DirectUrl; +use uv_shell::escape_posix_for_single_quotes; use uv_trampoline_builder::windows_script_launcher; use crate::record::RecordEntry; @@ -122,10 +123,11 @@ fn format_shebang(executable: impl AsRef, os_name: &str, relocatable: bool } else { "" }; - // 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!("{}'{}'", prefix, executable.replace('\'', r#"'"'"'"#)); + let executable = format!( + "{}'{}'", + prefix, + escape_posix_for_single_quotes(&executable) + ); return format!("#!/bin/sh\n'''exec' {executable} \"$0\" \"$@\"\n' '''"); } } @@ -769,7 +771,7 @@ mod test { Wheel-Version: 1.0 Generator: bdist_wheel (0.37.1) Root-Is-Purelib: false - Tag: cp38-cp38-manylinux_2_17_x86_64 + Tag: cp38-cp38-manylinux_2_17_x86_64 "}; let wheel = parse_email_message_file(&mut text.as_bytes(), "WHEEL").unwrap(); diff --git a/crates/uv-shell/src/lib.rs b/crates/uv-shell/src/lib.rs index 5ba401dbe..a8689f764 100644 --- a/crates/uv-shell/src/lib.rs +++ b/crates/uv-shell/src/lib.rs @@ -1,5 +1,8 @@ +mod shlex; pub mod windows; +pub use shlex::{escape_posix_for_single_quotes, shlex_posix, shlex_windows}; + use std::path::{Path, PathBuf}; use uv_fs::Simplified; use uv_static::EnvVars; diff --git a/crates/uv-shell/src/shlex.rs b/crates/uv-shell/src/shlex.rs new file mode 100644 index 000000000..5fe9b747e --- /dev/null +++ b/crates/uv-shell/src/shlex.rs @@ -0,0 +1,50 @@ +use crate::{Shell, Simplified}; +use std::path::Path; + +/// Quote a path, if necessary, for safe use in a POSIX-compatible shell command. +pub fn shlex_posix(executable: impl AsRef) -> String { + // Convert to a display path. + let executable = executable.as_ref().portable_display().to_string(); + + // Like Python's `shlex.quote`: + // > Use single quotes, and put single quotes into double quotes + // > The string $'b is then quoted as '$'"'"'b' + if executable.contains(' ') { + format!("'{}'", escape_posix_for_single_quotes(&executable)) + } else { + executable + } +} + +/// Escape a string for being used in single quotes in a POSIX-compatible shell command. +/// +/// We want our scripts to support any POSIX shell. There's two kind of quotes in POSIX: +/// Single and double quotes. In bash, single quotes must not contain another single +/// quote, you can't even escape it ( under "QUOTING"). +/// Double quotes have escaping rules different from shell to shell, which we can't do. +/// Bash has `$'\''`, but that's not universal enough. +/// +/// As solution, use implicit string concatenations, by putting the single quote into double +/// quotes. +pub fn escape_posix_for_single_quotes(string: &str) -> String { + string.replace('\'', r#"'"'"'"#) +} + +/// Quote a path, if necessary, for safe use in `PowerShell` and `cmd`. +pub fn shlex_windows(executable: impl AsRef, shell: Shell) -> String { + // Convert to a display path. + let executable = executable.as_ref().user_display().to_string(); + + // Wrap the executable in quotes (and a `&` invocation on PowerShell), if it contains spaces. + if executable.contains(' ') { + if shell == Shell::Powershell { + // For PowerShell, wrap in a `&` invocation. + format!("& \"{executable}\"") + } else { + // Otherwise, assume `cmd`, which doesn't need the `&`. + format!("\"{executable}\"") + } + } else { + executable + } +} diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 9520ee9c9..cc68cbb02 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -25,7 +25,7 @@ use uv_python::{ }; use uv_resolver::{ExcludeNewer, FlatIndex}; use uv_settings::PythonInstallMirrors; -use uv_shell::Shell; +use uv_shell::{shlex_posix, shlex_windows, Shell}; use uv_types::{BuildContext, BuildIsolation, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError}; @@ -421,37 +421,3 @@ async fn venv_impl( Ok(ExitStatus::Success) } - -/// Quote a path, if necessary, for safe use in a POSIX-compatible shell command. -fn shlex_posix(executable: impl AsRef) -> String { - // Convert to a display path. - let executable = executable.as_ref().portable_display().to_string(); - - // Like Python's `shlex.quote`: - // > Use single quotes, and put single quotes into double quotes - // > The string $'b is then quoted as '$'"'"'b' - if executable.contains(' ') { - format!("'{}'", executable.replace('\'', r#"'"'"'"#)) - } else { - executable - } -} - -/// Quote a path, if necessary, for safe use in `PowerShell` and `cmd`. -fn shlex_windows(executable: impl AsRef, shell: Shell) -> String { - // Convert to a display path. - let executable = executable.as_ref().user_display().to_string(); - - // Wrap the executable in quotes (and a `&` invocation on PowerShell), if it contains spaces. - if executable.contains(' ') { - if shell == Shell::Powershell { - // For PowerShell, wrap in a `&` invocation. - format!("& \"{executable}\"") - } else { - // Otherwise, assume `cmd`, which doesn't need the `&`. - format!("\"{executable}\"") - } - } else { - executable - } -}