Filter Windows Python executables by file version metadata during discovery

This commit is contained in:
Zanie Blue 2025-06-10 08:23:03 -05:00
parent e67dff85cc
commit e0399dafd8
6 changed files with 412 additions and 21 deletions

59
Cargo.lock generated
View file

@ -738,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@ -948,6 +948,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d7439c3735f405729d52c3fbbe4de140eaf938a1fe47d227c27f8254d4302a5"
[[package]]
name = "dataview"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47a802a2cad0ff4dfc4f3110da174b7a6928c315cae523e88638cfb72941b4d5"
dependencies = [
"derive_pod",
]
[[package]]
name = "deadpool"
version = "0.10.0"
@ -977,6 +986,12 @@ dependencies = [
"syn",
]
[[package]]
name = "derive_pod"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2ea6706d74fca54e15f1d40b5cf7fe7f764aaec61352a9fcec58fe27e042fc8"
[[package]]
name = "diff"
version = "0.1.13"
@ -1115,7 +1130,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -1941,7 +1956,7 @@ checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37"
dependencies = [
"hermit-abi 0.4.0",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -2001,7 +2016,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -2385,6 +2400,12 @@ dependencies = [
"libc",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
@ -2561,6 +2582,25 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pelite"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a821dd5a5c4744099b50dc94a6a381c8b4b007f4d80da5334428e220945319b"
dependencies = [
"dataview",
"libc",
"no-std-compat",
"pelite-macros",
"winapi",
]
[[package]]
name = "pelite-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a7cf3f8ecebb0f4895f4892a8be0a0dc81b498f9d56735cb769dc31bf00815b"
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -2885,7 +2925,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -3334,7 +3374,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -3347,7 +3387,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.9.2",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -3928,7 +3968,7 @@ dependencies = [
"getrandom 0.3.1",
"once_cell",
"rustix 1.0.7",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -5603,6 +5643,7 @@ dependencies = [
"itertools 0.14.0",
"once_cell",
"owo-colors",
"pelite",
"procfs",
"regex",
"reqwest",
@ -6282,7 +6323,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]

View file

@ -132,6 +132,7 @@ owo-colors = { version = "4.1.0" }
path-slash = { version = "0.2.1" }
pathdiff = { version = "0.2.1" }
percent-encoding = { version = "2.3.1" }
pelite = { version = "0.9" }
petgraph = { version = "0.8.0" }
proc-macro2 = { version = "1.0.86" }
procfs = { version = "0.17.0", default-features = false, features = ["flate2"] }

View file

@ -71,6 +71,7 @@ procfs = { workspace = true }
windows-registry = { workspace = true }
windows-result = { workspace = true }
windows-sys = { workspace = true }
pelite = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }

View file

@ -28,6 +28,8 @@ use crate::interpreter::{StatusCodeError, UnexpectedResponseError};
use crate::managed::ManagedPythonInstallations;
#[cfg(windows)]
use crate::microsoft_store::find_microsoft_store_pythons;
#[cfg(windows)]
use crate::pe_version::extract_version_from_pe;
use crate::virtualenv::Error as VirtualEnvError;
use crate::virtualenv::{
CondaEnvironmentKind, conda_environment_from_env, virtualenv_from_env,
@ -554,6 +556,20 @@ fn python_executables_from_search_path<'a>(
.into_iter()
.flatten(),
)
.filter(|path| {
// On Windows, try PE version extraction first as an optimization
// Skip this executable if PE version doesn't match
if let Some(pe_matches) = try_pe_version_check(path, Some(version)) {
if !pe_matches {
debug!(
"Skipping `{}` based on PE version mismatch",
path.display()
);
return false;
}
}
true
})
})
.into_iter()
.flatten()
@ -668,23 +684,78 @@ fn python_interpreters<'a>(
})
}
/// On Windows, try to extract version from PE metadata to potentially skip expensive interpreter queries.
/// Returns `None` if PE extraction fails or is not available, indicating fallback to full query.
#[cfg(windows)]
fn try_pe_version_check(path: &Path, request: Option<&VersionRequest>) -> Option<bool> {
let Some(request) = request else {
// No specific version requested, can't optimize
return None;
};
let pe_version = match extract_version_from_pe(path) {
Ok(Some(version)) => version,
Ok(None) => {
debug!("No version info found in PE file: {}", path.display());
return None;
}
Err(err) => {
debug!(
"Failed to extract PE version from {}: {}",
path.display(),
err
);
return None;
}
};
let matches = request.matches_version(&pe_version);
if matches {
debug!(
"PE version {} from {} matches request {}",
pe_version,
path.display(),
request
);
} else {
debug!(
"PE version {} from {} does not match request {}",
pe_version,
path.display(),
request
);
}
Some(matches)
}
/// Non-Windows version always returns None (no optimization available)
#[cfg(not(windows))]
fn try_pe_version_check(_path: &Path, _request: Option<&VersionRequest>) -> Option<bool> {
None
}
/// Lazily convert Python executables into interpreters.
fn python_interpreters_from_executables<'a>(
executables: impl Iterator<Item = Result<(PythonSource, PathBuf), Error>> + 'a,
cache: &'a Cache,
) -> impl Iterator<Item = Result<(PythonSource, Interpreter), Error>> + 'a {
executables.map(|result| match result {
Ok((source, path)) => Interpreter::query(&path, cache)
.map(|interpreter| (source, interpreter))
.inspect(|(source, interpreter)| {
debug!(
"Found `{}` at `{}` ({source})",
interpreter.key(),
path.display()
);
})
.map_err(|err| Error::Query(Box::new(err), path, source))
.inspect_err(|err| debug!("{err}")),
executables.map(move |result| match result {
Ok((source, path)) => {
// Proceed with full interpreter query
Interpreter::query(&path, cache)
.map(|interpreter| (source, interpreter))
.inspect(|(source, interpreter)| {
debug!(
"Found `{}` at `{}` ({source})",
interpreter.key(),
path.display()
);
})
.map_err(|err| Error::Query(Box::new(err), path, source))
.inspect_err(|err| debug!("{err}"))
}
Err(err) => Err(err),
})
}

View file

@ -13,6 +13,7 @@ pub use crate::environment::{InvalidEnvironmentKind, PythonEnvironment};
pub use crate::implementation::ImplementationName;
pub use crate::installation::{PythonInstallation, PythonInstallationKey};
pub use crate::interpreter::{BrokenSymlink, Error as InterpreterError, Interpreter};
pub use crate::pe_version::extract_version_from_pe;
pub use crate::pointer_size::PointerSize;
pub use crate::prefix::Prefix;
pub use crate::python_version::PythonVersion;
@ -35,6 +36,7 @@ pub mod macos_dylib;
pub mod managed;
#[cfg(windows)]
mod microsoft_store;
mod pe_version;
pub mod platform;
mod pointer_size;
mod prefix;

View file

@ -0,0 +1,275 @@
//! Extract version information from Windows PE executables.
//!
//! This module provides functionality to extract Python version information
//! directly from Windows PE executable files using the pelite crate, which
//! can be faster than executing the Python interpreter to query its version.
use std::path::Path;
#[cfg(target_os = "windows")]
use std::str::FromStr;
#[cfg(windows)]
use tracing::debug;
#[cfg(target_os = "windows")]
use pelite::{pe32::Pe as Pe32, pe64::Pe as Pe64};
use crate::PythonVersion;
/// Extract Python version information from a Windows PE executable.
///
/// This function reads the PE file's version resource to extract version
/// information without executing the Python interpreter. This can be
/// significantly faster for version discovery.
///
/// # Arguments
///
/// * `path` - Path to the Python executable
///
/// # Returns
///
/// Returns `Ok(Some(PythonVersion))` if version information was successfully
/// extracted, `Ok(None)` if no version information was found, or an error
/// if the file could not be read or parsed.
#[cfg(target_os = "windows")]
pub fn extract_version_from_pe(path: &Path) -> Result<Option<PythonVersion>, std::io::Error> {
use pelite::FileMap;
debug!("Extracting version info from PE file: {}", path.display());
// Read the PE file
let map = FileMap::open(path)?;
// Parse as PE64 first, fall back to PE32 if needed
match parse_pe64_version(&map) {
Ok(version) => Ok(version),
Err(_) => parse_pe32_version(&map),
}
}
#[cfg(target_os = "windows")]
fn parse_pe64_version(map: &pelite::FileMap) -> Result<Option<PythonVersion>, std::io::Error> {
use pelite::pe64::PeFile;
let pe = PeFile::from_bytes(map).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to parse PE64 file: {e}"),
)
})?;
extract_version_from_pe64_file(&pe)
}
#[cfg(target_os = "windows")]
fn parse_pe32_version(map: &pelite::FileMap) -> Result<Option<PythonVersion>, std::io::Error> {
use pelite::pe32::PeFile;
let pe = PeFile::from_bytes(map).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to parse PE32 file: {e}"),
)
})?;
extract_version_from_pe32_file(&pe)
}
#[cfg(target_os = "windows")]
fn extract_version_from_pe64_file(
pe: &pelite::pe64::PeFile,
) -> Result<Option<PythonVersion>, std::io::Error> {
// Get resources from the PE file
let resources = pe.resources().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to read PE resources: {e}"),
)
})?;
// Try to get version info
let Ok(version_info) = resources.version_info() else {
debug!("No version info found in PE file");
return Ok(None);
};
// Get the fixed file info which contains version numbers
let Some(fixed_info) = version_info.fixed() else {
debug!("No fixed version info found in PE file");
return Ok(None);
};
// Extract version from the file version field
let file_version = fixed_info.dwFileVersion;
#[allow(clippy::cast_possible_truncation)]
let major = file_version.Major as u8;
#[allow(clippy::cast_possible_truncation)]
let minor = file_version.Minor as u8;
#[allow(clippy::cast_possible_truncation)]
let patch = file_version.Patch as u8;
// Validate that this looks like a Python version
if major == 0 || major > 10 || minor > 50 {
debug!(
"Version {}.{}.{} doesn't look like a Python version",
major, minor, patch
);
return Ok(None);
}
debug!("Extracted Python version: {}.{}.{}", major, minor, patch);
match PythonVersion::from_str(&format!("{major}.{minor}.{patch}")) {
Ok(version) => Ok(Some(version)),
Err(e) => {
debug!(
"Failed to parse version {}.{}.{}: {}",
major, minor, patch, e
);
Ok(None)
}
}
}
#[cfg(target_os = "windows")]
fn extract_version_from_pe32_file(
pe: &pelite::pe32::PeFile,
) -> Result<Option<PythonVersion>, std::io::Error> {
// Get resources from the PE file
let resources = pe.resources().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to read PE resources: {e}"),
)
})?;
// Try to get version info
let Ok(version_info) = resources.version_info() else {
debug!("No version info found in PE file");
return Ok(None);
};
// Get the fixed file info which contains version numbers
let Some(fixed_info) = version_info.fixed() else {
debug!("No fixed version info found in PE file");
return Ok(None);
};
// Extract version from the file version field
let file_version = fixed_info.dwFileVersion;
#[allow(clippy::cast_possible_truncation)]
let major = file_version.Major as u8;
#[allow(clippy::cast_possible_truncation)]
let minor = file_version.Minor as u8;
#[allow(clippy::cast_possible_truncation)]
let patch = file_version.Patch as u8;
// Validate that this looks like a Python version
if major == 0 || major > 10 || minor > 50 {
debug!(
"Version {}.{}.{} doesn't look like a Python version",
major, minor, patch
);
return Ok(None);
}
debug!("Extracted Python version: {}.{}.{}", major, minor, patch);
match PythonVersion::from_str(&format!("{major}.{minor}.{patch}")) {
Ok(version) => Ok(Some(version)),
Err(e) => {
debug!(
"Failed to parse version {}.{}.{}: {}",
major, minor, patch, e
);
Ok(None)
}
}
}
/// Extract version information from a Windows PE executable.
///
/// On non-Windows platforms, this function always returns `Ok(None)`.
#[cfg(not(target_os = "windows"))]
pub fn extract_version_from_pe(_path: &Path) -> Result<Option<PythonVersion>, std::io::Error> {
Ok(None)
}
#[test]
fn test_basic_pe_version_functionality() {
use std::str::FromStr;
// Basic test for the non-Windows version
#[cfg(not(target_os = "windows"))]
{
let result = extract_version_from_pe(Path::new("test.exe"));
assert_eq!(result.unwrap(), None);
}
// Test PythonVersion parsing
assert!(PythonVersion::from_str("3.12.0").is_ok());
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[cfg(target_os = "windows")]
use std::io::Write;
#[cfg(target_os = "windows")]
use tempfile::NamedTempFile;
#[test]
#[cfg(target_os = "windows")]
fn test_extract_version_from_nonexistent_file() {
let result = extract_version_from_pe(Path::new("nonexistent.exe"));
assert!(result.is_err());
}
#[test]
#[cfg(target_os = "windows")]
fn test_extract_version_from_invalid_pe_file() {
// Create a temporary file with invalid PE content
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"Not a PE file").unwrap();
temp_file.flush().unwrap();
let result = extract_version_from_pe(temp_file.path());
assert!(result.is_err());
}
#[test]
#[cfg(not(target_os = "windows"))]
fn test_extract_version_non_windows() {
let result = extract_version_from_pe(Path::new("python.exe"));
assert_eq!(result.unwrap(), None);
}
#[test]
fn test_version_validation() {
// Test that valid Python versions work
assert!(PythonVersion::from_str("3.12.0").is_ok());
assert!(PythonVersion::from_str("3.9.18").is_ok());
assert!(PythonVersion::from_str("3.13.1").is_ok());
// Test some edge cases that should still work
assert!(PythonVersion::from_str("0.1.0").is_ok()); // PythonVersion allows this
// Test malformed versions
assert!(PythonVersion::from_str("not.a.version").is_err());
assert!(PythonVersion::from_str("").is_err());
}
#[test]
fn test_always_runs() {
// This test should always run regardless of platform
// Test that the non-Windows version works
let result = extract_version_from_pe(Path::new("fake.exe"));
#[cfg(not(target_os = "windows"))]
assert_eq!(result.unwrap(), None);
#[cfg(target_os = "windows")]
assert!(result.is_err() || result.unwrap().is_none());
}
}