|
75 | 75 |
|
76 | 76 | import os |
77 | 77 | import platform |
| 78 | +import subprocess |
78 | 79 | import sys |
79 | 80 | from functools import cache |
80 | 81 | from os import environ |
@@ -907,40 +908,168 @@ def _active_env_var_shell_ids() -> frozenset[str]: |
907 | 908 | ) |
908 | 909 |
|
909 | 910 |
|
910 | | -@cache |
911 | | -def _parent_process_exe_names() -> frozenset[str]: |
912 | | - """Collect executable names from the parent process tree. |
| 911 | +def _shell_name(command: str) -> str: |
| 912 | + """Normalize a process command into a comparable shell name. |
| 913 | +
|
| 914 | + Strips a leading ``-`` (which marks a login shell, like ``-bash``), then |
| 915 | + returns the lowercased filename stem (``/usr/bin/bash`` becomes ``bash``). |
| 916 | + Returns an empty string when no name can be extracted. |
| 917 | + """ |
| 918 | + return PurePosixPath(command.lstrip("-")).stem.lower() |
913 | 919 |
|
914 | | - On Linux, reads ``/proc/<pid>/exe`` symlinks up the process tree via |
915 | | - ``/proc/<pid>/stat`` to find parent PIDs. Returns a {class}`frozenset` |
916 | | - of lowercased executable stems (like ``"bash"``, ``"python3"``). |
917 | 920 |
|
918 | | - Returns an empty set on non-Linux platforms where ``/proc`` is |
919 | | - unavailable. |
| 921 | +def _parse_proc_ppid(record: str) -> int: |
| 922 | + """Extract the parent PID from a ``/proc/<pid>`` process record. |
| 923 | +
|
| 924 | + Two on-disk layouts are recognized, told apart by the presence of the |
| 925 | + parenthesized ``comm`` field: |
| 926 | +
|
| 927 | + - Linux ``stat`` (and NetBSD's Linux-compatible ``stat``): |
| 928 | + ``"pid (comm) state ppid ..."``. The ``comm`` field may itself contain |
| 929 | + spaces and parentheses, so the PPID is taken as the second field after |
| 930 | + the last ``)``. |
| 931 | + - BSD ``status`` (FreeBSD, DragonFly): ``"comm pid ppid pgid ..."``, where |
| 932 | + the PPID is the third whitespace-separated field. |
| 933 | +
|
| 934 | + Raises {class}`ValueError` or {class}`IndexError` on an unparsable record. |
| 935 | + """ |
| 936 | + marker = record.rfind(")") |
| 937 | + if marker != -1: |
| 938 | + return int(record[marker + 2 :].split()[1]) |
| 939 | + return int(record.split()[2]) |
| 940 | + |
| 941 | + |
| 942 | +def _ppid_from_proc(pid: int) -> int | None: |
| 943 | + """Read the parent PID of ``pid`` from ``/proc``. |
| 944 | +
|
| 945 | + Tries the Linux-style ``stat`` file first, then the BSD-style ``status`` |
| 946 | + file, so the same walk works across Linux, NetBSD, and FreeBSD. Returns |
| 947 | + {data}`None` when neither file can be read or parsed. |
| 948 | + """ |
| 949 | + for proc_file in ("stat", "status"): |
| 950 | + try: |
| 951 | + record = Path(f"/proc/{pid}/{proc_file}").read_text() |
| 952 | + except OSError: |
| 953 | + continue |
| 954 | + try: |
| 955 | + return _parse_proc_ppid(record) |
| 956 | + except (ValueError, IndexError): |
| 957 | + return None |
| 958 | + return None |
| 959 | + |
| 960 | + |
| 961 | +def _exe_names_from_proc() -> frozenset[str]: |
| 962 | + """Walk the parent process tree through ``/proc`` (Linux and BSD procfs). |
| 963 | +
|
| 964 | + For each ancestor, collects both the resolved executable |
| 965 | + (``/proc/<pid>/exe``, so a ``/bin/sh`` symlinked to Bash reports ``bash``) |
| 966 | + and its ``argv[0]`` (``/proc/<pid>/cmdline``). Reading both means a login |
| 967 | + shell is still recognized when the ``exe`` symlink is unreadable (hardened |
| 968 | + ``/proc`` mounted with ``hidepid``), and vice versa. |
920 | 969 | """ |
921 | 970 | names: set[str] = set() |
| 971 | + pid = os.getpid() |
| 972 | + visited: set[int] = set() |
| 973 | + while pid > 1 and pid not in visited: |
| 974 | + visited.add(pid) |
| 975 | + # Resolved executable target (follows /bin/sh -> bash symlinks). |
| 976 | + try: |
| 977 | + if name := _shell_name(os.readlink(f"/proc/{pid}/exe")): |
| 978 | + names.add(name) |
| 979 | + except OSError: |
| 980 | + pass |
| 981 | + # argv[0] from the raw, null-separated command line. |
| 982 | + try: |
| 983 | + cmdline = Path(f"/proc/{pid}/cmdline").read_bytes() |
| 984 | + argv0 = cmdline.split(b"\0", 1)[0].decode(errors="replace") |
| 985 | + if name := _shell_name(argv0): |
| 986 | + names.add(name) |
| 987 | + except OSError: |
| 988 | + pass |
| 989 | + ppid = _ppid_from_proc(pid) |
| 990 | + if ppid is None: |
| 991 | + break |
| 992 | + pid = ppid |
| 993 | + return frozenset(names) |
| 994 | + |
| 995 | + |
| 996 | +def _exe_names_from_ps() -> frozenset[str]: |
| 997 | + """Walk the parent process tree through ``ps``. |
| 998 | +
|
| 999 | + Used on POSIX systems without a usable ``/proc`` (notably macOS, and BSDs |
| 1000 | + that do not mount procfs). Parses a single ``ps`` snapshot into a |
| 1001 | + ``{pid: (ppid, command)}`` table, then walks from the current process up to |
| 1002 | + the root, taking each ancestor's ``argv[0]`` as its shell name. |
| 1003 | +
|
| 1004 | + Only short option flags are passed: the BSD and macOS ``ps`` have no |
| 1005 | + long-form equivalents. The approach mirrors |
| 1006 | + [shellingham](https://github.com/sarugaku/shellingham) for ``/proc``-less |
| 1007 | + systems. |
| 1008 | + """ |
922 | 1009 | try: |
923 | | - pid = os.getpid() |
924 | | - visited: set[int] = set() |
925 | | - while pid > 1 and pid not in visited: |
926 | | - visited.add(pid) |
927 | | - try: |
928 | | - names.add(Path(os.readlink(f"/proc/{pid}/exe")).stem.lower()) |
929 | | - except OSError: |
930 | | - pass |
931 | | - try: |
932 | | - stat_content = Path(f"/proc/{pid}/stat").read_text() |
933 | | - # Format: "pid (comm) state ppid ...". The comm field may |
934 | | - # contain spaces and parentheses, so find the last ')'. |
935 | | - ppid_str = stat_content[stat_content.rfind(")") + 2 :].split()[1] |
936 | | - pid = int(ppid_str) |
937 | | - except (OSError, ValueError, IndexError): |
938 | | - break |
939 | | - except OSError: |
940 | | - pass |
| 1010 | + result = subprocess.run( |
| 1011 | + ("ps", "-A", "-ww", "-o", "pid=,ppid=,command="), |
| 1012 | + capture_output=True, |
| 1013 | + text=True, |
| 1014 | + check=True, |
| 1015 | + timeout=2, |
| 1016 | + ) |
| 1017 | + except (OSError, subprocess.SubprocessError): |
| 1018 | + return frozenset() |
| 1019 | + |
| 1020 | + # str() coerces an unexpected stdout (like a globally mocked subprocess.run |
| 1021 | + # returning a Mock) to text, so parsing degrades to an empty result instead |
| 1022 | + # of raising. |
| 1023 | + output = str(result.stdout) |
| 1024 | + |
| 1025 | + # Parse "<pid> <ppid> <command...>" rows into {pid: (ppid, command)}. |
| 1026 | + table: dict[int, tuple[int, str]] = {} |
| 1027 | + for line in output.splitlines(): |
| 1028 | + fields = line.split(maxsplit=2) |
| 1029 | + if len(fields) < 2: |
| 1030 | + continue |
| 1031 | + try: |
| 1032 | + child, parent = int(fields[0]), int(fields[1]) |
| 1033 | + except ValueError: |
| 1034 | + continue |
| 1035 | + table[child] = (parent, fields[2] if len(fields) == 3 else "") |
| 1036 | + |
| 1037 | + names: set[str] = set() |
| 1038 | + pid = os.getpid() |
| 1039 | + visited: set[int] = set() |
| 1040 | + while pid > 1 and pid in table and pid not in visited: |
| 1041 | + visited.add(pid) |
| 1042 | + ppid, command = table[pid] |
| 1043 | + # argv[0] is the first whitespace-separated token of the command. |
| 1044 | + if command and (name := _shell_name(command.split(maxsplit=1)[0])): |
| 1045 | + names.add(name) |
| 1046 | + pid = ppid |
941 | 1047 | return frozenset(names) |
942 | 1048 |
|
943 | 1049 |
|
| 1050 | +@cache |
| 1051 | +def _parent_process_exe_names() -> frozenset[str]: |
| 1052 | + """Collect executable names from the parent process tree. |
| 1053 | +
|
| 1054 | + Returns a {class}`frozenset` of lowercased executable stems (like |
| 1055 | + ``"bash"`` or ``"python3"``) gathered by walking from the current process |
| 1056 | + up to the root. Dispatches on what the platform exposes: |
| 1057 | +
|
| 1058 | + - ``/proc`` when present (Linux always, BSDs that mount procfs): no |
| 1059 | + subprocess is spawned. |
| 1060 | + - ``ps`` otherwise (macOS, BSDs without procfs). |
| 1061 | + - An empty set on Windows, which has neither. |
| 1062 | + """ |
| 1063 | + # /proc, when present, needs no subprocess (Linux always, procfs BSDs). |
| 1064 | + if Path("/proc").is_dir(): |
| 1065 | + return _exe_names_from_proc() |
| 1066 | + # macOS and BSDs without procfs: walk the tree via `ps`. Windows, which |
| 1067 | + # has neither /proc nor `ps`, falls through to an empty set. |
| 1068 | + if os.name == "posix": |
| 1069 | + return _exe_names_from_ps() |
| 1070 | + return frozenset() |
| 1071 | + |
| 1072 | + |
944 | 1073 | def _parent_process_shells(shell_ids: str | tuple[str, ...]) -> bool: |
945 | 1074 | """Check if any parent process in the tree matches the given shell IDs. |
946 | 1075 |
|
@@ -977,8 +1106,9 @@ def _detect_shell( |
977 | 1106 | reports the actual shell implementation rather than the interface name: |
978 | 1107 | when ``/bin/sh`` symlinks to ``/bin/bash``, ``bash`` is detected, not |
979 | 1108 | ``sh``. |
980 | | - 3. Falls back to walking the parent process tree via `/proc` to find the |
981 | | - active shell (for stripped environments without shell env vars). |
| 1109 | + 3. Falls back to walking the parent process tree (via `/proc` on Linux, |
| 1110 | + `ps` on macOS and the BSDs) to find the active shell, for stripped |
| 1111 | + environments without shell env vars. |
982 | 1112 |
|
983 | 1113 | :param version_env_var: Shell-specific environment variable name |
984 | 1114 | (like ``"BASH_VERSION"``). |
@@ -1733,8 +1863,9 @@ def current_shell(strict: bool = False) -> Shell: |
1733 | 1863 |
|
1734 | 1864 | 1. Shell-specific environment variables (strongest: the Python process |
1735 | 1865 | *is* the shell). |
1736 | | - 2. ``/proc`` parent process tree (strong: the shell is an ancestor |
1737 | | - process actively running). |
| 1866 | + 2. Parent process tree, read from ``/proc`` on Linux or ``ps`` on macOS |
| 1867 | + and the BSDs (strong: the shell is an ancestor process actively |
| 1868 | + running). |
1738 | 1869 | 3. ``SHELL`` environment variable resolved through symlinks (weak: |
1739 | 1870 | configured login shell, may differ from the active shell). |
1740 | 1871 |
|
@@ -1776,7 +1907,7 @@ def current_shell(strict: bool = False) -> Shell: |
1776 | 1907 | from .shell_data import POWERSHELL, SH, UNKNOWN_SHELL |
1777 | 1908 |
|
1778 | 1909 | # Collect all matching shells via the full scan (env vars, SHELL=, |
1779 | | - # /proc tree, Windows defaults). |
| 1910 | + # parent process tree, Windows defaults). |
1780 | 1911 | matching: set[Shell] = { |
1781 | 1912 | shell # type: ignore[misc] |
1782 | 1913 | for shell in ALL_SHELLS |
|
0 commit comments