From e1527c53f7c5a45fab995470da4f45eb82f89300 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 14:34:09 +0900 Subject: [PATCH] Honor standalone executable ._pth startup configuration Apply explicit path isolation before CLI environment settings, preserve canonical empty-file and prefix handling, and retire three expected failures proven by test_site. Assisted-by: OpenAI Codex: model version unavailable --- Lib/test/test_site.py | 3 - crates/vm/src/getpath.rs | 132 +++++++++++++++++++++++++++++++++++- crates/vm/src/vm/setting.rs | 4 ++ src/interpreter.rs | 3 +- src/settings.rs | 20 ++++-- 5 files changed, 150 insertions(+), 12 deletions(-) diff --git a/Lib/test/test_site.py b/Lib/test/test_site.py index 548c90260..3fa15d2ef 100644 --- a/Lib/test/test_site.py +++ b/Lib/test/test_site.py @@ -741,7 +741,6 @@ class _pthFileTests(unittest.TestCase): pth_lines.append('import site') return pth_lines - @unittest.expectedFailure # TODO: RUSTPYTHON @support.requires_subprocess() def test_underpth_basic(self): pth_lines = ['#.', '# ..', *sys.path, '.', '..'] @@ -761,7 +760,6 @@ class _pthFileTests(unittest.TestCase): "sys.path is incorrect" ) - @unittest.expectedFailure # TODO: RUSTPYTHON @support.requires_subprocess() def test_underpth_nosite_file(self): libpath = test.support.STDLIB_DIR @@ -786,7 +784,6 @@ class _pthFileTests(unittest.TestCase): "sys.path is incorrect" ) - @unittest.expectedFailure # TODO: RUSTPYTHON @support.requires_subprocess() def test_underpth_file(self): libpath = test.support.STDLIB_DIR diff --git a/crates/vm/src/getpath.rs b/crates/vm/src/getpath.rs index 29e997742..16f5c8d64 100644 --- a/crates/vm/src/getpath.rs +++ b/crates/vm/src/getpath.rs @@ -7,6 +7,7 @@ use crate::vm::{Paths, Settings}; use std::env; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; // Platform-specific landmarks (ref: getpath.py PLATFORM CONSTANTS) @@ -103,6 +104,127 @@ fn search_up_dir>(start: P, landmarks: &[&str]) -> Option io::Result<()> { + let Some(executable) = get_executable_path() else { + return Ok(()); + }; + #[cfg(unix)] + let resolved = resolve_executable_symlinks(&executable).filter(|path| *path != executable); + #[cfg(not(unix))] + let resolved: Option = None; + for mut candidate in core::iter::once(executable).chain(resolved) { + if cfg!(windows) + && candidate.extension().is_some_and(|ext| { + ext.as_encoded_bytes().eq_ignore_ascii_case(b"exe") + || ext.as_encoded_bytes().eq_ignore_ascii_case(b"dll") + }) + { + candidate.set_extension(""); + } + candidate.as_mut_os_string().push("._pth"); + #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] + let file = crate::host_env::fs::open(&candidate); + #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + let file = std::fs::File::open(&candidate); + let Ok(file) = file else { continue }; + let mut content = Vec::new(); + file.take(32 * 1024).read_to_end(&mut content)?; + if content.len() >= 32 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "cannot read ._pth file of 32KB or larger during initialization", + )); + } + // Settings currently stores paths as String, not surrogate-capable text. + // Reject unsupported encoding rather than changing paths or ignoring isolation. + let content = core::str::from_utf8(&content).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "non-UTF-8 ._pth paths are unsupported", + ) + })?; + let parent = std::path::absolute(candidate.parent().unwrap_or_else(|| Path::new(".")))?; + let mut search_paths = Vec::new(); + let mut import_site = false; + for line in content.lines() { + let line = line.split('#').next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + if line == "import site" { + import_site = true; + } else if line.starts_with("import ") { + eprintln!("unsupported 'import' line in ._pth file"); + } else { + let joined = parent.join(line); + let double_root = cfg!(unix) + && joined + .to_str() + .is_some_and(|path| path.starts_with("//") && !path.starts_with("///")); + let mut normalized = PathBuf::new(); + for part in joined.components() { + match part { + std::path::Component::ParentDir => { + normalized.pop(); + } + std::path::Component::CurDir => {} + _ => normalized.push(part.as_os_str()), + } + } + if double_root && let Ok(relative) = normalized.strip_prefix("/") { + normalized = Path::new("//").join(relative); + } + search_paths.push( + normalized + .to_str() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "non-UTF-8 ._pth paths are unsupported", + ) + })? + .to_owned(), + ); + } + } + let home = parent + .to_str() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "non-UTF-8 ._pth directory is unsupported", + ) + })? + .to_owned(); + settings.home = Some(home); + if content.is_empty() { + // getpath.py detects this file but skips its final `if pth:` block. + settings.under_pth = Some(UnderPth::Empty); + return Ok(()); + } + settings.under_pth = Some(UnderPth::Isolated); + settings.path_list = search_paths; + settings.isolated = true; + settings.ignore_environment = true; + settings.user_site_directory = false; + settings.safe_path = true; + settings.import_site = import_site; + return Ok(()); + } + Ok(()) +} + /// Compute path configuration from Settings /// /// This function should be called before interpreter initialization. @@ -194,8 +316,11 @@ pub fn init_path_config(settings: &Settings) -> Paths { if paths.base_executable.is_empty() { paths.base_executable = calculate_base_executable(executable.as_ref(), home_dir.as_ref()); } - paths.module_search_paths = - build_module_search_paths(settings, &paths.base_prefix, &paths.base_exec_prefix); + paths.module_search_paths = if settings.under_pth == Some(UnderPth::Isolated) { + settings.path_list.clone() + } else { + build_module_search_paths(settings, &paths.base_prefix, &paths.base_exec_prefix) + }; paths.stdlib_dir = calculate_stdlib_dir(&paths.base_prefix); paths @@ -434,6 +559,9 @@ fn calculate_stdlib_dir(prefix: &str) -> Option { /// Preserve an embedding hook's custom search order by appending instead when /// the calculated paths have already been replaced or modified. pub fn add_build_time_paths(settings: &Settings, paths: &mut Paths, extra: Vec) { + if settings.under_pth.is_some() { + return; + } let calculated = build_module_search_paths(settings, &paths.base_prefix, &paths.base_exec_prefix); let index = if paths.module_search_paths == calculated { diff --git a/crates/vm/src/vm/setting.rs b/crates/vm/src/vm/setting.rs index c2a2c01ba..d1e4b48c1 100644 --- a/crates/vm/src/vm/setting.rs +++ b/crates/vm/src/vm/setting.rs @@ -156,6 +156,9 @@ pub struct Settings { /// Environment PYTHONPATH (and RUSTPYTHONPATH) pub path_list: Vec, + /// Standalone CLI ._pth path override; embedded applications do not discover these files. + pub under_pth: Option, + /// PYTHONHOME: explicit prefix, optionally followed by the exec prefix. pub home: Option, // wchar_t *platlibdir; @@ -221,6 +224,7 @@ impl Default for Settings { lazy_imports: -1, warnoptions: vec![], path_list: vec![], + under_pth: None, home: None, argv: vec![], hash_seed: None, diff --git a/src/interpreter.rs b/src/interpreter.rs index 3bb497812..a28e26cfb 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -101,7 +101,8 @@ fn setup_dynamic_stdlib(vm: &mut crate::VirtualMachine) { // PYTHONHOME forbids falling back to the build-tree pure-Python library. #[cfg(feature = "stdlib")] fn use_build_stdlib(vm: &crate::VirtualMachine) -> bool { - vm.state.config.paths.stdlib_dir.is_none() + vm.state.config.settings.under_pth.is_none() + && vm.state.config.paths.stdlib_dir.is_none() && vm.state.config.settings.home.as_deref().is_none_or(|home| { let (prefix, _) = home .split_once(if cfg!(windows) { ';' } else { ':' }) diff --git a/src/settings.rs b/src/settings.rs index 28606f4d9..3c744f4aa 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -217,14 +217,20 @@ pub fn parse_opts() -> Result<(Settings, RunMode), lexopt::Error> { let (args, mode, argv) = parse_args()?; let mut settings = Settings::default(); - settings.isolated = args.isolate; + if let Err(err) = rustpython_vm::getpath::configure_under_pth(&mut settings) { + eprintln!("Fatal Python error: cannot configure ._pth paths: {err}"); + std::process::exit(1); + } + settings.isolated |= args.isolate; settings.ignore_environment = settings.isolated || args.ignore_environment; settings.bytes_warning = args.bytes_warning.into(); - settings.import_site = !args.no_site; + if settings.under_pth != Some(rustpython_vm::getpath::UnderPth::Isolated) { + settings.import_site = !args.no_site; + } let ignore_environment = settings.ignore_environment; - if !ignore_environment { + if !ignore_environment && settings.under_pth.is_none() { settings.path_list.extend(get_paths("RUSTPYTHONPATH")); settings.path_list.extend(get_paths("PYTHONPATH")); } @@ -233,9 +239,11 @@ pub fn parse_opts() -> Result<(Settings, RunMode), lexopt::Error> { let get_env = |env| (!ignore_environment).then(|| env::var_os(env)).flatten(); - settings.home = get_env("PYTHONHOME") - .filter(|home| !home.is_empty()) - .map(|home| home.to_string_lossy().into_owned()); + if settings.under_pth.is_none() { + settings.home = get_env("PYTHONHOME") + .filter(|home| !home.is_empty()) + .map(|home| home.to_string_lossy().into_owned()); + } let env_count = |env| { get_env(env).filter(|v| !v.is_empty()).map_or(0, |val| {