diff --git a/Cargo.lock b/Cargo.lock index 13d693a65..e9d615da8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,9 +599,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "c3a42d84bb6b69d3a8b3eaacf0d88f179e1929695e1ad012b6cf64d9caaa5fd2" dependencies = [ "jobserver", "libc", @@ -675,6 +675,8 @@ checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" dependencies = [ "clap_builder", "clap_derive", + "crypto-common", + "inout", ] [[package]] @@ -1071,6 +1073,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_arbitrary" version = "1.4.1" @@ -1082,6 +1093,15 @@ dependencies = [ "syn", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "diff" version = "0.1.13" @@ -1157,9 +1177,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" @@ -1286,9 +1306,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1476,9 +1496,9 @@ checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", @@ -2260,9 +2280,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags 2.9.1", "libc", @@ -2496,18 +2516,18 @@ dependencies = [ [[package]] name = "munge" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cce144fab80fbb74ec5b89d1ca9d41ddf6b644ab7e986f7d3ed0aab31625cb1" +checksum = "d7feb0b48aa0a25f9fe0899482c6e1379ee7a11b24a53073eacdecb9adb6dc60" dependencies = [ "munge_macro", ] [[package]] name = "munge_macro" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "574af9cd5b9971cbfdf535d6a8d533778481b241c447826d976101e0149392a1" +checksum = "f2e3795a5d2da581a8b252fec6022eee01aea10161a4d1bf237d4cbe47f7e988" dependencies = [ "proc-macro2", "quote", @@ -2606,6 +2626,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" @@ -2726,6 +2752,23 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +[[package]] +name = "p12" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4873306de53fe82e7e484df31e1e947d61514b6ea2ed6cd7b45d63006fd9224" +dependencies = [ + "cbc", + "cipher", + "des", + "getrandom 0.2.16", + "hmac", + "lazy_static", + "rc2", + "sha1", + "yasna", +] + [[package]] name = "parking" version = "2.2.1" @@ -2773,6 +2816,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2931,6 +2984,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" @@ -3229,6 +3288,28 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rc2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +dependencies = [ + "cipher", +] + +[[package]] +name = "rcgen" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0068c5b3cab1d4e271e0bb6539c87563c43411cad90b057b15c79958fbeb41f7" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "rctree" version = "0.5.0" @@ -3237,18 +3318,18 @@ checksum = "3b42e27ef78c35d3998403c1d26f3efd9e135d3e5121b0a4845cc5cc27547f4f" [[package]] name = "redox_syscall" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags 2.9.1", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", @@ -3564,9 +3645,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -3602,9 +3683,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "once_cell", "ring", @@ -3909,6 +3990,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3954,9 +4046,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -4398,6 +4490,25 @@ dependencies = [ "tikv-jemalloc-sys", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + [[package]] name = "tiny-skia" version = "0.8.4" @@ -4465,9 +4576,9 @@ source = "git+https://github.com/astral-sh/tl.git?rev=6e25b2ee2513d75385101a8ff9 [[package]] name = "tokio" -version = "1.47.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -4519,9 +4630,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -4533,9 +4644,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ "foldhash", "indexmap", @@ -4564,20 +4675,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "toml_datetime 0.6.11", - "winnow", -] - -[[package]] -name = "toml_edit" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1dee9dc43ac2aaf7d3b774e2fba5148212bf2bd9374f4e50152ebe9afd03d42" +checksum = "17d3b47e6b7a040216ae5302712c94d1cf88c95b47efa80e2c59ce96c878267e" dependencies = [ "indexmap", "serde", @@ -4590,9 +4690,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ "winnow", ] @@ -6362,9 +6462,13 @@ dependencies = [ "assert_cmd", "assert_fs", "fs-err", + "p12", + "rcgen", + "tempfile", "thiserror 2.0.12", "uv-fs", "which", + "windows 0.61.3", "zip", ] @@ -6734,7 +6838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1" dependencies = [ "windows-core 0.59.0", - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -6769,7 +6873,7 @@ dependencies = [ "windows-interface", "windows-result", "windows-strings 0.3.1", - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -6916,7 +7020,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -6952,10 +7056,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -7198,6 +7303,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" @@ -7337,9 +7451,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "bdbb9122ea75b11bf96e7492afb723e8a7fbe12c67417aa95e7e3d18144d37cd" dependencies = [ "yoke", "zerofrom", diff --git a/Cargo.toml b/Cargo.toml index 5b3fcafd2..ae4416f81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -206,7 +206,9 @@ hyper = { version = "1.4.1", features = ["server", "http1"] } hyper-util = { version = "0.1.8", features = ["tokio"] } ignore = { version = "0.4.23" } insta = { version = "1.40.0", features = ["json", "filters", "redactions"] } +p12 = { version = "0.6.3" } predicates = { version = "3.1.2" } +rcgen = { version = "0.14.3" } similar = { version = "2.6.0" } temp-env = { version = "0.3.6" } test-case = { version = "3.3.1" } diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index a5645c126..e2f342234 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -619,11 +619,11 @@ mod test { "#}; assert_snapshot!(format_err(input).await, @r#" - error: TOML parse error at line 8, column 28 + error: TOML parse error at line 8, column 16 | 8 | tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" } - | ^ - missing comma between key-value pairs, expected `,` + | ^ + missing opening quote, expected `"` "#); } diff --git a/crates/uv-install-wheel/Cargo.toml b/crates/uv-install-wheel/Cargo.toml index 06e72af39..3eac625f9 100644 --- a/crates/uv-install-wheel/Cargo.toml +++ b/crates/uv-install-wheel/Cargo.toml @@ -29,7 +29,6 @@ uv-normalize = { workspace = true } uv-pep440 = { workspace = true } uv-pypi-types = { workspace = true } uv-shell = { workspace = true } -uv-trampoline-builder = { workspace = true } uv-warnings = { workspace = true } clap = { workspace = true, optional = true, features = ["derive"] } @@ -52,6 +51,8 @@ tracing = { workspace = true } walkdir = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] +uv-trampoline-builder = { workspace = true } + same-file = { workspace = true } self-replace = { workspace = true } diff --git a/crates/uv-install-wheel/src/lib.rs b/crates/uv-install-wheel/src/lib.rs index f36a699f1..ea835d7be 100644 --- a/crates/uv-install-wheel/src/lib.rs +++ b/crates/uv-install-wheel/src/lib.rs @@ -80,6 +80,7 @@ pub enum Error { MismatchedVersion(Version, Version), #[error("Invalid egg-link")] InvalidEggLink(PathBuf), + #[cfg(windows)] #[error(transparent)] LauncherError(#[from] uv_trampoline_builder::Error), #[error("Scripts must not use the reserved name {0}")] diff --git a/crates/uv-install-wheel/src/wheel.rs b/crates/uv-install-wheel/src/wheel.rs index 78ebab994..62d80e291 100644 --- a/crates/uv-install-wheel/src/wheel.rs +++ b/crates/uv-install-wheel/src/wheel.rs @@ -17,7 +17,6 @@ use uv_fs::{Simplified, persist_with_retry_sync, relative_to}; 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 uv_warnings::warn_user_once; use crate::record::RecordEntry; @@ -226,14 +225,18 @@ pub(crate) fn write_script_entrypoints( ); // If necessary, wrap the launcher script in a Windows launcher binary. - if cfg!(windows) { + #[cfg(windows)] + { + use uv_trampoline_builder::windows_script_launcher; write_file_recorded( site_packages, &entrypoint_relative, &windows_script_launcher(&launcher_python_script, is_gui, &launcher_executable)?, record, )?; - } else { + } + #[cfg(not(windows))] + { write_file_recorded( site_packages, &entrypoint_relative, diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index 1c6f09b15..cccf911c0 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -34,7 +34,6 @@ uv-pypi-types = { workspace = true } uv-redacted = { workspace = true } uv-state = { workspace = true } uv-static = { workspace = true } -uv-trampoline-builder = { workspace = true } uv-warnings = { workspace = true } anyhow = { workspace = true } @@ -69,6 +68,8 @@ which = { workspace = true } once_cell = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] +uv-trampoline-builder = { workspace = true } + windows-registry = { workspace = true } windows-result = { workspace = true } windows-sys = { workspace = true } diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 34e239e0a..581cb448d 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -9,7 +9,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_configuration::{Preview, PreviewFeatures}; @@ -21,7 +20,6 @@ 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 crate::downloads::{Error as DownloadError, ManagedPythonDownload}; use crate::implementation::{ @@ -94,6 +92,7 @@ pub enum Error { }, #[error("Failed to find a directory to install executables into")] NoExecutableDirectory, + #[cfg(windows)] #[error(transparent)] LauncherError(#[from] uv_trampoline_builder::Error), #[error("Failed to read managed Python directory name: {0}")] @@ -620,13 +619,17 @@ impl ManagedPythonInstallation { /// Returns `true` if the path is a link to this installation's binary, e.g., as created by /// [`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() - } else if cfg!(windows) { + #[cfg(unix)] + { + same_file::is_same_file(path, self.executable(false)).unwrap_or_default() + } + #[cfg(windows)] + { + use uv_trampoline_builder::{Launcher, LauncherKind}; 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 @@ -634,7 +637,9 @@ impl ManagedPythonInstallation { // directly. dunce::canonicalize(&launcher.python_path).unwrap_or(launcher.python_path) == self.executable(false) - } else { + } + #[cfg(not(any(unix, windows)))] + { unreachable!("Only Windows and Unix are supported") } } @@ -877,7 +882,8 @@ pub fn create_link_to_executable(link: &Path, executable: &Path) -> Result<(), E err, })?; - if cfg!(unix) { + #[cfg(unix)] + { // Note this will never copy on Unix — we use it here to allow compilation on Windows match symlink_or_copy_file(executable, link) { Ok(()) => Ok(()), @@ -890,7 +896,11 @@ pub fn create_link_to_executable(link: &Path, executable: &Path) -> Result<(), E err, }), } - } else if cfg!(windows) { + } + #[cfg(windows)] + { + use uv_trampoline_builder::windows_python_launcher; + // TODO(zanieb): Install GUI launchers as well let launcher = windows_python_launcher(executable, false)?; @@ -906,7 +916,9 @@ pub fn create_link_to_executable(link: &Path, executable: &Path) -> Result<(), E err, }) } - } else { + } + #[cfg(not(any(unix, windows)))] + { unimplemented!("Only Windows and Unix systems are supported.") } } diff --git a/crates/uv-trampoline-builder/Cargo.toml b/crates/uv-trampoline-builder/Cargo.toml index be38ddd82..7227bd7be 100644 --- a/crates/uv-trampoline-builder/Cargo.toml +++ b/crates/uv-trampoline-builder/Cargo.toml @@ -23,14 +23,22 @@ 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 = { version = "0.61.0", features = [ + "std", + "Win32_System_LibraryLoader", +] } + [dev-dependencies] assert_cmd = { workspace = true } assert_fs = { workspace = true } anyhow = { workspace = true } fs-err = { workspace = true } +p12 = { 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..4f982d9c4 100644 --- a/crates/uv-trampoline-builder/src/lib.rs +++ b/crates/uv-trampoline-builder/src/lib.rs @@ -1,8 +1,7 @@ -use std::io::{self, Cursor, Read, Seek, Write}; +use std::io::{self, Cursor, Write}; use std::path::{Path, PathBuf}; use std::str::Utf8Error; -use fs_err::File; use thiserror::Error; use uv_fs::Simplified; use zip::ZipWriter; @@ -32,16 +31,23 @@ 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 +const RESOURCE_TRAMPOLINE_KIND: &str = "UV_TRAMPOLINE_KIND\0"; +const RESOURCE_PYTHON_PATH: &str = "UV_PYTHON_PATH\0"; +// 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. +const RESOURCE_SCRIPT_DATA: &str = "UV_SCRIPT_DATA\0"; #[derive(Debug)] pub struct Launcher { pub kind: LauncherKind, pub python_path: PathBuf, - payload: Vec, + pub script_data: Option>, } impl Launcher { @@ -49,119 +55,90 @@ impl Launcher { /// /// 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)] + #[allow(unused_variables)] pub fn try_from_path(path: &Path) -> Result, Error> { - let mut file = File::open(path)?; + #[cfg(not(windows))] + { + Err(Error::NotWindows) + } + #[cfg(windows)] + { + 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 { - return Ok(None); - }; + let mut path_str = path.as_os_str().encode_wide().collect::>(); + path_str.push(0); - // 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) - })?; + #[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); + }; - // 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 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); + }; - let path_length = { - let raw_length = u32::from_le_bytes(buffer); + 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()))?, + ); - if raw_length > MAX_PATH_LENGTH { - return Err(Error::InvalidPathLength(raw_length)); - } + let script_data = read_resource(module, RESOURCE_SCRIPT_DATA); - // SAFETY: Above we guarantee the length is less than 32KB - raw_length as usize - }; + Ok(Some(Self { + kind, + python_path, + script_data, + })) + })(); - // 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) - })?; + #[allow(unsafe_code)] + unsafe { + windows::Win32::Foundation::FreeLibrary(module) + .map_err(|err| Error::Io(io::Error::from_raw_os_error(err.code().0)))?; + }; - // 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> { + pub fn write_to_file(self, file_path: &Path, is_gui: bool) -> Result<(), Error> { 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"), - )); + // Write the launcher binary + fs_err::write(file_path, 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(file_path, &all_resources)?; + } else { + write_resources(file_path, 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()); - - file.write_all(&launcher)?; Ok(()) } @@ -169,8 +146,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 +164,20 @@ pub enum LauncherKind { } impl LauncherKind { - /// Return the magic number for this [`LauncherKind`]. - const fn magic_number(self) -> &'static [u8; 4] { + 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,22 +186,18 @@ 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)] @@ -288,6 +236,75 @@ fn get_launcher_bin(gui: bool) -> Result<&'static [u8], Error> { }) } +/// Helper to write Windows PE resources +#[allow(unused_variables)] +fn write_resources(path: &Path, resources: &[(&str, &[u8])]) -> Result<(), Error> { + #[cfg(not(windows))] + { + Err(Error::NotWindows) + } + #[cfg(windows)] + { + #[allow(unsafe_code)] + unsafe { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::System::LibraryLoader::{ + BeginUpdateResourceW, EndUpdateResourceW, UpdateResourceW, + }; + + let mut path_str = path.as_os_str().encode_wide().collect::>(); + path_str.push(0); + 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 { + let mut name_null_term = name.encode_utf16().collect::>(); + name_null_term.push(0); + UpdateResourceW( + handle, + windows::core::PCWSTR(RT_RCDATA as *const _), + windows::core::PCWSTR(name_null_term.as_ptr()), + 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(()) + } +} + +#[cfg(windows)] +/// Safely reads a resource from a PE file +fn read_resource(handle: windows::Win32::Foundation::HMODULE, name: &str) -> Option> { + #[allow(unsafe_code)] + unsafe { + use windows::Win32::System::LibraryLoader::{ + FindResourceW, LoadResource, LockResource, SizeofResource, + }; + // Find the resource + let resource = FindResourceW( + Some(handle), + windows::core::PCWSTR(name.encode_utf16().collect::>().as_ptr()), + windows::core::PCWSTR(RT_RCDATA as *const _), + ); + + // Get resource size and data + let size = SizeofResource(Some(handle), resource); + let data = LoadResource(Some(handle), resource).ok()?; + let ptr = LockResource(data); + let ptr = ptr.cast::(); + + // Copy the resource data into a Vec + Some(std::slice::from_raw_parts(ptr, size as usize).to_vec()) + } +} + /// A Windows script is a minimal .exe launcher binary with the python entrypoint script appended as /// stored zip file. /// @@ -325,16 +342,28 @@ 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()?; + 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 + let launcher = fs_err::read(&temp_file)?; + fs_err::remove_file(temp_file)?; Ok(launcher) } @@ -358,15 +387,26 @@ pub fn windows_python_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()); - 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()?; + 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 +416,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 +527,66 @@ if __name__ == "__main__": format!("#!{executable}") } + /// Creates a self-signed certificate and returns its path. + fn create_temp_certificate(temp_dir: &tempfile::TempDir) -> Result { + use p12::PFX; + use rcgen::{CertificateParams, KeyPair}; + + let signing_key = KeyPair::generate()?; + let mut cert_params = CertificateParams::new(vec!["UvTrampolineTest".to_string()])?; + cert_params.insert_extended_key_usage(rcgen::ExtendedKeyUsagePurpose::CodeSigning); + let cert = cert_params.self_signed(&signing_key)?; + + // Create PKCS#12 archive + let pfx = PFX::new( + cert.der(), + &signing_key.serialize_der(), + None, + "", + "UvTrampolineTest", + ) + .expect("Failed to create PFX archive"); + + // Create temp file + let temp_pfx = temp_dir.path().join("uv-trampoline-test.pfx"); + fs_err::write(&temp_pfx, pfx.to_der())?; + + println!( + "Wrote testing code-signing certificate in {}", + temp_pfx.display() + ); + Ok(temp_pfx) + } + + /// 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().expect("Failed to create temporary directory"); + let temp_pfx = + create_temp_certificate(&temp_dir).expect("Failed to create self-signed certificate"); + + Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!( + r" + $ErrorActionPreference = 'Stop' + Import-Module Microsoft.PowerShell.Security + $pfx = Get-PfxCertificate -FilePath '{}'; + Set-AuthenticodeSignature -FilePath '{}' -Certificate $pfx; + ", + temp_pfx.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 +641,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 +668,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 +692,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 +723,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 2e5be82ce..e9c426ebf 100644 --- a/crates/uv-trampoline/README.md +++ b/crates/uv-trampoline/README.md @@ -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..0eaffb0ba 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: &str = "UV_TRAMPOLINE_KIND\0"; +const RESOURCE_PYTHON_PATH: &str = "UV_PYTHON_PATH\0"; /// The kind of trampoline. enum TrampolineKind { @@ -46,21 +48,35 @@ 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: &str) -> Option> { + unsafe { + let mut resource_id_null_term = resource_id.encode_utf16().collect::>(); + resource_id_null_term.push(0); + // Find the resource + let resource = FindResourceW( + None, + windows::core::PCWSTR(resource_id_null_term.as_ptr()), + windows::core::PCWSTR(RT_RCDATA as *const _), + ); + + // Get resource size and data + let size = SizeofResource(None, resource); + let data = LoadResource(None, resource).ok(); + + let ptr = LockResource(data?) as *const u8; + + // Copy the resource data into a Vec + Some(std::slice::from_raw_parts(ptr, size as usize).to_vec()) } } @@ -70,14 +86,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 +211,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-i686-console.exe b/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe index 3aca836cd..8d5feabf2 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..6ec25f6cd 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..a8b1e1d8d 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..34a252d19 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/Cargo.toml b/crates/uv/Cargo.toml index facb52a7a..a053fe3cb 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -53,7 +53,6 @@ uv-shell = { workspace = true } uv-static = { workspace = true } uv-tool = { workspace = true } uv-torch = { workspace = true } -uv-trampoline-builder = { workspace = true } uv-types = { workspace = true } uv-version = { workspace = true } uv-virtualenv = { workspace = true } @@ -109,6 +108,8 @@ which = { workspace = true } zip = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] +uv-trampoline-builder = { workspace = true } + arrayvec = { workspace = true } self-replace = { workspace = true } windows = { workspace = true } diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 3018512fc..f0e8090ab 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1960,11 +1960,7 @@ fn copy_entrypoint( }; let launcher = launcher.with_python_path(python_path); - let mut file = fs_err::OpenOptions::new() - .create_new(true) - .write(true) - .open(target)?; - launcher.write_to_file(&mut file)?; + launcher.write_to_file(target, 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 4bdeab474..af4cd8f87 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -29,7 +29,6 @@ use uv_python::{ PythonVersionFile, VersionFileDiscoveryOptions, VersionFilePreference, VersionRequest, }; use uv_shell::Shell; -use uv_trampoline_builder::{Launcher, LauncherKind}; use uv_warnings::{warn_user, write_error_chain}; use crate::commands::python::{ChangeEvent, ChangeEventKind}; @@ -1051,20 +1050,33 @@ fn find_matching_bin_link<'a>( mut installations: impl Iterator, path: &Path, ) -> Option<&'a ManagedPythonInstallation> { - let target = if cfg!(unix) { - if !path.is_symlink() { - return None; - } - fs_err::canonicalize(path).ok()? - } 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") - }; + #[cfg(not(any(unix, windows)))] + { + unreachable!("Only Windows and Unix are supported"); + } - installations.find(|installation| installation.executable(false) == target) + #[cfg(unix)] + { + let target = if !path.is_symlink() { + return None; + } else { + fs_err::canonicalize(path).ok()? + }; + + installations.find(|installation| installation.executable(false) == target) + } + #[cfg(windows)] + { + let target = { + use uv_trampoline_builder::{Launcher, LauncherKind}; + + let launcher: Launcher = Launcher::try_from_path(path).ok()??; + if !matches!(launcher.kind, LauncherKind::Python) { + return None; + } + dunce::canonicalize(launcher.python_path).ok()? + }; + + installations.find(|installation| installation.executable(false) == target) + } }