1// Package tools Python runtime resolution.
2//
3// Resolution is lazy (per invocation, after permission checks) and shares
4// the caller's timeout budget. Priority:
5//
6// 1. Configured python_interpreter (one executable path, no PATH lookup).
7// 2. Active VIRTUAL_ENV.
8// 3. Session-root .venv.
9// 4. PATH candidates python3, then python.
10//
11// A configured or active environment that fails validation is an error,
12// never a silent fallthrough. A present but invalid .venv is reported.
13// A missing .venv permits system discovery. PATH candidates may fall
14// through; cancellation stops discovery immediately.
15package tools
16
17import (
18 "bytes"
19 "context"
20 "encoding/json"
21 "fmt"
22 "io"
23 "os"
24 "os/exec"
25 "path/filepath"
26 "runtime"
27 "strings"
28 "time"
29)
30
31// maxPythonProbeSeconds bounds each version probe. The probe also observes
32// the remaining invocation deadline, whichever is shorter.
33const maxPythonProbeSeconds = 5
34
35// maxPythonProbeOutput bounds captured probe stdout/stderr.
36const maxPythonProbeOutput = 64 * 1024
37
38// pythonProbeCode prints structured version info using only the standard
39// library. It runs under -I -S so user configuration cannot alter it.
40const pythonProbeCode = `import json,sys;print(json.dumps({"major":sys.version_info[0],"minor":sys.version_info[1],"micro":sys.version_info[2],"executable":sys.executable}))`
41
42// pythonProbeArgv builds the isolated probe argv for exe. -B suppresses
43// bytecode-cache writes during probing.
44func pythonProbeArgv(exe string) []string {
45 return []string{exe, "-I", "-S", "-B", "-c", pythonProbeCode}
46}
47
48// pythonVersion is a validated Python 3 interpreter identity.
49type pythonVersion struct {
50 Major int
51 Minor int
52 Micro int
53 Executable string
54}
55
56func (v pythonVersion) String() string {
57 return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Micro)
58}
59
60// resolvedPython is a validated interpreter ready for execution.
61type resolvedPython struct {
62 Path string
63 Version pythonVersion
64}
65
66// pythonResolveDeps are the injectable seams for resolution. Production
67// code fills them from the host; tests substitute fixtures. No
68// package-global mutable overrides.
69type pythonResolveDeps struct {
70 GOOS string
71 CWD string
72 Configured string
73 Getenv func(string) string
74 LookPath func(string) (string, error)
75 IsFile func(string) bool
76 EvalSym func(string) (string, error)
77 Probe func(ctx context.Context, exe string) (pythonVersion, error)
78 // LayoutOK classifies a Windows candidate as a positively identified
79 // installed interpreter (or venv). Nil uses the default filesystem
80 // check. Ignored on non-Windows platforms.
81 LayoutOK func(exe string) bool
82}
83
84func (d *pythonResolveDeps) goos() string {
85 if d.GOOS != "" {
86 return d.GOOS
87 }
88 return runtime.GOOS
89}
90
91func (d *pythonResolveDeps) getenv(k string) string {
92 if d.Getenv != nil {
93 return d.Getenv(k)
94 }
95 return os.Getenv(k)
96}
97
98func (d *pythonResolveDeps) lookPath(name string) (string, error) {
99 if d.LookPath != nil {
100 return d.LookPath(name)
101 }
102 return exec.LookPath(name)
103}
104
105func (d *pythonResolveDeps) isFile(path string) bool {
106 if d.IsFile != nil {
107 return d.IsFile(path)
108 }
109 info, err := os.Stat(path)
110 return err == nil && !info.IsDir()
111}
112
113func (d *pythonResolveDeps) evalSym(path string) (string, error) {
114 if d.EvalSym != nil {
115 return d.EvalSym(path)
116 }
117 return filepath.EvalSymlinks(path)
118}
119
120func (d *pythonResolveDeps) probe(ctx context.Context, exe string) (pythonVersion, error) {
121 if d.Probe != nil {
122 return d.Probe(ctx, exe)
123 }
124 return probePythonVersion(ctx, exe)
125}
126
127func (d *pythonResolveDeps) layoutOK(exe string) bool {
128 if d.LayoutOK != nil {
129 return d.LayoutOK(exe)
130 }
131 return hasWindowsRuntimeLayout(exe, d.isFile)
132}
133
134// resolveConfiguredPythonPath maps a configured python_interpreter value to
135// a filesystem path. Absolute paths are used directly; relative paths
136// resolve against the session CWD, which is anchored to absolute first so
137// a relative --cwd cannot stack (project/project/...). No PATH lookup, no
138// shell expansion.
139func resolveConfiguredPythonPath(cwd, configured string) string {
140 if filepath.IsAbs(configured) {
141 return filepath.Clean(configured)
142 }
143 cwd = absPythonCWD(cwd)
144 return filepath.Join(cwd, filepath.FromSlash(configured))
145}
146
147// absPythonCWD anchors the session CWD to absolute once. Resolution and
148// execution both chdir into the session CWD, so every constructed
149// candidate must survive that chdir.
150func absPythonCWD(cwd string) string {
151 if cwd == "" {
152 cwd, _ = os.Getwd()
153 }
154 if abs, err := filepath.Abs(cwd); err == nil {
155 return abs
156 }
157 return cwd
158}
159
160// pythonVenvCandidate builds the interpreter path inside an environment
161// root. Relative roots resolve against the session CWD.
162func pythonVenvCandidate(cwd, root, goos string) string {
163 if !filepath.IsAbs(root) {
164 root = filepath.Join(absPythonCWD(cwd), filepath.FromSlash(root))
165 }
166 if goos == "windows" {
167 return filepath.Join(root, "Scripts", "python.exe")
168 }
169 return filepath.Join(root, "bin", "python")
170}
171
172// resolvePythonInterpreter implements the specified priority order.
173func resolvePythonInterpreter(ctx context.Context, deps pythonResolveDeps) (resolvedPython, error) {
174 goos := deps.goos()
175 cwd := absPythonCWD(deps.CWD)
176
177 if err := ctx.Err(); err != nil {
178 return resolvedPython{}, err
179 }
180
181 // 1. Configured interpreter: fail closed, never fall through.
182 if strings.TrimSpace(deps.Configured) != "" {
183 path := resolveConfiguredPythonPath(cwd, strings.TrimSpace(deps.Configured))
184 return resolvePythonFailClosed(ctx, &deps, goos, "configured python_interpreter", path)
185 }
186
187 // 2. Active VIRTUAL_ENV: fail closed, never fall through.
188 if venv := strings.TrimSpace(deps.getenv("VIRTUAL_ENV")); venv != "" {
189 path := pythonVenvCandidate(cwd, venv, goos)
190 return resolvePythonFailClosed(ctx, &deps, goos, "active VIRTUAL_ENV", path)
191 }
192
193 // 3. Session-root .venv: missing permits system discovery, but a
194 // present yet invalid environment is reported, not bypassed.
195 if venvCandidate := pythonVenvCandidate(cwd, filepath.Join(cwd, ".venv"), goos); deps.isFile(venvCandidate) {
196 return resolvePythonFailClosed(ctx, &deps, goos, "local .venv", venvCandidate)
197 } else {
198 // Also consider a .venv directory whose interpreter has a
199 // version-suffixed name? No: only the canonical layout is valid.
200 // A .venv dir without the canonical interpreter counts as
201 // present-but-invalid.
202 if dirExists(filepath.Join(cwd, ".venv")) {
203 return resolvedPython{}, fmt.Errorf("local .venv at %q has no usable Python 3 interpreter at %q; create the environment or configure python_interpreter to an existing interpreter (zut never installs Python or packages)", filepath.Join(cwd, ".venv"), venvCandidate)
204 }
205 }
206
207 // 4. PATH candidates: absent or non-Python-3 entries fall through.
208 for _, name := range []string{"python3", "python"} {
209 if err := ctx.Err(); err != nil {
210 return resolvedPython{}, err
211 }
212 path, err := deps.lookPath(name)
213 if err != nil || strings.TrimSpace(path) == "" {
214 continue
215 }
216 // A relative PATH entry can yield a relative interpreter path.
217 // The probe runs in the process directory while execution runs
218 // in the session CWD, so anchor it: probe, classification, and
219 // execution must all address the same validated executable.
220 if abs, absErr := filepath.Abs(path); absErr == nil {
221 path = abs
222 }
223 if goos == "windows" {
224 if err := classifyWindowsCandidate(path, &deps); err != nil {
225 continue
226 }
227 }
228 ver, err := deps.probe(ctx, path)
229 if err != nil {
230 if ctx.Err() != nil {
231 return resolvedPython{}, ctx.Err()
232 }
233 continue
234 }
235 return resolvedPython{Path: path, Version: ver}, nil
236 }
237
238 return resolvedPython{}, missingPythonError()
239}
240
241// resolvePythonFailClosed validates one explicit environment source. Any
242// failure is returned; the caller must not fall through to another source.
243func resolvePythonFailClosed(ctx context.Context, deps *pythonResolveDeps, goos, source, path string) (resolvedPython, error) {
244 if err := ctx.Err(); err != nil {
245 return resolvedPython{}, err
246 }
247 if !deps.isFile(path) {
248 return resolvedPython{}, fmt.Errorf("%s %q is not usable: executable not found; point python_interpreter at an existing Python 3 interpreter (zut never installs Python or packages)", source, path)
249 }
250 if goos == "windows" {
251 if err := classifyWindowsCandidate(path, deps); err != nil {
252 return resolvedPython{}, fmt.Errorf("%s %q is not usable: %w; configure python_interpreter to an existing installed Python 3 interpreter", source, path, err)
253 }
254 }
255 ver, err := deps.probe(ctx, path)
256 if err != nil {
257 if ctx.Err() != nil {
258 return resolvedPython{}, ctx.Err()
259 }
260 return resolvedPython{}, fmt.Errorf("%s %q is not usable: %v; configure python_interpreter to an existing Python 3 interpreter (zut never installs Python or packages)", source, path, err)
261 }
262 return resolvedPython{Path: path, Version: ver}, nil
263}
264
265func missingPythonError() error {
266 return fmt.Errorf("no Python 3 interpreter found: an existing Python 3 environment is required; configure python_interpreter in config.json, activate a VIRTUAL_ENV, create ./.venv, or add python3 to PATH (zut never installs Python or packages)")
267}
268
269func dirExists(path string) bool {
270 info, err := os.Stat(path)
271 return err == nil && info.IsDir()
272}
273
274// classifyWindowsCandidate enforces the Windows safety policy for EVERY
275// source, including configured paths and virtual environments. It runs
276// before any probe or execution. Unknown wrappers fail closed.
277func classifyWindowsCandidate(exe string, deps *pythonResolveDeps) error {
278 target := exe
279 if resolved, err := deps.evalSym(exe); err == nil && strings.TrimSpace(resolved) != "" {
280 target = resolved
281 }
282 lowerTarget := strings.ToLower(strings.ReplaceAll(target, "\\", "/"))
283 if strings.Contains(lowerTarget, "/windowsapps/") || strings.Contains(lowerTarget, "windowsapps") && strings.Contains(lowerTarget, "microsoft") {
284 return fmt.Errorf("windows Store alias is not supported")
285 }
286 // filepath.Base is separator-sensitive to the host OS, so extract the
287 // basename manually: test fixtures use Windows paths on Unix hosts.
288 base := lowerTarget
289 if i := strings.LastIndex(base, "/"); i >= 0 {
290 base = base[i+1:]
291 }
292 switch base {
293 case "py.exe", "pyw.exe", "pylauncher.exe":
294 return fmt.Errorf("python launcher %q is not supported; execute an installed interpreter directly", base)
295 case "pymanager.exe", "python-manager.exe", "python-install-manager.exe", "install-manager.exe", "installmanager.exe":
296 return fmt.Errorf("python install manager %q is not supported (named entrypoint)", base)
297 }
298 if strings.Contains(base, "manager") && (strings.Contains(base, "python") || strings.Contains(base, "py") || strings.Contains(base, "install")) {
299 return fmt.Errorf("python install manager %q is not supported (heuristic)", base)
300 }
301 if !deps.layoutOK(exe) && !deps.layoutOK(target) {
302 return fmt.Errorf("unverifiable interpreter wrapper %q is not supported", exe)
303 }
304 return nil
305}
306
307// hasWindowsRuntimeLayout positively identifies an installed CPython
308// executable or a virtual-environment interpreter. Anything else fails
309// closed. Never trust the basename alone.
310func hasWindowsRuntimeLayout(exe string, isFile func(string) bool) bool {
311 dir := filepath.Dir(exe)
312 parent := filepath.Dir(dir)
313 // Virtual environments carry pyvenv.cfg at the environment root.
314 if isFile(filepath.Join(dir, "pyvenv.cfg")) || isFile(filepath.Join(parent, "pyvenv.cfg")) {
315 return true
316 }
317 entries, err := os.ReadDir(dir)
318 if err == nil {
319 for _, e := range entries {
320 name := strings.ToLower(e.Name())
321 if strings.HasPrefix(name, "python") && strings.HasSuffix(name, ".dll") {
322 return true
323 }
324 }
325 }
326 // Installed layout also ships the standard library next to the exe.
327 if isFile(filepath.Join(dir, "Lib", "os.py")) || isFile(filepath.Join(parent, "Lib", "os.py")) {
328 return true
329 }
330 return false
331}
332
333// probePythonVersion runs the isolated version probe, bounded by at most
334// five seconds and the remaining invocation deadline.
335func probePythonVersion(ctx context.Context, exe string) (pythonVersion, error) {
336 timeout := maxPythonProbeSeconds * time.Second
337 if deadline, ok := ctx.Deadline(); ok {
338 remaining := time.Until(deadline)
339 if remaining <= 0 {
340 return pythonVersion{}, ctx.Err()
341 }
342 if remaining < timeout {
343 timeout = remaining
344 }
345 }
346 probeCtx, cancel := context.WithTimeout(ctx, timeout)
347 defer cancel()
348
349 cmd := exec.CommandContext(probeCtx, exe, "-I", "-S", "-B", "-c", pythonProbeCode)
350 cmd.Env = pythonChildEnv()
351 cmd.Dir = ""
352 var stdout, stderr bytes.Buffer
353 cmd.Stdout = &limitedWriter{W: &stdout, N: maxPythonProbeOutput}
354 cmd.Stderr = &limitedWriter{W: &stderr, N: maxPythonProbeOutput}
355 // Probes only surface terminal failures; a failed process-group kill
356 // is folded into the probe error by Wait, so no cancel capture here.
357 closeOutput := configurePythonProcess(cmd, nil, nil)
358 defer closeOutput()
359 if err := cmd.Start(); err != nil {
360 return pythonVersion{}, fmt.Errorf("probe start: %w", err)
361 }
362 waitErr := cmd.Wait()
363 if probeCtx.Err() != nil && ctx.Err() == nil {
364 return pythonVersion{}, fmt.Errorf("probe timed out after %d seconds", int(timeout/time.Second))
365 }
366 if waitErr != nil {
367 out := strings.TrimSpace(stdout.String() + stderr.String())
368 if out != "" {
369 if len(out) > 512 {
370 out = out[:512]
371 }
372 return pythonVersion{}, fmt.Errorf("probe failed: %v: %s", waitErr, out)
373 }
374 return pythonVersion{}, fmt.Errorf("probe failed: %w", waitErr)
375 }
376 var raw struct {
377 Major int `json:"major"`
378 Minor int `json:"minor"`
379 Micro int `json:"micro"`
380 Executable string `json:"executable"`
381 }
382 dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
383 if err := dec.Decode(&raw); err != nil {
384 return pythonVersion{}, fmt.Errorf("probe returned invalid output")
385 }
386 if raw.Major != 3 {
387 return pythonVersion{}, fmt.Errorf("probe returned Python %d (need Python 3)", raw.Major)
388 }
389 return pythonVersion{Major: raw.Major, Minor: raw.Minor, Micro: raw.Micro, Executable: raw.Executable}, nil
390}
391
392// pythonChildEnv inherits the host environment consistently with Bash,
393// except for Windows launcher-manager opt-ins, which are stripped as
394// defense in depth. Setting them to "0" is insufficient, so they are
395// removed entirely. PYTHON_MANAGER_AUTOMATIC_INSTALL is never trusted.
396func pythonChildEnv() []string {
397 env := os.Environ()
398 if runtime.GOOS != "windows" {
399 return env
400 }
401 out := env[:0]
402 for _, kv := range env {
403 key := kv
404 if i := strings.IndexByte(kv, '='); i >= 0 {
405 key = kv[:i]
406 }
407 // Windows environment keys are case-insensitive: normalize before
408 // comparing so lowercase launcher opt-ins cannot survive.
409 switch strings.ToUpper(key) {
410 case "PYLAUNCHER_ALLOW_INSTALL", "PYLAUNCHER_ALWAYS_INSTALL", "PYTHON_MANAGER_AUTOMATIC_INSTALL":
411 continue
412 }
413 out = append(out, kv)
414 }
415 return out
416}
417
418type limitedWriter struct {
419 W *bytes.Buffer
420 N int
421}
422
423func (l *limitedWriter) Write(p []byte) (int, error) {
424 if l.W.Len() >= l.N {
425 return len(p), nil
426 }
427 room := l.N - l.W.Len()
428 if len(p) > room {
429 l.W.Write(p[:room])
430 return len(p), nil
431 }
432 l.W.Write(p)
433 return len(p), nil
434}
435
436var _ = io.Discard