diff --git a/Cargo.lock b/Cargo.lock index 78dfb8597..0345601d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1086,6 +1086,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_arbitrary" version = "1.4.1" @@ -2641,6 +2650,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -2838,6 +2853,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2996,6 +3021,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3304,6 +3335,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fae430c6b28f1ad601274e78b7dffa0546de0b73b4cd32f46723c0c2a16f7a5" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "rctree" version = "0.5.0" @@ -4595,6 +4639,25 @@ dependencies = [ "tikv-jemalloc-sys", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -6663,9 +6726,12 @@ dependencies = [ "assert_cmd", "assert_fs", "fs-err", + "rcgen", + "tempfile", "thiserror 2.0.17", "uv-fs", "which", + "windows 0.59.0", "zip", ] @@ -7460,6 +7526,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index d93880be4..a9aad6bea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -196,7 +196,7 @@ uuid = { version = "1.16.0" } version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "d8efd77673c9a90792da9da31b6c0da7ea8a324b" } walkdir = { version = "2.5.0" } which = { version = "8.0.0", features = ["regex"] } -windows = { version = "0.59.0", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Console", "Win32_System_Kernel", "Win32_System_Diagnostics_Debug", "Win32_Storage_FileSystem", "Win32_System_Registry", "Win32_System_IO", "Win32_System_Ioctl"] } +windows = { version = "0.59.0", features = ["std", "Win32_Globalization", "Win32_System_LibraryLoader", "Win32_System_Console", "Win32_System_Kernel", "Win32_System_Diagnostics_Debug", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Registry", "Win32_System_IO", "Win32_System_Ioctl"] } windows-registry = { version = "0.5.0" } wiremock = { version = "0.6.4" } wmi = { version = "0.16.0", default-features = false } @@ -216,6 +216,7 @@ hyper-util = { version = "0.1.8", features = ["tokio"] } ignore = { version = "0.4.23" } insta = { version = "1.40.0", features = ["json", "filters", "redactions"] } predicates = { version = "3.1.2" } +rcgen = { version = "0.14.5", features = ["crypto", "pem", "ring"], default-features = false } similar = { version = "2.6.0" } temp-env = { version = "0.3.6" } test-case = { version = "3.3.1" } diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index d9ae5a336..558a5a1e2 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -10,7 +10,6 @@ use std::str::FromStr; use fs_err as fs; use itertools::Itertools; -use same_file::is_same_file; use thiserror::Error; use tracing::{debug, warn}; use uv_preview::{Preview, PreviewFeatures}; @@ -22,7 +21,7 @@ use uv_platform::{Error as PlatformError, Os}; use uv_platform::{LibcDetectionError, Platform}; use uv_state::{StateBucket, StateStore}; use uv_static::EnvVars; -use uv_trampoline_builder::{Launcher, windows_python_launcher}; +use uv_trampoline_builder::{Launcher, LauncherKind}; use crate::downloads::{Error as DownloadError, ManagedPythonDownload}; use crate::implementation::{ @@ -649,12 +648,12 @@ impl ManagedPythonInstallation { /// [`create_bin_link`]. pub fn is_bin_link(&self, path: &Path) -> bool { if cfg!(unix) { - is_same_file(path, self.executable(false)).unwrap_or_default() + same_file::is_same_file(path, self.executable(false)).unwrap_or_default() } else if cfg!(windows) { let Some(launcher) = Launcher::try_from_path(path).unwrap_or_default() else { return false; }; - if !matches!(launcher.kind, uv_trampoline_builder::LauncherKind::Python) { + if !matches!(launcher.kind, LauncherKind::Python) { return false; } // We canonicalize the target path of the launcher in case it includes a minor version @@ -922,6 +921,8 @@ pub fn create_link_to_executable(link: &Path, executable: &Path) -> Result<(), E }), } } else if cfg!(windows) { + use uv_trampoline_builder::windows_python_launcher; + // TODO(zanieb): Install GUI launchers as well let launcher = windows_python_launcher(executable, false)?; @@ -938,7 +939,7 @@ pub fn create_link_to_executable(link: &Path, executable: &Path) -> Result<(), E }) } } else { - unimplemented!("Only Windows and Unix systems are supported.") + unimplemented!("Only Windows and Unix are supported.") } } diff --git a/crates/uv-trampoline-builder/Cargo.toml b/crates/uv-trampoline-builder/Cargo.toml index be38ddd82..8736a0bd1 100644 --- a/crates/uv-trampoline-builder/Cargo.toml +++ b/crates/uv-trampoline-builder/Cargo.toml @@ -23,14 +23,18 @@ workspace = true [dependencies] uv-fs = { workspace = true } - fs-err = {workspace = true } +tempfile = { workspace = true } thiserror = { workspace = true } zip = { workspace = true } +[target.'cfg(target_os = "windows")'.dependencies] +windows = { workspace = true } + [dev-dependencies] assert_cmd = { workspace = true } assert_fs = { workspace = true } anyhow = { workspace = true } fs-err = { workspace = true } +rcgen = { workspace = true } which = { workspace = true } diff --git a/crates/uv-trampoline-builder/src/lib.rs b/crates/uv-trampoline-builder/src/lib.rs index 1a25b9454..3942c9058 100644 --- a/crates/uv-trampoline-builder/src/lib.rs +++ b/crates/uv-trampoline-builder/src/lib.rs @@ -1,12 +1,9 @@ -use std::io::{self, Cursor, Read, Seek, Write}; +use std::io; use std::path::{Path, PathBuf}; use std::str::Utf8Error; use fs_err::File; use thiserror::Error; -use uv_fs::Simplified; -use zip::ZipWriter; -use zip::write::SimpleFileOptions; #[cfg(all(windows, target_arch = "x86"))] const LAUNCHER_I686_GUI: &[u8] = @@ -32,136 +29,150 @@ const LAUNCHER_AARCH64_GUI: &[u8] = const LAUNCHER_AARCH64_CONSOLE: &[u8] = include_bytes!("../../uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe"); -// See `uv-trampoline::bounce`. These numbers must match. -const PATH_LENGTH_SIZE: usize = size_of::(); -const MAX_PATH_LENGTH: u32 = 32 * 1024; -const MAGIC_NUMBER_SIZE: usize = 4; +// https://learn.microsoft.com/en-us/windows/win32/menurc/resource-types +#[cfg(windows)] +const RT_RCDATA: u16 = 10; + +// Resource IDs matching uv-trampoline +#[cfg(windows)] +const RESOURCE_TRAMPOLINE_KIND: windows::core::PCWSTR = windows::core::w!("UV_TRAMPOLINE_KIND"); +#[cfg(windows)] +const RESOURCE_PYTHON_PATH: windows::core::PCWSTR = windows::core::w!("UV_PYTHON_PATH"); +// Note: This does not need to be looked up as a resource, as we rely on `zipimport` +// to do the loading work. Still, keeping the content under a resource means that it +// sits nicely under the PE format. +#[cfg(windows)] +const RESOURCE_SCRIPT_DATA: windows::core::PCWSTR = windows::core::w!("UV_SCRIPT_DATA"); #[derive(Debug)] pub struct Launcher { pub kind: LauncherKind, pub python_path: PathBuf, - payload: Vec, + pub script_data: Option>, } impl Launcher { + /// Attempt to read [`Launcher`] metadata from a trampoline executable file. + /// + /// On Unix, this always returns [`None`]. Trampolines are a Windows-specific feature and cannot + /// be read on other platforms. + #[cfg(not(windows))] + pub fn try_from_path(_path: &Path) -> Result, Error> { + Ok(None) + } + /// Read [`Launcher`] metadata from a trampoline executable file. /// /// Returns `Ok(None)` if the file is not a trampoline executable. /// Returns `Err` if the file looks like a trampoline executable but is formatted incorrectly. - /// - /// Expects the following metadata to be at the end of the file: - /// - /// ```text - /// - file path (no greater than 32KB) - /// - file path length (u32) - /// - magic number(4 bytes) - /// ``` - /// - /// This should only be used on Windows, but should just return `Ok(None)` on other platforms. - /// - /// This is an implementation of [`uv-trampoline::bounce::read_trampoline_metadata`] that - /// returns errors instead of panicking. Unlike the utility there, we don't assume that the - /// file we are reading is a trampoline. - #[allow(clippy::cast_possible_wrap)] + #[cfg(windows)] pub fn try_from_path(path: &Path) -> Result, Error> { - let mut file = File::open(path)?; + use std::os::windows::ffi::OsStrExt; + use windows::Win32::System::LibraryLoader::LOAD_LIBRARY_AS_DATAFILE; + use windows::Win32::System::LibraryLoader::LoadLibraryExW; - // Read the magic number - let Some(kind) = LauncherKind::try_from_file(&mut file)? else { + let path_str = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + // SAFETY: winapi call; null-terminated strings + #[allow(unsafe_code)] + let Some(module) = (unsafe { + LoadLibraryExW( + windows::core::PCWSTR(path_str.as_ptr()), + None, + LOAD_LIBRARY_AS_DATAFILE, + ) + .ok() + }) else { return Ok(None); }; - // Seek to the start of the path length. - let path_length_offset = (MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE) as i64; - file.seek(io::SeekFrom::End(-path_length_offset)) - .map_err(|err| { - Error::InvalidLauncherSeek("path length".to_string(), path_length_offset, err) - })?; + let result = (|| { + let Some(kind_data) = read_resource(module, RESOURCE_TRAMPOLINE_KIND) else { + return Ok(None); + }; + let Some(kind) = LauncherKind::from_resource_value(kind_data[0]) else { + return Err(Error::UnprocessableMetadata); + }; - // Read the path length - let mut buffer = [0; PATH_LENGTH_SIZE]; - file.read_exact(&mut buffer) - .map_err(|err| Error::InvalidLauncherRead("path length".to_string(), err))?; + let Some(path_data) = read_resource(module, RESOURCE_PYTHON_PATH) else { + return Ok(None); + }; + let python_path = PathBuf::from( + String::from_utf8(path_data).map_err(|err| Error::InvalidPath(err.utf8_error()))?, + ); - let path_length = { - let raw_length = u32::from_le_bytes(buffer); + let script_data = read_resource(module, RESOURCE_SCRIPT_DATA); - if raw_length > MAX_PATH_LENGTH { - return Err(Error::InvalidPathLength(raw_length)); - } + Ok(Some(Self { + kind, + python_path, + script_data, + })) + })(); - // SAFETY: Above we guarantee the length is less than 32KB - raw_length as usize + // SAFETY: winapi call; handle is known to be valid. + #[allow(unsafe_code)] + unsafe { + windows::Win32::Foundation::FreeLibrary(module) + .map_err(|err| Error::Io(io::Error::from_raw_os_error(err.code().0)))?; }; - // Seek to the start of the path - let path_offset = (MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE + path_length) as i64; - file.seek(io::SeekFrom::End(-path_offset)).map_err(|err| { - Error::InvalidLauncherSeek("executable path".to_string(), path_offset, err) - })?; - - // Read the path - let mut buffer = vec![0u8; path_length]; - file.read_exact(&mut buffer) - .map_err(|err| Error::InvalidLauncherRead("executable path".to_string(), err))?; - - let path = PathBuf::from( - String::from_utf8(buffer).map_err(|err| Error::InvalidPath(err.utf8_error()))?, - ); - - #[allow(clippy::cast_possible_truncation)] - let file_size = { - let raw_length = file - .seek(io::SeekFrom::End(0)) - .map_err(|e| Error::InvalidLauncherSeek("size probe".into(), 0, e))?; - - if raw_length > usize::MAX as u64 { - return Err(Error::InvalidDataLength(raw_length)); - } - - // SAFETY: Above we guarantee the length is less than uszie - raw_length as usize - }; - - // Read the payload - file.seek(io::SeekFrom::Start(0)) - .map_err(|e| Error::InvalidLauncherSeek("rewind".into(), 0, e))?; - let payload_len = - file_size.saturating_sub(MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE + path_length); - let mut buffer = vec![0u8; payload_len]; - file.read_exact(&mut buffer) - .map_err(|err| Error::InvalidLauncherRead("payload".into(), err))?; - - Ok(Some(Self { - kind, - payload: buffer, - python_path: path, - })) + result } - pub fn write_to_file(self, file: &mut File) -> Result<(), Error> { + /// Write this trampoline launcher to a file. + /// + /// On Unix, this always returns [`Error::NotWindows`]. Trampolines are a Windows-specific + /// feature and cannot be written on other platforms. + #[cfg(not(windows))] + pub fn write_to_file(self, _file: &mut File, _is_gui: bool) -> Result<(), Error> { + Err(Error::NotWindows) + } + + /// Write this trampoline launcher to a file. + #[cfg(windows)] + pub fn write_to_file(self, file: &mut File, is_gui: bool) -> Result<(), Error> { + use std::io::Write; + use uv_fs::Simplified; + let python_path = self.python_path.simplified_display().to_string(); - if python_path.len() > MAX_PATH_LENGTH as usize { - return Err(Error::InvalidPathLength( - u32::try_from(python_path.len()).expect("path length already checked"), - )); + // Create temporary file for the base launcher + let temp_dir = tempfile::TempDir::new()?; + let temp_file = temp_dir + .path() + .join(format!("uv-trampoline-{}.exe", std::process::id())); + + // Write the launcher binary + fs_err::write(&temp_file, get_launcher_bin(is_gui)?)?; + + // Write resources + let resources = &[ + ( + RESOURCE_TRAMPOLINE_KIND, + &[self.kind.to_resource_value()][..], + ), + (RESOURCE_PYTHON_PATH, python_path.as_bytes()), + ]; + if let Some(script_data) = self.script_data { + let mut all_resources = resources.to_vec(); + all_resources.push((RESOURCE_SCRIPT_DATA, &script_data)); + write_resources(&temp_file, &all_resources)?; + } else { + write_resources(&temp_file, resources)?; } - let mut launcher: Vec = Vec::with_capacity( - self.payload.len() + python_path.len() + PATH_LENGTH_SIZE + MAGIC_NUMBER_SIZE, - ); - launcher.extend_from_slice(&self.payload); - launcher.extend_from_slice(python_path.as_bytes()); - launcher.extend_from_slice( - &u32::try_from(python_path.len()) - .expect("file path should be smaller than 4GB") - .to_le_bytes(), - ); - launcher.extend_from_slice(self.kind.magic_number()); + // Read back the complete file + let launcher = fs_err::read(&temp_file)?; + fs_err::remove_file(&temp_file)?; + // Then write it to the handle file.write_all(&launcher)?; + Ok(()) } @@ -169,8 +180,8 @@ impl Launcher { pub fn with_python_path(self, path: PathBuf) -> Self { Self { kind: self.kind, - payload: self.payload, python_path: path, + script_data: self.script_data, } } } @@ -187,45 +198,21 @@ pub enum LauncherKind { } impl LauncherKind { - /// Return the magic number for this [`LauncherKind`]. - const fn magic_number(self) -> &'static [u8; 4] { + #[cfg(windows)] + fn to_resource_value(self) -> u8 { match self { - Self::Script => b"UVSC", - Self::Python => b"UVPY", + Self::Script => 1, + Self::Python => 2, } } - /// Read a [`LauncherKind`] from 4 byte buffer. - /// - /// If the buffer does not contain a matching magic number, `None` is returned. - fn try_from_bytes(bytes: [u8; MAGIC_NUMBER_SIZE]) -> Option { - if &bytes == Self::Script.magic_number() { - return Some(Self::Script); + #[cfg(windows)] + fn from_resource_value(value: u8) -> Option { + match value { + 1 => Some(Self::Script), + 2 => Some(Self::Python), + _ => None, } - if &bytes == Self::Python.magic_number() { - return Some(Self::Python); - } - None - } - - /// Read a [`LauncherKind`] from a file handle, based on the magic number. - /// - /// This will mutate the file handle, seeking to the end of the file. - /// - /// If the file cannot be read, an [`io::Error`] is returned. If the path is not a launcher, - /// `None` is returned. - #[allow(clippy::cast_possible_wrap)] - pub fn try_from_file(file: &mut File) -> Result, Error> { - // If the file is less than four bytes, it's not a launcher. - let Ok(_) = file.seek(io::SeekFrom::End(-(MAGIC_NUMBER_SIZE as i64))) else { - return Ok(None); - }; - - let mut buffer = [0; MAGIC_NUMBER_SIZE]; - file.read_exact(&mut buffer) - .map_err(|err| Error::InvalidLauncherRead("magic number".to_string(), err))?; - - Ok(Self::try_from_bytes(buffer)) } } @@ -234,25 +221,22 @@ impl LauncherKind { pub enum Error { #[error(transparent)] Io(#[from] io::Error), - #[error("Only paths with a length up to 32KB are supported but found a length of {0} bytes")] - InvalidPathLength(u32), - #[error("Only data with a length up to usize is supported but found a length of {0} bytes")] - InvalidDataLength(u64), #[error("Failed to parse executable path")] InvalidPath(#[source] Utf8Error), - #[error("Failed to seek to {0} at offset {1}")] - InvalidLauncherSeek(String, i64, #[source] io::Error), - #[error("Failed to read launcher {0}")] - InvalidLauncherRead(String, #[source] io::Error), #[error( "Unable to create Windows launcher for: {0} (only x86_64, x86, and arm64 are supported)" )] UnsupportedWindowsArch(&'static str), #[error("Unable to create Windows launcher on non-Windows platform")] NotWindows, + #[error("Cannot process launcher metadata from resource")] + UnprocessableMetadata, + #[error("Resources over 2^32 bytes are not supported")] + ResourceTooLarge, } #[allow(clippy::unnecessary_wraps, unused_variables)] +#[cfg(windows)] fn get_launcher_bin(gui: bool) -> Result<&'static [u8], Error> { Ok(match std::env::consts::ARCH { #[cfg(all(windows, target_arch = "x86"))] @@ -283,26 +267,116 @@ fn get_launcher_bin(gui: bool) -> Result<&'static [u8], Error> { arch => { return Err(Error::UnsupportedWindowsArch(arch)); } - #[cfg(not(windows))] - _ => &[], }) } +/// Helper to write Windows PE resources +#[cfg(windows)] +fn write_resources(path: &Path, resources: &[(windows::core::PCWSTR, &[u8])]) -> Result<(), Error> { + // SAFETY: winapi calls; null-terminated strings + #[allow(unsafe_code)] + unsafe { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::System::LibraryLoader::{ + BeginUpdateResourceW, EndUpdateResourceW, UpdateResourceW, + }; + + let path_str = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let handle = BeginUpdateResourceW(windows::core::PCWSTR(path_str.as_ptr()), false) + .map_err(|err| Error::Io(io::Error::from_raw_os_error(err.code().0)))?; + + for (name, data) in resources { + UpdateResourceW( + handle, + windows::core::PCWSTR(RT_RCDATA as *const _), + *name, + 0, + Some(data.as_ptr().cast()), + u32::try_from(data.len()).map_err(|_| Error::ResourceTooLarge)?, + ) + .map_err(|err| Error::Io(io::Error::from_raw_os_error(err.code().0)))?; + } + + EndUpdateResourceW(handle, false) + .map_err(|err| Error::Io(io::Error::from_raw_os_error(err.code().0)))?; + } + + Ok(()) +} + +/// Safely reads a resource from a PE file +#[cfg(windows)] +fn read_resource( + handle: windows::Win32::Foundation::HMODULE, + name: windows::core::PCWSTR, +) -> Option> { + // SAFETY: winapi calls; null-terminated strings; all pointers are checked. + #[allow(unsafe_code)] + unsafe { + use windows::Win32::System::LibraryLoader::{ + FindResourceW, LoadResource, LockResource, SizeofResource, + }; + // Find the resource + let resource = FindResourceW( + Some(handle), + name, + windows::core::PCWSTR(RT_RCDATA as *const _), + ); + if resource.is_invalid() { + return None; + } + + // Get resource size and data + let size = SizeofResource(Some(handle), resource); + if size == 0 { + return None; + } + let data = LoadResource(Some(handle), resource).ok()?; + let ptr = LockResource(data) as *const u8; + if ptr.is_null() { + return None; + } + + // Copy the resource data into a Vec + Some(std::slice::from_raw_parts(ptr, size as usize).to_vec()) + } +} + +/// Construct a Windows script launcher. +/// +/// On Unix, this always returns [`Error::NotWindows`]. Trampolines are a Windows-specific feature +/// and cannot be created on other platforms. +#[cfg(not(windows))] +pub fn windows_script_launcher( + _launcher_python_script: &str, + _is_gui: bool, + _python_executable: impl AsRef, +) -> Result, Error> { + Err(Error::NotWindows) +} + +/// Construct a Windows script launcher. +/// /// A Windows script is a minimal .exe launcher binary with the python entrypoint script appended as /// stored zip file. /// /// -#[allow(unused_variables)] +#[cfg(windows)] pub fn windows_script_launcher( launcher_python_script: &str, is_gui: bool, python_executable: impl AsRef, ) -> Result, Error> { - // This method should only be called on Windows, but we avoid `#[cfg(windows)]` to retain - // compilation on all platforms. - if cfg!(not(windows)) { - return Err(Error::NotWindows); - } + use std::io::{Cursor, Write}; + + use zip::ZipWriter; + use zip::write::SimpleFileOptions; + + use uv_fs::Simplified; let launcher_bin: &[u8] = get_launcher_bin(is_gui)?; @@ -325,48 +399,84 @@ pub fn windows_script_launcher( let python = python_executable.as_ref(); let python_path = python.simplified_display().to_string(); - let mut launcher: Vec = Vec::with_capacity(launcher_bin.len() + payload.len()); - launcher.extend_from_slice(launcher_bin); - launcher.extend_from_slice(&payload); - launcher.extend_from_slice(python_path.as_bytes()); - launcher.extend_from_slice( - &u32::try_from(python_path.len()) - .expect("file path should be smaller than 4GB") - .to_le_bytes(), - ); - launcher.extend_from_slice(LauncherKind::Script.magic_number()); + // Start with base launcher binary + // Create temporary file for the launcher + let temp_dir = tempfile::TempDir::new()?; + let temp_file = temp_dir + .path() + .join(format!("uv-trampoline-{}.exe", std::process::id())); + fs_err::write(&temp_file, launcher_bin)?; + + // Write resources + let resources = &[ + ( + RESOURCE_TRAMPOLINE_KIND, + &[LauncherKind::Script.to_resource_value()][..], + ), + (RESOURCE_PYTHON_PATH, python_path.as_bytes()), + (RESOURCE_SCRIPT_DATA, &payload), + ]; + write_resources(&temp_file, resources)?; + + // Read back the complete file + // TODO(zanieb): It's weird that we write/read from a temporary file here because in the main + // usage at `write_script_entrypoints` we do the same thing again. We should refactor these + // to avoid repeated work. + let launcher = fs_err::read(&temp_file)?; + fs_err::remove_file(temp_file)?; Ok(launcher) } +/// Construct a Windows Python launcher. +/// +/// On Unix, this always returns [`Error::NotWindows`]. Trampolines are a Windows-specific feature +/// and cannot be created on other platforms. +#[cfg(not(windows))] +pub fn windows_python_launcher( + _python_executable: impl AsRef, + _is_gui: bool, +) -> Result, Error> { + Err(Error::NotWindows) +} + +/// Construct a Windows Python launcher. +/// /// A minimal .exe launcher binary for Python. /// /// Sort of equivalent to a `python` symlink on Unix. -#[allow(unused_variables)] +#[cfg(windows)] pub fn windows_python_launcher( python_executable: impl AsRef, is_gui: bool, ) -> Result, Error> { - // This method should only be called on Windows, but we avoid `#[cfg(windows)]` to retain - // compilation on all platforms. - if cfg!(not(windows)) { - return Err(Error::NotWindows); - } + use uv_fs::Simplified; let launcher_bin: &[u8] = get_launcher_bin(is_gui)?; let python = python_executable.as_ref(); let python_path = python.simplified_display().to_string(); - let mut launcher: Vec = Vec::with_capacity(launcher_bin.len()); - launcher.extend_from_slice(launcher_bin); - launcher.extend_from_slice(python_path.as_bytes()); - launcher.extend_from_slice( - &u32::try_from(python_path.len()) - .expect("file path should be smaller than 4GB") - .to_le_bytes(), - ); - launcher.extend_from_slice(LauncherKind::Python.magic_number()); + // Create temporary file for the launcher + let temp_dir = tempfile::TempDir::new()?; + let temp_file = temp_dir + .path() + .join(format!("uv-trampoline-{}.exe", std::process::id())); + fs_err::write(&temp_file, launcher_bin)?; + + // Write resources + let resources = &[ + ( + RESOURCE_TRAMPOLINE_KIND, + &[LauncherKind::Python.to_resource_value()][..], + ), + (RESOURCE_PYTHON_PATH, python_path.as_bytes()), + ]; + write_resources(&temp_file, resources)?; + + // Read back the complete file + let launcher = fs_err::read(&temp_file)?; + fs_err::remove_file(temp_file)?; Ok(launcher) } @@ -376,6 +486,7 @@ pub fn windows_python_launcher( mod test { use std::io::Write; use std::path::Path; + use std::path::PathBuf; use std::process::Command; use anyhow::Result; @@ -486,6 +597,69 @@ if __name__ == "__main__": format!("#!{executable}") } + /// Creates a self-signed certificate and returns its path. + fn create_temp_certificate(temp_dir: &tempfile::TempDir) -> Result<(PathBuf, PathBuf)> { + use rcgen::{ + CertificateParams, DnType, ExtendedKeyUsagePurpose, KeyPair, KeyUsagePurpose, SanType, + }; + + let mut params = CertificateParams::default(); + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::CodeSigning); + params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + params + .distinguished_name + .push(DnType::CommonName, "uv-test-signer"); + params + .subject_alt_names + .push(SanType::DnsName("uv-test-signer".try_into()?)); + + let private_key = KeyPair::generate()?; + let public_cert = params.self_signed(&private_key)?; + + let public_cert_path = temp_dir.path().join("uv-trampoline-test.crt"); + let private_key_path = temp_dir.path().join("uv-trampoline-test.key"); + fs_err::write(public_cert_path.as_path(), public_cert.pem())?; + fs_err::write(private_key_path.as_path(), private_key.serialize_pem())?; + + Ok((public_cert_path, private_key_path)) + } + + /// Signs the given binary using `PowerShell`'s `Set-AuthenticodeSignature` with a temporary certificate. + fn sign_authenticode(bin_path: impl AsRef) { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temporary directory"); + let (public_cert, private_key) = + create_temp_certificate(&temp_dir).expect("Failed to create self-signed certificate"); + + // Instead of powershell, we rely on pwsh which supports CreateFromPemFile. + Command::new("pwsh") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!( + r" + $ErrorActionPreference = 'Stop' + Import-Module Microsoft.PowerShell.Security + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPemFile('{}', '{}') + Set-AuthenticodeSignature -FilePath '{}' -Certificate $cert; + ", + public_cert.display().to_string().replace('\'', "''"), + private_key.display().to_string().replace('\'', "''"), + bin_path.as_ref().display().to_string().replace('\'', "''"), + ), + ]) + .env_remove("PSModulePath") + .assert() + .success(); + + println!("Signed binary: {}", bin_path.as_ref().display()); + } + #[test] fn console_script_launcher() -> Result<()> { // Create Temp Dirs @@ -540,6 +714,17 @@ if __name__ == "__main__": assert!(launcher.kind == LauncherKind::Script); assert!(launcher.python_path == python_executable_path); + // Now code-sign the launcher and verify that it still works. + sign_authenticode(console_bin_path.path()); + + let stdout_predicate = "Hello from uv-trampoline-console.exe\r\n"; + let stderr_predicate = "Hello from uv-trampoline-console.exe\r\n"; + Command::new(console_bin_path.path()) + .assert() + .success() + .stdout(stdout_predicate) + .stderr(stderr_predicate); + Ok(()) } @@ -556,7 +741,9 @@ if __name__ == "__main__": let console_launcher = windows_python_launcher(&python_executable_path, false)?; // Create Launcher - File::create(console_bin_path.path())?.write_all(console_launcher.as_ref())?; + { + File::create(console_bin_path.path())?.write_all(console_launcher.as_ref())?; + } println!( "Wrote Python Launcher in {}", @@ -578,6 +765,15 @@ if __name__ == "__main__": assert!(launcher.kind == LauncherKind::Python); assert!(launcher.python_path == python_executable_path); + // Now code-sign the launcher and verify that it still works. + sign_authenticode(console_bin_path.path()); + Command::new(console_bin_path.path()) + .arg("-c") + .arg("print('Hello from Python Launcher')") + .assert() + .success() + .stdout("Hello from Python Launcher\r\n"); + Ok(()) } @@ -600,7 +796,9 @@ if __name__ == "__main__": windows_script_launcher(&launcher_gui_script, true, &pythonw_executable_path)?; // Create Launcher - File::create(gui_bin_path.path())?.write_all(gui_launcher.as_ref())?; + { + File::create(gui_bin_path.path())?.write_all(gui_launcher.as_ref())?; + } println!("Wrote GUI Launcher in {}", gui_bin_path.path().display()); diff --git a/crates/uv-trampoline/Cargo.toml b/crates/uv-trampoline/Cargo.toml index 718c7e3f9..b5b5e2ca8 100644 --- a/crates/uv-trampoline/Cargo.toml +++ b/crates/uv-trampoline/Cargo.toml @@ -42,6 +42,7 @@ windows = { version = "0.61.0", features = [ "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", + "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/uv-trampoline/README.md b/crates/uv-trampoline/README.md index e6b424045..c352fabd8 100644 --- a/crates/uv-trampoline/README.md +++ b/crates/uv-trampoline/README.md @@ -46,7 +46,7 @@ rustup target add --toolchain nightly-2025-06-23 aarch64-pc-windows-msvc Then, build the trampolines for all supported architectures: ```shell -cargo +nightly-2025-06-23 xwin build --release --target i686-pc-windows-msvc +cargo +nightly-2025-06-23 xwin build --xwin-arch x86 --release --target i686-pc-windows-msvc cargo +nightly-2025-06-23 xwin build --release --target x86_64-pc-windows-msvc cargo +nightly-2025-06-23 xwin build --release --target aarch64-pc-windows-msvc ``` @@ -92,24 +92,16 @@ arbitrary Python scripts, and when invoked it bounces to invoking `python `. -The intended use is: +It uses PE resources to store/load the information required to do this: -- take your Python script, name it `__main__.py`, and pack it into a `.zip` file. Then concatenate - that `.zip` file onto the end of one of our prebuilt `.exe`s. -- After the zip file content, write the path to the Python executable that the script uses to run - the Python script as UTF-8 encoded string, followed by the path's length as a 32-bit little-endian - integer. -- At the very end, write the magic number `UVUV` in bytes. +| Resource name | Contains | +| :------------------------: | :-------------------------------------------------------: | +| `RESOURCE_TRAMPOLINE_KIND` | `1` (script) or `2` (Python launcher) | +| `RESOURCE_PYTHON_PATH` | Path to `python.exe` | +| `RESOURCE_SCRIPT_DATA` | Zip file, containing a Python script called `__main__.py` | -| `launcher.exe` | -| :-------------------------: | -| `` | -| `` | -| `` | -| `` | - -Then when you run `python` on the `.exe`, it will see the `.zip` trailer at the end of the `.exe`, -and automagically look inside to find and execute `__main__.py`. Easy-peasy. +This works because when you run `python` on the `.exe`, the `zipimport` mechanism will see the +embedded `.zip` file, and automagically look inside to find and execute `__main__.py`. Easy-peasy. ### Why does this exist? diff --git a/crates/uv-trampoline/src/bounce.rs b/crates/uv-trampoline/src/bounce.rs index f60f28e97..0046e174b 100644 --- a/crates/uv-trampoline/src/bounce.rs +++ b/crates/uv-trampoline/src/bounce.rs @@ -1,8 +1,5 @@ #![allow(clippy::disallowed_types)] use std::ffi::{CString, c_void}; -use std::fs::File; -use std::io::{Read, Seek, SeekFrom}; -use std::mem::size_of; use std::path::{Path, PathBuf}; use std::vec::Vec; @@ -20,6 +17,7 @@ use windows::Win32::{ JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, }, + System::LibraryLoader::{FindResourceW, LoadResource, LockResource, SizeofResource}, System::Threading::{ CreateProcessA, GetExitCodeProcess, GetStartupInfoA, INFINITE, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOA, WaitForInputIdle, @@ -34,8 +32,12 @@ use windows::core::{BOOL, PSTR, s}; use crate::{error, format, warn}; -const PATH_LEN_SIZE: usize = size_of::(); -const MAX_PATH_LEN: u32 = 32 * 1024; +// https://learn.microsoft.com/en-us/windows/win32/menurc/resource-types +const RT_RCDATA: u16 = 10; + +/// Resource IDs for the trampoline metadata +const RESOURCE_TRAMPOLINE_KIND: windows::core::PCWSTR = windows::core::w!("UV_TRAMPOLINE_KIND"); +const RESOURCE_PYTHON_PATH: windows::core::PCWSTR = windows::core::w!("UV_PYTHON_PATH"); /// The kind of trampoline. enum TrampolineKind { @@ -46,21 +48,42 @@ enum TrampolineKind { } impl TrampolineKind { - const fn magic_number(&self) -> &'static [u8; 4] { - match self { - Self::Script => b"UVSC", - Self::Python => b"UVPY", + fn from_resource(data: &[u8]) -> Option { + match data.first() { + Some(1) => Some(Self::Script), + Some(2) => Some(Self::Python), + _ => None, } } +} - fn from_buffer(buffer: &[u8]) -> Option { - if buffer.ends_with(Self::Script.magic_number()) { - Some(Self::Script) - } else if buffer.ends_with(Self::Python.magic_number()) { - Some(Self::Python) - } else { - None +/// Safely loads a resource from the current module +fn load_resource(resource_id: windows::core::PCWSTR) -> Option> { + // SAFETY: winapi calls; null-terminated strings; all pointers are checked. + unsafe { + // Find the resource + let resource = FindResourceW( + None, + resource_id, + windows::core::PCWSTR(RT_RCDATA as *const _), + ); + if resource.is_invalid() { + return None; } + + // Get resource size and data + let size = SizeofResource(None, resource); + if size == 0 { + return None; + } + let data = LoadResource(None, resource).ok(); + let ptr = LockResource(data?) as *const u8; + if ptr.is_null() { + return None; + } + + // Copy the resource data into a Vec + Some(std::slice::from_raw_parts(ptr, size as usize).to_vec()) } } @@ -70,14 +93,50 @@ fn make_child_cmdline() -> CString { let executable_name = std::env::current_exe().unwrap_or_else(|_| { error_and_exit("Failed to get executable name"); }); - let (kind, python_exe) = read_trampoline_metadata(executable_name.as_ref()); - let mut child_cmdline = Vec::::new(); + // Load trampoline kind + let trampoline_kind = load_resource(RESOURCE_TRAMPOLINE_KIND) + .and_then(|data| TrampolineKind::from_resource(&data)) + .unwrap_or_else(|| error_and_exit("Failed to load trampoline kind from resources")); + + // Load Python path + let python_path = load_resource(RESOURCE_PYTHON_PATH) + .and_then(|data| String::from_utf8(data).ok()) + .map(PathBuf::from) + .unwrap_or_else(|| error_and_exit("Failed to load Python path from resources")); + + let python_exe = if python_path.is_absolute() { + python_path + } else { + let parent_dir = match executable_name.parent() { + Some(parent) => parent, + None => { + error_and_exit("Executable path has no parent directory"); + } + }; + parent_dir.join(python_path) + }; + + let python_exe = + if !python_exe.is_absolute() || matches!(trampoline_kind, TrampolineKind::Script) { + // NOTICE: dunce adds 5kb~ + // TODO(john): In order to avoid resolving junctions and symlinks for relative paths and + // scripts, we can consider reverting https://github.com/astral-sh/uv/pull/5750/files#diff-969979506be03e89476feade2edebb4689a9c261f325988d3c7efc5e51de26d1L273-L277. + dunce::canonicalize(python_exe.as_path()).unwrap_or_else(|_| { + error_and_exit("Failed to canonicalize script path"); + }) + } else { + // For Python trampolines with absolute paths, we skip `dunce::canonicalize` to + // avoid resolving junctions. + python_exe + }; + + let mut child_cmdline = Vec::::new(); push_quoted_path(python_exe.as_ref(), &mut child_cmdline); child_cmdline.push(b' '); // Only execute the trampoline again if it's a script, otherwise, just invoke Python. - match kind { + match trampoline_kind { TrampolineKind::Python => { // SAFETY: `std::env::set_var` is safe to call on Windows, and // this code only ever runs on Windows. @@ -159,144 +218,6 @@ fn is_virtualenv(executable: &Path) -> bool { .unwrap_or(false) } -/// Reads the executable binary from the back to find: -/// -/// * The path to the Python executable -/// * The kind of trampoline we are executing -/// -/// The executable is expected to have the following format: -/// -/// * The file must end with the magic number 'UVPY' or 'UVSC' (identifying the trampoline kind) -/// * The last 4 bytes (little endian) are the length of the path to the Python executable. -/// * The path encoded as UTF-8 comes right before the length -/// -/// # Panics -/// -/// If there's any IO error, or the file does not conform to the specified format. -fn read_trampoline_metadata(executable_name: &Path) -> (TrampolineKind, PathBuf) { - let mut file_handle = File::open(executable_name).unwrap_or_else(|_| { - print_last_error_and_exit(&format!( - "Failed to open executable '{}'", - &*executable_name.to_string_lossy(), - )); - }); - - let metadata = executable_name.metadata().unwrap_or_else(|_| { - print_last_error_and_exit(&format!( - "Failed to get the size of the executable '{}'", - &*executable_name.to_string_lossy(), - )); - }); - let file_size = metadata.len(); - - // Start with a size of 1024 bytes which should be enough for most paths but avoids reading the - // entire file. - let mut buffer: Vec = Vec::new(); - let mut bytes_to_read = 1024.min(u32::try_from(file_size).unwrap_or(u32::MAX)); - - let mut kind; - let path: String = loop { - // SAFETY: Casting to usize is safe because we only support 64bit systems where usize is guaranteed to be larger than u32. - buffer.resize(bytes_to_read as usize, 0); - - file_handle - .seek(SeekFrom::Start(file_size - u64::from(bytes_to_read))) - .unwrap_or_else(|_| { - print_last_error_and_exit("Failed to set the file pointer to the end of the file"); - }); - - // Pulls in core::fmt::{write, Write, getcount} - let read_bytes = file_handle.read(&mut buffer).unwrap_or_else(|_| { - print_last_error_and_exit("Failed to read the executable file"); - }); - - // Truncate the buffer to the actual number of bytes read. - buffer.truncate(read_bytes); - - let Some(inner_kind) = TrampolineKind::from_buffer(&buffer) else { - error_and_exit( - "Magic number 'UVSC' or 'UVPY' not found at the end of the file. Did you append the magic number, the length and the path to the python executable at the end of the file?", - ); - }; - kind = inner_kind; - - // Remove the magic number - buffer.truncate(buffer.len() - kind.magic_number().len()); - - let path_len = match buffer.get(buffer.len() - PATH_LEN_SIZE..) { - Some(path_len) => { - let path_len = u32::from_le_bytes(path_len.try_into().unwrap_or_else(|_| { - error_and_exit("Slice length is not equal to 4 bytes"); - })); - - if path_len > MAX_PATH_LEN { - error_and_exit(&format!( - "Only paths with a length up to 32KBs are supported but the python path has a length of {}", - path_len - )); - } - - // SAFETY: path len is guaranteed to be less than 32KBs - path_len as usize - } - None => { - error_and_exit( - "Python executable length missing. Did you write the length of the path to the Python executable before the Magic number?", - ); - } - }; - - // Remove the path length - buffer.truncate(buffer.len() - PATH_LEN_SIZE); - - if let Some(path_offset) = buffer.len().checked_sub(path_len) { - buffer.drain(..path_offset); - - break String::from_utf8(buffer).unwrap_or_else(|_| { - error_and_exit("Python executable path is not a valid UTF-8 encoded path"); - }); - } else { - // SAFETY: Casting to u32 is safe because `path_len` is guaranteed to be less than 32KBs, - // MAGIC_NUMBER is 4 bytes and PATH_LEN_SIZE is 4 bytes. - bytes_to_read = (path_len + kind.magic_number().len() + PATH_LEN_SIZE) as u32; - - if u64::from(bytes_to_read) > file_size { - error_and_exit( - "The length of the python executable path exceeds the file size. Verify that the path length is appended to the end of the launcher script as a u32 in little endian", - ); - } - } - }; - - let path = PathBuf::from(path); - let path = if path.is_absolute() { - path - } else { - let parent_dir = match executable_name.parent() { - Some(parent) => parent, - None => { - error_and_exit("Executable path has no parent directory"); - } - }; - parent_dir.join(path) - }; - - let path = if !path.is_absolute() || matches!(kind, TrampolineKind::Script) { - // NOTICE: dunce adds 5kb~ - // TODO(john): In order to avoid resolving junctions and symlinks for relative paths and - // scripts, we can consider reverting https://github.com/astral-sh/uv/pull/5750/files#diff-969979506be03e89476feade2edebb4689a9c261f325988d3c7efc5e51de26d1L273-L277. - dunce::canonicalize(path.as_path()).unwrap_or_else(|_| { - error_and_exit("Failed to canonicalize script path"); - }) - } else { - // For Python trampolines with absolute paths, we skip `dunce::canonicalize` to - // avoid resolving junctions. - path - }; - - (kind, path) -} - fn push_arguments(output: &mut Vec) { // SAFETY: We rely on `GetCommandLineA` to return a valid pointer to a null terminated string. let arguments_as_str = unsafe { GetCommandLineA() }; diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe b/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe index aec8a5eeb..310c94b12 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-gui.exe b/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-gui.exe index c79c8e5ce..a88c52ac2 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-gui.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-gui.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe b/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe index 3aca836cd..7b4916823 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-i686-gui.exe b/crates/uv-trampoline/trampolines/uv-trampoline-i686-gui.exe index 9b978d9a7..31bc5f34d 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-i686-gui.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-i686-gui.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-console.exe b/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-console.exe index a43976351..f2d715aa1 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-console.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-console.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-gui.exe b/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-gui.exe index 5ab044a64..a20b944c3 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-gui.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-gui.exe differ diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 9bd590c9b..02c75504e 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1982,7 +1982,7 @@ fn copy_entrypoint( .create_new(true) .write(true) .open(target)?; - launcher.write_to_file(&mut file)?; + launcher.write_to_file(&mut file, is_gui)?; trace!("Updated entrypoint at {}", target.user_display()); diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 6344e87cc..52561b70a 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -1060,20 +1060,22 @@ fn find_matching_bin_link<'a>( mut installations: impl Iterator, path: &Path, ) -> Option<&'a ManagedPythonInstallation> { - let target = if cfg!(unix) { + if cfg!(unix) { if !path.is_symlink() { return None; } - fs_err::canonicalize(path).ok()? + let target = fs_err::canonicalize(path).ok()?; + + installations.find(|installation| installation.executable(false) == target) } else if cfg!(windows) { let launcher = Launcher::try_from_path(path).ok()??; if !matches!(launcher.kind, LauncherKind::Python) { return None; } - dunce::canonicalize(launcher.python_path).ok()? - } else { - unreachable!("Only Windows and Unix are supported") - }; + let target = dunce::canonicalize(launcher.python_path).ok()?; - installations.find(|installation| installation.executable(false) == target) + installations.find(|installation| installation.executable(false) == target) + } else { + unreachable!("Only Unix and Windows are supported") + } }