馃寪 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

馃悰 fix(activate): stop when relocation cannot resolve - #3409

Merged
gaborbernat merged 5 commits into
pypa:mainfrom
gaborbernat:fix/activation-relocation
Oct 17, 2026
Merged

gaborbernat merged 5 commits into
pypa:mainfrom
gaborbernat:fix/activation-relocation

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

The bash activate script carries the absolute path of its environment. When that directory is gone, because the environment was moved after creation, the script locates the environment from the file being sourced. That lookup could fail and the script carried on with whatever came out of it. With eval "$(cat bin/activate)", or the script fed to bash on standard input, BASH_SOURCE is empty: realpath '' failed, dirname of the empty result gave ., and VIRTUAL_ENV became the parent of the working directory with <parent>/bin at the front of PATH. With source <(cat bin/activate) the source is a pipe under /dev/fd, the last realpath failed, and PATH gained /bin from an empty VIRTUAL_ENV. Without realpath on PATH, which macOS lacked before 13, all three lookups printed command not found and the same /bin entry was added. Every case ended with status 0. 馃悰 Relocation now stops with status 1, and leaves PATH, PS1 and VIRTUAL_ENV as they were, unless bash sourced the file by its own path, every lookup succeeded, the file is the activate of the resolved directory's bin directory, and that directory holds a pyvenv.cfg (the marker file every virtual environment carries).

A failed lookup fed the next step

The fallback ran realpath, dirname and realpath in a row and checked none of them. #2996 added it reading $0, and #3091 switched it to BASH_SOURCE, the array bash fills with the file names of sourced scripts. The variable is empty when the text reaches bash through eval or standard input, and other shells do not set it. Each lookup now ends with || return 1. The return builtin stops a sourced file, so the tool's own error message is the last output and nothing after that line runs. Before any lookup, a guard checks that the environment directory is missing, that BASH_VERSION is set and that BASH_SOURCE is not empty. When the guard fails it prints Relocating this activation script requires sourcing it from bash., and false leaves status 1; the rest of the script sits in the else branch, so no variable changes. With <(cat bin/activate) the file name is /dev/fd/<n>, whose /proc/<pid>/fd entry points at a pipe, and the realpath of its parent directory fails.

flowchart LR
    S["BASH_SOURCE[0]"] --> G{bash and non-empty?}
    G -- no --> M[message, status 1]
    G -- yes --> R1[realpath]
    R1 -- fails --> X1[return 1]
    R1 --> D[dirname]
    D --> R2["realpath dir/.."]
    R2 -- fails --> X2[return 1]
    R2 --> C{same file as env/bin/activate and pyvenv.cfg?}
    C -- no --> X3["Cannot identify relocated environment, return 1"]
    C -- yes --> E[export VIRTUAL_ENV, prepend PATH]
    S -. before: empty name .-> R1
    R1 -. before: empty result .-> D
    D -. before: dot .-> P[parent of the working directory]
    P --> E0[export VIRTUAL_ENV, prepend PATH]
    classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000;
    classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332;
    class P,E0 bad;
    class M,X1,X2,X3,E ok;
Loading

Any directory above the script counted as the environment

The old fallback took the parent of the sourced file's directory as the environment, whatever that file was: a copy of activate dropped into some other bin, or a wrapper script that evals the real one. The script now compares the sourced file with <env>/<bin>/activate through -ef, which is true when both names have the same device and inode numbers, and requires pyvenv.cfg in that directory. The bin directory name is the one the generator wrote into the script from creator.bin_dir, so Scripts under Git Bash on Windows passes like bin elsewhere. A symlink to the real activate passes too, since realpath resolved it. Anything else prints Cannot identify relocated environment: <dir> and returns 1.

stateDiagram-v2
    [*] --> Resolved: realpath and dirname succeed
    Resolved --> Guessed: before, parent taken as is
    Guessed --> Activated
    Resolved --> Checked: now, file -ef env/bin/activate
    Checked --> Refused: other file, or no pyvenv.cfg
    Checked --> Activated: same inode and pyvenv.cfg present
    Refused --> [*]: status 1, nothing exported
    classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000
    classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332
    class Guessed bad
    class Checked,Refused ok
Loading

Behavior changes

Activating an environment at its original path is unchanged. Activating a moved environment requires bash with realpath and dirname on PATH, and the script sourced by its own path. eval "$(cat bin/activate)", the script on standard input, and shells other than bash print Relocating this activation script requires sourcing it from bash. and stop with status 1; PATH, PS1 and VIRTUAL_ENV are left as they were. source <(cat bin/activate) stops with a realpath error. A missing realpath or dirname stops with that tool's command not found. A moved environment without pyvenv.cfg, a copy of activate outside its bin directory, and a wrapper that evals the script are refused with Cannot identify relocated environment. A moved environment sourced from Git Bash on Windows, whose bin directory is Scripts, activates.

Reported by Anthropic's OSS Scanner: ANT-2026-WUTWEG5X.

@gaborbernat
gaborbernat force-pushed the fix/activation-relocation branch from 800d683 to 2d43ecc Compare October 16, 2026 14:22
@gaborbernat
gaborbernat force-pushed the fix/activation-relocation branch from 2d43ecc to 289ffc8 Compare October 16, 2026 14:25
The relocation check compared the sourced file with bin/activate, so a
moved environment under Git Bash on Windows, whose scripts live in
Scripts, was refused. Compare against the generated bin name instead,
drop the bash check the outer guard already makes, and have the failure
tests assert PATH and VIRTUAL_ENV stay untouched.
@gaborbernat
gaborbernat merged commit c89c09e into pypa:main Oct 17, 2026
76 checks passed
@gaborbernat
gaborbernat deleted the fix/activation-relocation branch October 17, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant