worktree-shield 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- worktree_shield/__init__.py +8 -0
- worktree_shield/__main__.py +6 -0
- worktree_shield/cli.py +377 -0
- worktree_shield/collectors/__init__.py +1 -0
- worktree_shield/collectors/base.py +63 -0
- worktree_shield/collectors/compose.py +315 -0
- worktree_shield/collectors/envfiles.py +130 -0
- worktree_shield/collectors/layout.py +106 -0
- worktree_shield/collectors/node.py +87 -0
- worktree_shield/collectors/procfile.py +134 -0
- worktree_shield/collectors/python.py +128 -0
- worktree_shield/collectors/runtime.py +167 -0
- worktree_shield/collectors/static.py +25 -0
- worktree_shield/collectors/vite.py +133 -0
- worktree_shield/collisions.py +115 -0
- worktree_shield/config.py +188 -0
- worktree_shield/exit_codes.py +22 -0
- worktree_shield/files.py +70 -0
- worktree_shield/git/__init__.py +1 -0
- worktree_shield/git/discovery.py +85 -0
- worktree_shield/git/porcelain.py +48 -0
- worktree_shield/models.py +356 -0
- worktree_shield/names.py +83 -0
- worktree_shield/orchestrator.py +121 -0
- worktree_shield/overlap.py +175 -0
- worktree_shield/parsers/__init__.py +1 -0
- worktree_shield/parsers/commands.py +158 -0
- worktree_shield/parsers/compose.py +148 -0
- worktree_shield/parsers/dburl.py +156 -0
- worktree_shield/parsers/dotenv.py +91 -0
- worktree_shield/parsers/interpolate.py +142 -0
- worktree_shield/parsers/jsonc.py +48 -0
- worktree_shield/parsers/jstext.py +142 -0
- worktree_shield/plan.py +175 -0
- worktree_shield/privacy.py +237 -0
- worktree_shield/reports/__init__.py +1 -0
- worktree_shield/reports/findings.py +176 -0
- worktree_shield/reports/json_report.py +61 -0
- worktree_shield/reports/markdown.py +103 -0
- worktree_shield/reports/sarif.py +116 -0
- worktree_shield/reports/terminal.py +327 -0
- worktree_shield/rules/__init__.py +1 -0
- worktree_shield/rules/catalog.py +28 -0
- worktree_shield/rules/common.py +61 -0
- worktree_shield/rules/context.py +201 -0
- worktree_shield/rules/engine.py +60 -0
- worktree_shield/rules/explain.py +27 -0
- worktree_shield/rules/rules.md +112 -0
- worktree_shield/rules/runtime.py +164 -0
- worktree_shield/rules/shared.py +216 -0
- worktree_shield/runner.py +273 -0
- worktree_shield-0.1.0.dist-info/METADATA +151 -0
- worktree_shield-0.1.0.dist-info/RECORD +56 -0
- worktree_shield-0.1.0.dist-info/WHEEL +4 -0
- worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
- worktree_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Listen ports in Procfiles and dev containers.
|
|
2
|
+
|
|
3
|
+
``Procfile`` and ``Procfile.dev`` (foreman, honcho, Heroku, Rails ``bin/dev``) hold one
|
|
4
|
+
``name: command`` per line; each command is scanned by
|
|
5
|
+
:func:`~worktree_shield.parsers.commands.listen_ports` and never run.
|
|
6
|
+
|
|
7
|
+
A dev container (``.devcontainer/devcontainer.json`` or ``.devcontainer.json``) can
|
|
8
|
+
publish ``appPort`` on the host, which fails when the port is taken, and forward
|
|
9
|
+
``forwardPorts`` to this machine, which the editor moves to another local port when it
|
|
10
|
+
is taken: those claims are not strict.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import PurePosixPath
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from worktree_shield.collectors.base import Collected, Scope, port_key
|
|
21
|
+
from worktree_shield.models import ResourceClaim, ResourceKind, SkippedFile
|
|
22
|
+
from worktree_shield.names import port_number
|
|
23
|
+
from worktree_shield.parsers.commands import listen_ports
|
|
24
|
+
from worktree_shield.parsers.jsonc import strip_jsonc
|
|
25
|
+
|
|
26
|
+
PROCFILES = ("Procfile", "Procfile.dev")
|
|
27
|
+
DEVCONTAINERS = (".devcontainer/devcontainer.json", ".devcontainer.json")
|
|
28
|
+
_PROCESS = re.compile(r"([A-Za-z0-9_-]+):\s*(.+)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def collect_procfiles(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
|
|
32
|
+
"""The listen ports of every process in the Procfiles of ``directory``."""
|
|
33
|
+
for name in PROCFILES:
|
|
34
|
+
relative = directory / name
|
|
35
|
+
text = scope.read(relative, out)
|
|
36
|
+
if text is None:
|
|
37
|
+
continue
|
|
38
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
39
|
+
match = _PROCESS.fullmatch(line.strip())
|
|
40
|
+
if match is None or line.lstrip().startswith("#"):
|
|
41
|
+
continue
|
|
42
|
+
process, command = match.groups()
|
|
43
|
+
for mention in listen_ports(command):
|
|
44
|
+
out.claims.append(
|
|
45
|
+
ResourceClaim(
|
|
46
|
+
ResourceKind.LISTEN_PORT,
|
|
47
|
+
port_key(mention.host, mention.port),
|
|
48
|
+
scope.worktree,
|
|
49
|
+
scope.source(relative, number),
|
|
50
|
+
f"{name} {process}: {mention.fragment}",
|
|
51
|
+
strict=mention.strict,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def collect_devcontainer(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
|
|
57
|
+
"""The ports a dev container in ``directory`` publishes or forwards."""
|
|
58
|
+
for name in DEVCONTAINERS:
|
|
59
|
+
relative = directory / name
|
|
60
|
+
text = scope.read(relative, out)
|
|
61
|
+
if text is None:
|
|
62
|
+
continue
|
|
63
|
+
plain = strip_jsonc(text)
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(plain)
|
|
66
|
+
except json.JSONDecodeError as error:
|
|
67
|
+
out.skipped.append(
|
|
68
|
+
SkippedFile(scope.source(relative, error.lineno), "is not valid JSON")
|
|
69
|
+
)
|
|
70
|
+
continue
|
|
71
|
+
if not isinstance(data, dict):
|
|
72
|
+
continue
|
|
73
|
+
finder = _Lines(plain)
|
|
74
|
+
for key, strict in (("forwardPorts", False), ("appPort", True)):
|
|
75
|
+
entries = _entries(data.get(key), single_allowed=key == "appPort")
|
|
76
|
+
start = finder.key(key)
|
|
77
|
+
for entry in entries:
|
|
78
|
+
port = _port(entry, published=strict)
|
|
79
|
+
if port is None:
|
|
80
|
+
continue
|
|
81
|
+
host = "localhost" if key == "forwardPorts" else None
|
|
82
|
+
out.claims.append(
|
|
83
|
+
ResourceClaim(
|
|
84
|
+
ResourceKind.LISTEN_PORT,
|
|
85
|
+
port_key(host, port),
|
|
86
|
+
scope.worktree,
|
|
87
|
+
scope.source(relative, finder.value(entry, start)),
|
|
88
|
+
f"{key}: {entry}",
|
|
89
|
+
strict=strict,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _entries(value: Any, *, single_allowed: bool) -> list[Any]:
|
|
95
|
+
"""The entries of a port list; appPort may also be a single port (the spec says so,
|
|
96
|
+
forwardPorts must be an array)."""
|
|
97
|
+
if isinstance(value, list):
|
|
98
|
+
return value
|
|
99
|
+
return [value] if single_allowed and value is not None else []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _port(entry: Any, *, published: bool) -> int | None:
|
|
103
|
+
"""The local port of a forwardPorts or appPort entry: ``3000``, ``"db:5432"``
|
|
104
|
+
(forwarded service port) or ``"8080:80"`` (published host:container)."""
|
|
105
|
+
if isinstance(entry, bool):
|
|
106
|
+
return None
|
|
107
|
+
if isinstance(entry, int):
|
|
108
|
+
return port_number(str(entry))
|
|
109
|
+
if isinstance(entry, str):
|
|
110
|
+
first, _, second = entry.partition(":")
|
|
111
|
+
if not second:
|
|
112
|
+
return port_number(first)
|
|
113
|
+
return port_number(first if published else second)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _Lines:
|
|
118
|
+
"""Lines of keys and array values in already parsed JSON text, found in order."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, text: str) -> None:
|
|
121
|
+
self.text = text
|
|
122
|
+
self.position = 0
|
|
123
|
+
|
|
124
|
+
def key(self, key: str) -> int:
|
|
125
|
+
match = re.search(re.escape(json.dumps(key)) + r"\s*:", self.text)
|
|
126
|
+
return match.end() if match else len(self.text)
|
|
127
|
+
|
|
128
|
+
def value(self, entry: Any, start: int) -> int | None:
|
|
129
|
+
literal = re.escape(json.dumps(entry)) if isinstance(entry, str) else rf"\b{entry}\b"
|
|
130
|
+
match = re.compile(literal).search(self.text, max(start, self.position))
|
|
131
|
+
if match is None:
|
|
132
|
+
return None
|
|
133
|
+
self.position = match.end()
|
|
134
|
+
return self.text.count("\n", 0, match.start()) + 1
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Listen ports in the task runners of ``pyproject.toml``: poe, taskipy, pdm, hatch, rye.
|
|
2
|
+
|
|
3
|
+
The file is parsed with :mod:`tomllib`; task commands are scanned as text by
|
|
4
|
+
:func:`~worktree_shield.parsers.commands.listen_ports` and never run. Script tasks that
|
|
5
|
+
call Python functions (``script = "api:main"``) are not commands and are skipped. A
|
|
6
|
+
task's ``env`` table counts like a ``PORT=…`` prefix.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import tomllib
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from pathlib import PurePosixPath
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from worktree_shield.collectors.base import Collected, Scope, port_key
|
|
19
|
+
from worktree_shield.models import ResourceClaim, ResourceKind, SkippedFile
|
|
20
|
+
from worktree_shield.names import port_number, port_variable
|
|
21
|
+
from worktree_shield.parsers.commands import PortMention, listen_ports
|
|
22
|
+
|
|
23
|
+
_MAX_DEPTH = 8 # sequences of sequences; real projects use two levels at most
|
|
24
|
+
_COMMAND_KEYS = ("cmd", "shell")
|
|
25
|
+
_SEQUENCE_KEYS = ("sequence", "composite", "chain")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def collect_pyproject(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
|
|
29
|
+
"""The listen ports in the task runners of ``directory/pyproject.toml``."""
|
|
30
|
+
relative = directory / "pyproject.toml"
|
|
31
|
+
text = scope.read(relative, out)
|
|
32
|
+
if text is None:
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
data = tomllib.loads(text)
|
|
36
|
+
except tomllib.TOMLDecodeError:
|
|
37
|
+
out.skipped.append(SkippedFile(scope.source(relative), "is not valid TOML"))
|
|
38
|
+
return
|
|
39
|
+
found: list[ResourceClaim] = []
|
|
40
|
+
for section, tasks in _task_tables(data):
|
|
41
|
+
for name, task in tasks.items():
|
|
42
|
+
line = _line(text, section, name)
|
|
43
|
+
label = ".".join((*section, name))
|
|
44
|
+
for mention in _mentions(task, 0):
|
|
45
|
+
found.append(
|
|
46
|
+
ResourceClaim(
|
|
47
|
+
ResourceKind.LISTEN_PORT,
|
|
48
|
+
port_key(mention.host, mention.port),
|
|
49
|
+
scope.worktree,
|
|
50
|
+
scope.source(relative, line),
|
|
51
|
+
f"{label}: {mention.fragment}",
|
|
52
|
+
strict=mention.strict,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
# In file order, whichever runner comes first; tasks without a known line last.
|
|
56
|
+
out.claims += sorted(found, key=lambda claim: claim.source.line or len(text) + 1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _task_tables(data: dict[str, Any]) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]:
|
|
60
|
+
tool = data.get("tool")
|
|
61
|
+
if not isinstance(tool, dict):
|
|
62
|
+
return
|
|
63
|
+
for runner, key in (
|
|
64
|
+
("poe", "tasks"),
|
|
65
|
+
("taskipy", "tasks"),
|
|
66
|
+
("pdm", "scripts"),
|
|
67
|
+
("rye", "scripts"),
|
|
68
|
+
):
|
|
69
|
+
table = tool.get(runner)
|
|
70
|
+
if isinstance(table, dict) and isinstance(table.get(key), dict):
|
|
71
|
+
yield ("tool", runner, key), table[key]
|
|
72
|
+
hatch = tool.get("hatch")
|
|
73
|
+
envs = hatch.get("envs") if isinstance(hatch, dict) else None
|
|
74
|
+
if isinstance(envs, dict):
|
|
75
|
+
for env, settings in envs.items():
|
|
76
|
+
if isinstance(settings, dict) and isinstance(settings.get("scripts"), dict):
|
|
77
|
+
yield ("tool", "hatch", "envs", env, "scripts"), settings["scripts"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _mentions(task: Any, depth: int) -> list[PortMention]:
|
|
81
|
+
"""The listen ports of one task in any of the documented shapes."""
|
|
82
|
+
if depth > _MAX_DEPTH:
|
|
83
|
+
return []
|
|
84
|
+
if isinstance(task, str):
|
|
85
|
+
return listen_ports(task)
|
|
86
|
+
if isinstance(task, list):
|
|
87
|
+
return [m for item in task for m in _mentions(item, depth + 1)]
|
|
88
|
+
if not isinstance(task, dict):
|
|
89
|
+
return []
|
|
90
|
+
found: list[PortMention] = []
|
|
91
|
+
for key in _COMMAND_KEYS:
|
|
92
|
+
command = task.get(key)
|
|
93
|
+
if isinstance(command, str):
|
|
94
|
+
found += listen_ports(command)
|
|
95
|
+
elif isinstance(command, list) and all(isinstance(arg, str) for arg in command):
|
|
96
|
+
found += listen_ports(shlex.join(command)) # an argument vector
|
|
97
|
+
for key in _SEQUENCE_KEYS:
|
|
98
|
+
if isinstance(task.get(key), list):
|
|
99
|
+
found += _mentions(task[key], depth + 1)
|
|
100
|
+
env = task.get("env")
|
|
101
|
+
if isinstance(env, dict):
|
|
102
|
+
for name, value in env.items():
|
|
103
|
+
port = port_number(value) if isinstance(value, str) else None
|
|
104
|
+
if port is not None and port_variable(name) is ResourceKind.LISTEN_PORT:
|
|
105
|
+
found.append(PortMention(port, None, f"env.{name}={port}"))
|
|
106
|
+
return found
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _line(text: str, section: tuple[str, ...], name: str) -> int | None:
|
|
110
|
+
"""The line defining task ``name``: its own ``[section.name]`` table, or a
|
|
111
|
+
``name = …`` line under ``[section]``. ``None`` when written some other way."""
|
|
112
|
+
dotted = r"\s*\.\s*".join(_key_pattern(part) for part in section)
|
|
113
|
+
own = re.compile(rf"^[ \t]*\[\s*{dotted}\s*\.\s*{_key_pattern(name)}\s*\]", re.M)
|
|
114
|
+
if match := own.search(text):
|
|
115
|
+
return text.count("\n", 0, match.start()) + 1
|
|
116
|
+
header = re.compile(rf"^[ \t]*\[\s*{dotted}\s*\]", re.M).search(text)
|
|
117
|
+
if header is None:
|
|
118
|
+
return None
|
|
119
|
+
following = re.compile(r"^[ \t]*\[", re.M).search(text, header.end())
|
|
120
|
+
end = following.start() if following else len(text)
|
|
121
|
+
key = re.compile(rf"^[ \t]*{_key_pattern(name)}\s*=", re.M).search(text, header.end(), end)
|
|
122
|
+
return text.count("\n", 0, key.start()) + 1 if key else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _key_pattern(key: str) -> str:
|
|
126
|
+
"""A TOML key as written: bare, or in double or single quotes."""
|
|
127
|
+
escaped = re.escape(key)
|
|
128
|
+
return f"(?:{escaped}|\"{escaped}\"|'{escaped}')"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Listening processes, tied to a worktree only by their working directory.
|
|
2
|
+
|
|
3
|
+
Read-only and without privileges: the listening sockets the system shows, and for each
|
|
4
|
+
owning process its name and working directory. Never its command line or environment,
|
|
5
|
+
which can hold secrets, and nothing is signalled. A process name like ``node`` says
|
|
6
|
+
nothing about the worktree, so it is never used to attribute one; a listener that
|
|
7
|
+
cannot be tied is kept with the reason, because it may still hold a port a worktree
|
|
8
|
+
needs.
|
|
9
|
+
|
|
10
|
+
The view is a snapshot: a server started a second later is not seen. On Linux the
|
|
11
|
+
sockets of other users' processes are listed without their owner; on macOS only the
|
|
12
|
+
current user's processes are visible.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from socket import SOCK_DGRAM, SOCK_STREAM
|
|
21
|
+
from typing import Any, Protocol
|
|
22
|
+
|
|
23
|
+
import psutil
|
|
24
|
+
|
|
25
|
+
from worktree_shield.collectors.base import port_key
|
|
26
|
+
from worktree_shield.models import Listener, Observation, ResourceClaim, ResourceKind, SourceRef
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Socket:
|
|
31
|
+
"""A listening (TCP) or bound (UDP) local socket and the process owning it."""
|
|
32
|
+
|
|
33
|
+
protocol: str
|
|
34
|
+
address: str
|
|
35
|
+
port: int
|
|
36
|
+
pid: int | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProcessGoneError(Exception):
|
|
40
|
+
"""The process ended while it was looked at."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProcessDeniedError(Exception):
|
|
44
|
+
"""The process cannot be inspected without more privileges."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProcessTable(Protocol):
|
|
48
|
+
def listening_sockets(self) -> tuple[list[Socket], str | None]:
|
|
49
|
+
"""The sockets, and what limited the view (``None`` when nothing did)."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def process(self, pid: int) -> tuple[str, str | None]:
|
|
53
|
+
"""Name and working directory (``None`` if unreadable) of a process."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def observe_runtime(
|
|
58
|
+
table: ProcessTable, worktrees: Sequence[str]
|
|
59
|
+
) -> tuple[list[ResourceClaim], list[Listener], list[str]]:
|
|
60
|
+
"""Runtime claims of the worktrees, the listeners no worktree owns, and the
|
|
61
|
+
limitations of the view."""
|
|
62
|
+
sockets, limitation = table.listening_sockets()
|
|
63
|
+
roots = sorted(((_canonical(w), w) for w in worktrees), key=lambda r: len(r[0]), reverse=True)
|
|
64
|
+
looked_up: dict[int, tuple[str | None, str | None, str | None]] = {}
|
|
65
|
+
claims: list[ResourceClaim] = []
|
|
66
|
+
unattributed: list[Listener] = []
|
|
67
|
+
for socket in sockets:
|
|
68
|
+
if socket.pid is None:
|
|
69
|
+
unattributed.append(_listener(socket, None, "owned by a process of another user"))
|
|
70
|
+
continue
|
|
71
|
+
if socket.pid not in looked_up:
|
|
72
|
+
looked_up[socket.pid] = _look_up(table, socket.pid, roots)
|
|
73
|
+
name, worktree, note = looked_up[socket.pid]
|
|
74
|
+
if worktree is None:
|
|
75
|
+
unattributed.append(_listener(socket, name, note or ""))
|
|
76
|
+
continue
|
|
77
|
+
key = port_key(socket.address, socket.port, protocol=socket.protocol)
|
|
78
|
+
source = SourceRef(worktree, f"pid {socket.pid}")
|
|
79
|
+
detail = f"{name} (pid {socket.pid}) is listening; it runs in this worktree"
|
|
80
|
+
claims.append(
|
|
81
|
+
ResourceClaim(
|
|
82
|
+
ResourceKind.LISTEN_PORT, key, worktree, source, detail, Observation.RUNTIME
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return claims, unattributed, [limitation] if limitation else []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _look_up(
|
|
89
|
+
table: ProcessTable, pid: int, roots: list[tuple[str, str]]
|
|
90
|
+
) -> tuple[str | None, str | None, str | None]:
|
|
91
|
+
"""(name, worktree, note): the worktree it runs in, or why there is none."""
|
|
92
|
+
try:
|
|
93
|
+
name, cwd = table.process(pid)
|
|
94
|
+
except ProcessGoneError:
|
|
95
|
+
return None, None, "the process ended while it was looked at"
|
|
96
|
+
except ProcessDeniedError:
|
|
97
|
+
return None, None, "the process cannot be inspected without more privileges"
|
|
98
|
+
if cwd is None:
|
|
99
|
+
return name, None, "its working directory cannot be read"
|
|
100
|
+
directory = _canonical(cwd)
|
|
101
|
+
for root, worktree in roots: # deepest first: nested worktrees
|
|
102
|
+
if directory == root or directory.startswith(root.rstrip(os.sep) + os.sep):
|
|
103
|
+
return name, worktree, None
|
|
104
|
+
return name, None, "it runs outside every worktree"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _listener(socket: Socket, process: str | None, note: str) -> Listener:
|
|
108
|
+
return Listener(socket.protocol, socket.address, socket.port, socket.pid, process, note)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _canonical(path: str) -> str:
|
|
112
|
+
return os.path.normcase(os.path.realpath(path))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PsutilTable:
|
|
116
|
+
"""The real process table, through psutil."""
|
|
117
|
+
|
|
118
|
+
def listening_sockets(self) -> tuple[list[Socket], str | None]:
|
|
119
|
+
found: list[Socket | None]
|
|
120
|
+
try:
|
|
121
|
+
found = [_socket(c, c.pid) for c in psutil.net_connections(kind="inet")]
|
|
122
|
+
limitation = None
|
|
123
|
+
except psutil.AccessDenied: # macOS: only the current user's processes
|
|
124
|
+
found = []
|
|
125
|
+
for process in psutil.process_iter():
|
|
126
|
+
try:
|
|
127
|
+
found += [_socket(c, process.pid) for c in process.net_connections(kind="inet")]
|
|
128
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess):
|
|
129
|
+
continue
|
|
130
|
+
limitation = "only the processes of the current user are visible"
|
|
131
|
+
listening = [s for s in found if s is not None]
|
|
132
|
+
if limitation is None and any(s.pid is None for s in listening):
|
|
133
|
+
limitation = "the sockets of other users' processes are listed without their owner"
|
|
134
|
+
return listening, limitation
|
|
135
|
+
|
|
136
|
+
def process(self, pid: int) -> tuple[str, str | None]:
|
|
137
|
+
try:
|
|
138
|
+
process = psutil.Process(pid)
|
|
139
|
+
name = process.name()
|
|
140
|
+
try:
|
|
141
|
+
cwd: str | None = process.cwd()
|
|
142
|
+
except psutil.AccessDenied:
|
|
143
|
+
cwd = None
|
|
144
|
+
return name, cwd or None
|
|
145
|
+
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
|
146
|
+
raise ProcessGoneError from None
|
|
147
|
+
except psutil.AccessDenied:
|
|
148
|
+
raise ProcessDeniedError from None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _socket(connection: Any, pid: int | None) -> Socket | None:
|
|
152
|
+
"""A listening TCP or bound, unconnected UDP socket; ``None`` for anything else.
|
|
153
|
+
``connection`` is a psutil ``sconn`` or ``pconn``; only the fields both have are used."""
|
|
154
|
+
if not connection.laddr:
|
|
155
|
+
return None
|
|
156
|
+
if connection.type == SOCK_STREAM:
|
|
157
|
+
if connection.status != psutil.CONN_LISTEN:
|
|
158
|
+
return None
|
|
159
|
+
protocol = "tcp"
|
|
160
|
+
elif connection.type == SOCK_DGRAM:
|
|
161
|
+
if connection.raddr:
|
|
162
|
+
return None
|
|
163
|
+
protocol = "udp"
|
|
164
|
+
else: # pragma: no cover - kind="inet" lists only TCP and UDP
|
|
165
|
+
return None
|
|
166
|
+
address, port = connection.laddr.ip, connection.laddr.port
|
|
167
|
+
return Socket(protocol, address.split("%", 1)[0], port, pid)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""All static collectors over one worktree: configuration files, read as data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from worktree_shield.collectors.base import Collected, Scope
|
|
6
|
+
from worktree_shield.collectors.compose import collect_compose
|
|
7
|
+
from worktree_shield.collectors.envfiles import collect_env_files
|
|
8
|
+
from worktree_shield.collectors.layout import config_directories
|
|
9
|
+
from worktree_shield.collectors.node import collect_package_json
|
|
10
|
+
from worktree_shield.collectors.procfile import collect_devcontainer, collect_procfiles
|
|
11
|
+
from worktree_shield.collectors.python import collect_pyproject
|
|
12
|
+
from worktree_shield.collectors.vite import collect_vite_config
|
|
13
|
+
from worktree_shield.models import Policy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def collect_worktree(scope: Scope, policy: Policy, out: Collected) -> None:
|
|
17
|
+
"""Every claim in the configuration of one worktree, directory by directory."""
|
|
18
|
+
for directory in config_directories(scope, out):
|
|
19
|
+
collect_env_files(scope, directory, policy, out)
|
|
20
|
+
collect_package_json(scope, directory, out)
|
|
21
|
+
collect_vite_config(scope, directory, out)
|
|
22
|
+
collect_pyproject(scope, directory, out)
|
|
23
|
+
collect_compose(scope, directory, policy, out)
|
|
24
|
+
collect_procfiles(scope, directory, out)
|
|
25
|
+
collect_devcontainer(scope, directory, out)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Ports and paths in ``vite.config.*``, matched as text; the config is never run.
|
|
2
|
+
|
|
3
|
+
Only literal settings count: ``server.port`` and ``preview.port`` (with ``host`` and
|
|
4
|
+
``strictPort`` from the same block), ``cacheDir`` and ``build.outDir`` when absolute. A
|
|
5
|
+
port computed from the environment (``Number(process.env.PORT)``) is left to the
|
|
6
|
+
collector that reads the variable. Comments, strings and template literals are blanked
|
|
7
|
+
first, so a commented-out block or a string that looks like one is ignored.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import PurePosixPath
|
|
14
|
+
|
|
15
|
+
from worktree_shield.collectors.base import Collected, Scope, is_absolute_path, path_key, port_key
|
|
16
|
+
from worktree_shield.models import ResourceClaim, ResourceKind
|
|
17
|
+
from worktree_shield.names import literal_host, port_number
|
|
18
|
+
from worktree_shield.parsers.jstext import blank_code
|
|
19
|
+
|
|
20
|
+
CONFIG_FILES = tuple(f"vite.config.{ext}" for ext in ("js", "ts", "mjs", "mts", "cjs", "cts"))
|
|
21
|
+
_BLOCK = re.compile(r"\b(server|preview|build)\s*:\s*\{")
|
|
22
|
+
_SETTING = re.compile(r"\b(port|host|strictPort|outDir)\s*:\s*")
|
|
23
|
+
_CACHE_DIR = re.compile(r"\bcacheDir\s*:\s*")
|
|
24
|
+
_END = r"(?=\s*(?:,|\}|\n|$))" # the literal is the whole value
|
|
25
|
+
_NUMBER = re.compile(r"([0-9]+)" + _END)
|
|
26
|
+
_BOOLEAN = re.compile(r"(true|false)" + _END)
|
|
27
|
+
|
|
28
|
+
_Value = str | int | bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def collect_vite_config(scope: Scope, directory: PurePosixPath, out: Collected) -> None:
|
|
32
|
+
"""The claims of every ``vite.config.*`` in ``directory``."""
|
|
33
|
+
for name in CONFIG_FILES:
|
|
34
|
+
relative = directory / name
|
|
35
|
+
text = scope.read(relative, out)
|
|
36
|
+
if text is not None:
|
|
37
|
+
out.claims += _claims(scope, relative, text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _claims(scope: Scope, relative: PurePosixPath, text: str) -> list[ResourceClaim]:
|
|
41
|
+
code = blank_code(text)
|
|
42
|
+
ports: list[ResourceClaim] = []
|
|
43
|
+
paths: list[tuple[int, ResourceClaim]] = []
|
|
44
|
+
|
|
45
|
+
def line(position: int) -> int:
|
|
46
|
+
return text.count("\n", 0, position) + 1
|
|
47
|
+
|
|
48
|
+
def path_claim(position: int, label: str, value: _Value | None) -> None:
|
|
49
|
+
if isinstance(value, str) and is_absolute_path(value):
|
|
50
|
+
source = scope.source(relative, line(position))
|
|
51
|
+
claim = ResourceClaim(
|
|
52
|
+
ResourceKind.ABSOLUTE_PATH,
|
|
53
|
+
path_key(value),
|
|
54
|
+
scope.worktree,
|
|
55
|
+
source,
|
|
56
|
+
f"{label}: {value}",
|
|
57
|
+
)
|
|
58
|
+
paths.append((position, claim))
|
|
59
|
+
|
|
60
|
+
for block in _BLOCK.finditer(code):
|
|
61
|
+
settings = _settings(code, text, block.end() - 1)
|
|
62
|
+
kind = block.group(1)
|
|
63
|
+
if kind == "build":
|
|
64
|
+
if "outDir" in settings:
|
|
65
|
+
value, position = settings["outDir"]
|
|
66
|
+
path_claim(position, "build.outDir", value)
|
|
67
|
+
continue
|
|
68
|
+
if "port" not in settings:
|
|
69
|
+
continue
|
|
70
|
+
value, position = settings["port"]
|
|
71
|
+
port = (
|
|
72
|
+
port_number(str(value))
|
|
73
|
+
if isinstance(value, int) and not isinstance(value, bool)
|
|
74
|
+
else None
|
|
75
|
+
)
|
|
76
|
+
if port is None:
|
|
77
|
+
continue
|
|
78
|
+
host_value = settings.get("host", (None, 0))[0]
|
|
79
|
+
host = literal_host(host_value) if isinstance(host_value, str) else None
|
|
80
|
+
strict = settings.get("strictPort", (False, 0))[0] is True
|
|
81
|
+
source = scope.source(relative, line(position))
|
|
82
|
+
ports.append(
|
|
83
|
+
ResourceClaim(
|
|
84
|
+
ResourceKind.LISTEN_PORT,
|
|
85
|
+
port_key(host, port),
|
|
86
|
+
scope.worktree,
|
|
87
|
+
source,
|
|
88
|
+
f"{kind}.port: {port}",
|
|
89
|
+
strict=strict,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
for match in _CACHE_DIR.finditer(code):
|
|
93
|
+
path_claim(match.start(), "cacheDir", _literal(code, text, match.end()))
|
|
94
|
+
return ports + [claim for _, claim in sorted(paths, key=lambda item: item[0])]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _settings(code: str, text: str, opening: int) -> dict[str, tuple[_Value | None, int]]:
|
|
98
|
+
"""The literal settings directly inside the object that opens at ``opening``
|
|
99
|
+
(nested objects such as ``hmr: { port }`` are skipped); empty if it never closes."""
|
|
100
|
+
depth = 0
|
|
101
|
+
found: dict[str, tuple[_Value | None, int]] = {}
|
|
102
|
+
index = opening + 1
|
|
103
|
+
while index < len(code):
|
|
104
|
+
char = code[index]
|
|
105
|
+
if char in "{[(":
|
|
106
|
+
depth += 1
|
|
107
|
+
elif char in "}])":
|
|
108
|
+
if depth == 0:
|
|
109
|
+
return found
|
|
110
|
+
depth -= 1
|
|
111
|
+
elif depth == 0 and (match := _SETTING.match(code, index)) and _starts_word(code, index):
|
|
112
|
+
found[match.group(1)] = (_literal(code, text, match.end()), index)
|
|
113
|
+
index = match.end()
|
|
114
|
+
continue
|
|
115
|
+
index += 1
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _starts_word(code: str, index: int) -> bool:
|
|
120
|
+
return index == 0 or not (code[index - 1].isalnum() or code[index - 1] in "_$")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _literal(code: str, text: str, position: int) -> _Value | None:
|
|
124
|
+
"""A number, boolean or string literal that is the whole value at ``position``."""
|
|
125
|
+
if number := _NUMBER.match(code, position):
|
|
126
|
+
return int(number.group(1))
|
|
127
|
+
if boolean := _BOOLEAN.match(code, position):
|
|
128
|
+
return boolean.group(1) == "true"
|
|
129
|
+
if position < len(code) and code[position] in "'\"":
|
|
130
|
+
closing = code.find(code[position], position + 1)
|
|
131
|
+
if closing > 0 and re.match(_END, code[closing + 1 :]):
|
|
132
|
+
return text[position + 1 : closing]
|
|
133
|
+
return None
|