This commit is contained in:
Mateusz Gruszczyński committed 2026-10-10 11:35:04 +02:00
1 parent f3dd34dae4
commit f213d853e2
28 files changed
+5368 -4692

No files matched your search

+49 -5
View File
@@ -24,6 +24,10 @@ pub struct RuntimeStatus {
pub python: bool,
pub pip: bool,
pub certbot: bool,
/// Versions of the executables actually selected for ACME, never a guessed system default.
pub python_version: Option<String>,
pub pip_version: Option<String>,
pub certbot_version: Option<String>,
}
impl Default for RuntimeStatus {
@@ -36,6 +40,9 @@ impl Default for RuntimeStatus {
python: false,
pip: false,
certbot: false,
python_version: None,
pip_version: None,
certbot_version: None,
}
}
}
@@ -68,11 +75,22 @@ fn find_on_path(name: &str) -> Option<PathBuf> {
if candidate.components().count() > 1 {
return candidate.is_file().then(|| candidate.to_owned());
}
env::var_os("PATH")
.into_iter()
.flat_map(|value| env::split_paths(&value).collect::<Vec<_>>())
.map(|directory| directory.join(name))
.find(|path| path.is_file())
for directory in env::var_os("PATH").into_iter().flat_map(|paths| env::split_paths(&paths).collect::<Vec<_>>()) {
let path = directory.join(name);
if path.is_file() {
return Some(path);
}
// Windows PATH lookup uses PATHEXT; Path::is_file alone does not.
// Python and Certbot are installed as .exe launchers on Windows.
#[cfg(windows)]
if path.extension().is_none() {
let executable = path.with_extension("exe");
if executable.is_file() {
return Some(executable);
}
}
}
None
}
fn python_candidates() -> impl Iterator<Item = PathBuf> {
@@ -137,6 +155,8 @@ async fn command_success(
}
}
include!("runtime_versions.rs");
async fn managed_environment_valid(
directory: &Path,
shutdown: &tokio_util::sync::CancellationToken,
@@ -203,12 +223,14 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
python: false,
pip: false,
certbot: false,
..RuntimeStatus::default()
});
return Err("acme.runtime_data_dir_missing".into());
};
let managed = data_dir.join("acme-python");
let managed_directory = Some(managed.to_string_lossy().into_owned());
if managed_environment_valid(&managed, &state.shutdown).await {
let versions = runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)));
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "managed",
@@ -217,6 +239,9 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
python: true,
pip: true,
certbot: true,
python_version: versions.python,
pip_version: versions.pip,
certbot_version: versions.certbot,
});
return Ok(RuntimeLaunch {
python: venv_python(&managed),
@@ -241,10 +266,16 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
python: false,
pip: false,
certbot: distro_certbot_available,
..RuntimeStatus::default()
});
return Err("acme.runtime_python_missing".into());
};
let system_pip = system_has_pip(&python, &state.shutdown).await;
let system_versions = runtime_versions(
&python,
system_pip,
distro_certbot.as_deref().filter(|_| distro_certbot_available),
);
state.acme.set_runtime(RuntimeStatus {
status: "initializing",
mode: "managed",
@@ -253,10 +284,14 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
python: true,
pip: system_pip,
certbot: distro_certbot_available,
python_version: system_versions.python.clone(),
pip_version: system_versions.pip.clone(),
certbot_version: system_versions.certbot.clone(),
});
let build = build_managed_environment(&python, &managed, system_pip, &state.shutdown).await;
if build.is_ok() {
tracing::info!(path = %managed.display(), "managed ACME runtime ready");
let versions = runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)));
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "managed",
@@ -265,6 +300,9 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
python: true,
pip: true,
certbot: true,
python_version: versions.python,
pip_version: versions.pip,
certbot_version: versions.certbot,
});
return Ok(RuntimeLaunch {
python: venv_python(&managed),
@@ -282,6 +320,9 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
python: true,
pip: system_pip,
certbot: true,
python_version: system_versions.python,
pip_version: system_versions.pip,
certbot_version: system_versions.certbot,
});
return Ok(RuntimeLaunch {
python,
@@ -312,6 +353,9 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
python: true,
pip: system_pip,
certbot: false,
python_version: system_versions.python,
pip_version: system_versions.pip,
certbot_version: None,
});
Err(message.into())
}
+151
View File
@@ -0,0 +1,151 @@
// 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 }
}
+62
View File
@@ -146,3 +146,65 @@ mod renewal_regressions {
stop(app).await;
}
}
mod runtime_metadata_tests {
use super::*;
fn write_package(site: &Path, package: &str, version: &str, metadata: &str) {
let folder = site.join(format!("{package}-{version}.dist-info"));
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("METADATA"), metadata).unwrap();
}
#[test]
fn managed_python_pip_and_certbot_versions_use_metadata_only() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let python = venv_python(root);
let certbot = venv_certbot(root);
std::fs::create_dir_all(venv_bin(root)).unwrap();
std::fs::write(&python, "").unwrap();
std::fs::write(&certbot, "").unwrap();
std::fs::write(root.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.13.5\n").unwrap();
let site = if cfg!(windows) {
root.join("Lib/site-packages")
} else {
root.join("lib/python3.13/site-packages")
};
write_package(&site, "pip", "25.2", "Metadata-Version: 2.3\nName: pip\nVersion: 25.2\n");
write_package(&site, "certbot", "5.8.0", "Name: certbot\nVersion: 5.8.0\n");
let versions = runtime_versions(&python, true, Some(&certbot));
assert_eq!(versions.python.as_deref(), Some("3.13.5"));
assert_eq!(versions.pip.as_deref(), Some("25.2"));
assert_eq!(versions.certbot.as_deref(), Some("5.8.0"));
}
#[test]
fn absent_or_unverified_versions_are_not_invented() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let executable = root.join("bin/python3");
std::fs::create_dir_all(root.join("bin")).unwrap();
std::fs::write(&executable, "").unwrap();
let site = root.join("lib/python3/dist-packages");
write_package(&site, "pip", "99.0", "Name: other-package\nVersion: 99.0\n");
let versions = runtime_versions(&executable, true, None);
assert!(versions.python.is_none());
assert!(versions.pip.is_none());
assert!(versions.certbot.is_none());
}
#[test]
fn conflicting_site_distributions_do_not_return_stale_versions() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let executable = root.join("bin/python3");
std::fs::create_dir_all(root.join("bin")).unwrap();
std::fs::write(&executable, "").unwrap();
write_package(&root.join("lib/python3/dist-packages"), "pip", "23.0", "Name: pip\nVersion: 23.0\n");
write_package(&root.join("lib/python3.13/site-packages"), "pip", "25.0", "Name: pip\nVersion: 25.0\n");
assert!(runtime_versions(&executable, true, None).pip.is_none());
}
}