Skip to content

Stop the loader from shadowing top-level imports via sys.path (#69139)#69787

Open
ggiesen wants to merge 2 commits into
saltstack:3006.xfrom
ggiesen:fix-loader-syspath-shadow
Open

Stop the loader from shadowing top-level imports via sys.path (#69139)#69787
ggiesen wants to merge 2 commits into
saltstack:3006.xfrom
ggiesen:fix-loader-syspath-shadow

Conversation

@ggiesen

@ggiesen ggiesen commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Stops Salt's module loader from putting its own source directories on the
global sys.path while a module body executes, which let a same-named
single-file Salt module shadow a top-level third-party package.

LazyLoader appended extra_module_dirs (__populate_sys_path) and the
module's own fpath_dirname (_load_module) to sys.path for the duration of
a load. Any bare import issued while that module ran could then resolve to a
same-named file under salt/utils, salt/modules, etc., and get cached in
sys.modules for the life of the process.

This is the root cause behind the "Proxymodule napalm is missing an init()"
reports in #69139. Loading salt/utils/napalm.py runs its top-level
import napalm, which reaches ncclient.transport, which does a bare
import ssh to detect the optional ssh-python/libssh package. With salt/utils
on sys.path that bound to salt/utils/ssh.py (a plain module, not the
ssh-python package), so ncclient's from ssh.channel import Channel raised
'ssh' is not a package, import napalm failed, HAS_NAPALM was False, and
the napalm modules never passed their __virtual__ gate. #69330 improved the
error message for this symptom; this fixes the underlying loader behaviour.

The fix: Salt-internal modules are imported via their fully-qualified salt.*
names, so they never needed sys.path; only external/custom module dirs do (so
a custom module's bare sibling imports still resolve). Skip appending any
directory at or under SALT_BASE_PATH in both __populate_sys_path and the
fpath_dirname append. The existing finally cleanup is unchanged.

The internal-vs-external test uses a path-component boundary
(_is_salt_internal_path), not a raw string prefix, so a directory whose name
merely begins with the salt package name -- notably the officially-supported
saltext.* extensions, which install sibling to the salt package in
site-packages (.../site-packages/saltext/...) -- is correctly treated as
external and keeps its sys.path entry.

Blast radius

This intentionally removes all Salt-internal directories from sys.path,
not just salt/utils, because the collision surface is general.

The colliding stems people are likely to hit are third-party packages, not
Salt's own dependencies. salt/utils/ alone shadows dns (dnspython),
napalm, github (PyGithub), slack, and vault (besides ssh), and
salt/modules/ adds git (GitPython), pip, consul, elasticsearch, and
telegram, among others.

The trigger is narrower than the surface, though, and worth being precise
about: the loader appends these directories, so a package that is
installed is still found in site-packages first and is not shadowed (I
confirmed import yaml and import msgpack bind to the real packages even with
salt/utils leaked onto sys.path). The actual failure mode is a bare
import X for a name that is not installed but matches a Salt stem -- an
optional-dependency probe (ncclient's import ssh detecting libssh is the
reported case), or one of the self-import modules below. Removing the leak
entirely closes the whole class regardless of import ordering.

On a normal install this changes nothing about which modules load. I compared
the full set of loadable modules (execution/state/render/serializer/output/
grain/pillar/sdb/proxy) with the stock loader vs this loader on a real 3006.26
onedir install (Rocky 9): no module started or stopped loading.

There is one behaviour change, and it is environment-dependent. 32 Salt modules
bare-import their own name (a module named after its optional dependency), e.g.
salt/modules/ethtool.py does import ethtool. When that dependency is not
installed, the module's own directory being on sys.path can make
import ethtool resolve to salt/modules/ethtool.py itself -- a fragile
circular self-import that leaves the module self-referential and non-functional.
Whether that self-import actually "succeeds" (and the broken module loads)
depends on the interpreter/install layout: it happened in a source/PYTHONPATH
checkout on Python 3.12 but not on the Python 3.11 onedir above (where those
modules simply do not load, dependency absent, with or without this change).
Where it does happen, the fix makes the module correctly not load instead of
self-shadow-loading. In every case, a module whose dependency is installed is
unaffected -- the real package is imported.

What issues does this PR fix or reference?

Fixes #69139

Previous Behavior

import napalm (and any third-party import whose top-level name collides with a
Salt module stem) could be shadowed by a Salt module during loading, breaking
the affected modules.

New Behavior

Salt-internal directories are never placed on sys.path, so a loaded module's
import chain resolves top-level names to the real installed packages. napalm
loads and its proxies initialise. External/custom module dirs (including
saltext.* extensions) are still added, so their sibling imports resolve.

Merge requirements satisfied?

  • Docs
  • Changelog
  • Tests written/updated

tests/pytests/unit/loader/test_loader.py:

  • test_loader_does_not_shadow_top_level_import -- cross-shadow (napalm/ssh
    class); fails without the fix.
  • test_loader_self_named_module_not_self_shadowed_when_dep_absent -- the
    ethtool/dson/json5 class; fails without the fix.
  • test_loader_self_named_module_loads_via_real_dep_when_present -- the same
    module still loads and binds to the real package when installed.
  • test_loader_external_module_dir_still_on_sys_path -- external/custom dirs
    are still appended.
  • test_loader_external_dir_sharing_salt_prefix_still_appended -- a dir whose
    path merely begins with the salt install path (the saltext boundary) is
    still treated as external; fails with a raw startswith guard.

tests/pytests/functional/loader/test_syspath_shadow.py:

  • test_loader_never_leaks_salt_internal_dirs_onto_sys_path_69139 -- drives the
    real minion_mods+utils loaders against the real SALT_BASE_PATH (the unit
    tests monkeypatch it) and asserts, from inside a module body executed
    mid-load, that no Salt-internal directory is on sys.path at all -- the
    name-independent guarantee covering the whole collision class -- plus the
    concrete salt/utils/ssh.py shadow when a real top-level ssh is absent.
    Fails without the fix.

Validated end to end on a real 3006.26 onedir install (Rocky 9) running a
napalm deltaproxy against live Junos devices: with the pinned, otherwise-broken
ncclient==0.7.0 still installed, napalm loads and the sub-proxies initialise
with no KeyError: 'napalm.init'; reverting the change reproduces the failure.

Commits signed with GPG?

No

…ack#69139)

The LazyLoader put Salt's own source directories on the global sys.path
while a module body executed (__populate_sys_path, and the fpath_dirname
append in _load_module). Any bare import issued while that module ran could
then resolve to a same-named single-file Salt module and get cached in
sys.modules for the life of the process.

That is what broke napalm on modern Salt. Loading salt/utils/napalm.py runs
its top-level `import napalm`, which reaches ncclient.transport, which does a
bare `import ssh` to detect the optional ssh-python/libssh package. With
salt/utils on sys.path that bound to salt/utils/ssh.py (a plain module, not
the ssh-python package), so ncclient's `from ssh.channel import Channel`
raised "'ssh' is not a package", `import napalm` failed, HAS_NAPALM was False,
and the napalm proxy/execution modules never passed their __virtual__ gate.
This is the root cause behind the "Proxymodule napalm is missing an init()"
reports in saltstack#69139 (which saltstack#69330 only improved the error message for).

Salt-internal modules are imported via their fully-qualified salt.* names, so
they never needed sys.path; only external/custom module dirs do, so a custom
module's bare sibling imports keep resolving. Skip appending any directory
under SALT_BASE_PATH in both __populate_sys_path and the fpath_dirname append.

As a side effect, a module whose optional same-named dependency is not
installed no longer "loads" by importing itself.
@ggiesen ggiesen force-pushed the fix-loader-syspath-shadow branch from de280ad to f72bf1b Compare July 12, 2026 00:04
@dwoz dwoz added the test:full Run the full test suite label Jul 12, 2026
…ack#69139)

The unit tests monkeypatch SALT_BASE_PATH and use a synthetic shadow file.
This drives the real minion_mods + utils loaders against the real
SALT_BASE_PATH and asserts, from inside a module body executed mid-load, that
no Salt-internal directory is ever placed on sys.path -- the name-independent
guarantee that protects every salt/utils and salt/modules collision (dns,
napalm, git, pip, consul, ...), not just ssh. The concrete salt/utils/ssh.py
shadow is also asserted when a real top-level ssh is absent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants