152 lines
6.0 KiB
Rust
152 lines
6.0 KiB
Rust
// Version information is diagnostic, not a health check. Read package metadata
|
|
// for the *selected* runtime without launching Python, pip, Certbot or a shell.
|
|
// A missing/ambiguous version stays None; do not guess an interpreter patch
|
|
// version from an executable name or report a hard-coded Certbot pin.
|
|
use std::io::Read;
|
|
|
|
const MAX_VERSION_METADATA_BYTES: u64 = 16 * 1024;
|
|
const MAX_SITE_DIRECTORIES: usize = 64;
|
|
const MAX_PACKAGES_PER_SITE: usize = 4096;
|
|
|
|
fn metadata_text(path: &Path) -> Option<String> {
|
|
let mut bytes = Vec::new();
|
|
std::fs::File::open(path)
|
|
.ok()?
|
|
.take(MAX_VERSION_METADATA_BYTES)
|
|
.read_to_end(&mut bytes)
|
|
.ok()?;
|
|
String::from_utf8(bytes).ok()
|
|
}
|
|
|
|
fn safe_version(raw: &str) -> Option<String> {
|
|
let version = raw.trim();
|
|
(version.len() <= 48
|
|
&& version.bytes().next().is_some_and(|byte| byte.is_ascii_digit())
|
|
&& version
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || b".+-_".contains(&byte)))
|
|
.then(|| version.to_owned())
|
|
}
|
|
|
|
fn python_environment_root(executable: &Path) -> Option<std::path::PathBuf> {
|
|
let parent = executable.parent()?;
|
|
let name = parent.file_name()?.to_str()?;
|
|
let root = if name.eq_ignore_ascii_case("bin") || name.eq_ignore_ascii_case("scripts") {
|
|
parent.parent()?
|
|
} else {
|
|
parent
|
|
};
|
|
Some(root.to_owned())
|
|
}
|
|
|
|
fn interpreter_version(root: &Path) -> Option<String> {
|
|
let content = metadata_text(&root.join("pyvenv.cfg"))?;
|
|
// CPython venv writes `version = X.Y.Z`. `version_info` from virtualenv
|
|
// is a fallback when its standard `version` field is not present.
|
|
for key in ["version", "version_info"] {
|
|
for line in content.lines() {
|
|
if let Some((name, value)) = line.split_once('=') {
|
|
if name.trim().eq_ignore_ascii_case(key) {
|
|
if let Some(version) = safe_version(value) {
|
|
return Some(version);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn python_minor(version: &str) -> Option<String> {
|
|
let mut parts = version.split('.');
|
|
let major = parts.next()?;
|
|
let minor = parts.next()?;
|
|
(major.bytes().all(|b| b.is_ascii_digit())
|
|
&& minor.bytes().all(|b| b.is_ascii_digit()))
|
|
.then(|| format!("python{major}.{minor}"))
|
|
}
|
|
|
|
fn package_sites(root: &Path, python_version: Option<&str>) -> Vec<std::path::PathBuf> {
|
|
let mut sites = vec![root.join("Lib/site-packages"), root.join("lib/python3/dist-packages")];
|
|
for base in [root.join("lib"), root.join("lib64")] {
|
|
if let Some(minor) = python_version.and_then(python_minor) {
|
|
sites.push(base.join(&minor).join("site-packages"));
|
|
sites.push(base.join(&minor).join("dist-packages"));
|
|
} else if let Ok(entries) = std::fs::read_dir(&base) {
|
|
// Python installations with no pyvenv.cfg may contain multiple
|
|
// interpreter versions. Never choose between conflicting results.
|
|
for entry in entries.take(MAX_SITE_DIRECTORIES).flatten() {
|
|
let path = entry.path();
|
|
if entry.file_name().to_string_lossy().starts_with("python3.") && path.is_dir() {
|
|
sites.push(path.join("site-packages"));
|
|
sites.push(path.join("dist-packages"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sites
|
|
}
|
|
|
|
fn package_version(root: &Path, package: &str, python_version: Option<&str>) -> Option<String> {
|
|
let prefix = format!("{package}-");
|
|
let mut result: Option<String> = None;
|
|
for site in package_sites(root, python_version) {
|
|
let Ok(entries) = std::fs::read_dir(site) else { continue };
|
|
for entry in entries.take(MAX_PACKAGES_PER_SITE).flatten() {
|
|
let name = entry.file_name();
|
|
let Some(dir_name) = name.to_str() else { continue };
|
|
let Some(suffix) = dir_name.strip_prefix(prefix.as_str())
|
|
.and_then(|name| name.strip_suffix(".dist-info")) else { continue };
|
|
let Some(directory_version) = safe_version(suffix) else { continue };
|
|
let Some(content) = metadata_text(&entry.path().join("METADATA")) else { continue };
|
|
let mut reported_name = None;
|
|
let mut reported_version = None;
|
|
for line in content.lines() {
|
|
if let Some(value) = line.strip_prefix("Name: ") {
|
|
reported_name = Some(value.trim());
|
|
} else if let Some(value) = line.strip_prefix("Version: ") {
|
|
reported_version = safe_version(value);
|
|
}
|
|
if reported_name.is_some() && reported_version.is_some() { break; }
|
|
}
|
|
if !reported_name.is_some_and(|name| name.eq_ignore_ascii_case(package))
|
|
|| reported_version.as_deref() != Some(directory_version.as_str()) {
|
|
continue;
|
|
}
|
|
if result.as_ref().is_some_and(|previous| previous != &directory_version) {
|
|
return None;
|
|
}
|
|
result = Some(directory_version);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RuntimeVersions {
|
|
python: Option<String>,
|
|
pip: Option<String>,
|
|
certbot: Option<String>,
|
|
}
|
|
|
|
fn runtime_versions(python: &Path, has_pip: bool, certbot: Option<&Path>) -> RuntimeVersions {
|
|
let root = python_environment_root(python);
|
|
let python_version = root.as_deref().and_then(interpreter_version);
|
|
let pip_version = if has_pip {
|
|
root.as_deref().and_then(|root| package_version(root, "pip", python_version.as_deref()))
|
|
} else {
|
|
None
|
|
};
|
|
let certbot_version = certbot.and_then(|path| {
|
|
let certbot_root = python_environment_root(path)?;
|
|
let certbot_python_version = if root.as_deref() == Some(certbot_root.as_path()) {
|
|
python_version.as_deref()
|
|
} else {
|
|
None
|
|
};
|
|
package_version(&certbot_root, "certbot", certbot_python_version)
|
|
});
|
|
RuntimeVersions { python: python_version, pip: pip_version, certbot: certbot_version }
|
|
}
|
|
|