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,175 @@
|
|
|
1
|
+
"""Whether two resource keys of the same kind name the same resource.
|
|
2
|
+
|
|
3
|
+
Keys keep what the configuration says (``tcp://*:5173``, ``tcp://127.0.0.1:5173``), so
|
|
4
|
+
equal strings are not the test: two listeners on the same port collide when one of
|
|
5
|
+
them binds every address. The rules here are the ones the collision graph uses.
|
|
6
|
+
|
|
7
|
+
Ports
|
|
8
|
+
``*`` (no host configured) and ``0.0.0.0`` bind every IPv4 address; ``::`` binds every
|
|
9
|
+
address, IPv4 included, because Linux sockets are dual-stack by default
|
|
10
|
+
(``net.ipv6.bindv6only = 0``). Two different addresses never collide; a host name
|
|
11
|
+
*possibly* matches an address or another name, since it is resolved at start-up.
|
|
12
|
+
Databases
|
|
13
|
+
``localhost``, ``127.0.0.1`` and ``::1`` reach the same server. A database left to the
|
|
14
|
+
default (``local``, no host) or a Unix socket is *possibly* that server, and an
|
|
15
|
+
unknown database name (``?``) possibly any database on it.
|
|
16
|
+
Paths
|
|
17
|
+
Compared after normalising ``.``, ``..`` and repeated separators, case-insensitively
|
|
18
|
+
for Windows paths. One inside the other possibly overlaps (an output directory that
|
|
19
|
+
holds another worktree's cache).
|
|
20
|
+
Names
|
|
21
|
+
Compose projects, container names and volumes are equal or not.
|
|
22
|
+
|
|
23
|
+
Client endpoints never overlap: many clients share one server.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ipaddress
|
|
29
|
+
import ntpath
|
|
30
|
+
import posixpath
|
|
31
|
+
import re
|
|
32
|
+
from enum import Enum
|
|
33
|
+
|
|
34
|
+
from worktree_shield.models import ResourceKind
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Overlap(Enum):
|
|
38
|
+
"""``SAME`` resource for certain, ``POSSIBLE`` depending on what a name resolves to
|
|
39
|
+
or how a path is used, or ``NONE``."""
|
|
40
|
+
|
|
41
|
+
NONE = "none"
|
|
42
|
+
POSSIBLE = "possible"
|
|
43
|
+
SAME = "same"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_PORT_KEY = re.compile(r"([a-z]+)://(\[[^\]]*\]|[^:\[\]]*):([0-9]+)")
|
|
47
|
+
_LOOPBACK = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
48
|
+
_LOCAL = "local"
|
|
49
|
+
_IP = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def overlap(kind: ResourceKind, a: str, b: str) -> Overlap:
|
|
53
|
+
"""How the resources behind keys ``a`` and ``b`` (both of ``kind``) overlap."""
|
|
54
|
+
if kind is ResourceKind.CLIENT_ENDPOINT:
|
|
55
|
+
return Overlap.NONE
|
|
56
|
+
if kind is ResourceKind.LISTEN_PORT:
|
|
57
|
+
return _ports(a, b)
|
|
58
|
+
if kind is ResourceKind.DATABASE:
|
|
59
|
+
return _databases(a, b)
|
|
60
|
+
if kind is ResourceKind.ABSOLUTE_PATH:
|
|
61
|
+
return _paths(a, b)
|
|
62
|
+
return Overlap.SAME if a == b else Overlap.NONE
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ports(a: str, b: str) -> Overlap:
|
|
66
|
+
first, second = _PORT_KEY.fullmatch(a), _PORT_KEY.fullmatch(b)
|
|
67
|
+
if first is None or second is None:
|
|
68
|
+
return Overlap.SAME if a == b else Overlap.NONE
|
|
69
|
+
if (first.group(1), first.group(3)) != (second.group(1), second.group(3)):
|
|
70
|
+
return Overlap.NONE # another protocol or port
|
|
71
|
+
return _hosts(first.group(2).strip("[]"), second.group(2).strip("[]"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _hosts(a: str, b: str) -> Overlap:
|
|
75
|
+
if a == b:
|
|
76
|
+
return Overlap.SAME
|
|
77
|
+
first, second = _address(a), _address(b)
|
|
78
|
+
if _binds_all(first, second) or _binds_all(second, first):
|
|
79
|
+
return Overlap.SAME
|
|
80
|
+
if isinstance(first, _IP) and isinstance(second, _IP):
|
|
81
|
+
return Overlap.NONE # two distinct addresses
|
|
82
|
+
if "localhost" in (a, b):
|
|
83
|
+
other = second if a == "localhost" else first
|
|
84
|
+
if isinstance(other, _IP):
|
|
85
|
+
return Overlap.POSSIBLE if other.is_loopback else Overlap.NONE
|
|
86
|
+
return Overlap.POSSIBLE # a host name: it resolves only when the server starts
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _binds_all(this: _IP | str, other: _IP | str) -> bool:
|
|
90
|
+
"""Whether a listener on ``this`` also takes ``other``'s address."""
|
|
91
|
+
if this == "*" or this == ipaddress.ip_address("::"):
|
|
92
|
+
return True
|
|
93
|
+
ipv4_any = ipaddress.ip_address("0.0.0.0") # noqa: S104
|
|
94
|
+
return this == ipv4_any and (other == "*" or isinstance(other, ipaddress.IPv4Address))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _address(host: str) -> _IP | str:
|
|
98
|
+
if host in {"", "*"}:
|
|
99
|
+
return "*"
|
|
100
|
+
try:
|
|
101
|
+
return ipaddress.ip_address(host)
|
|
102
|
+
except ValueError:
|
|
103
|
+
return host
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _databases(a: str, b: str) -> Overlap:
|
|
107
|
+
first, second = _database(a), _database(b)
|
|
108
|
+
if first is None or second is None:
|
|
109
|
+
return Overlap.SAME if a == b else Overlap.NONE
|
|
110
|
+
if first[0] != second[0] or first[2] != second[2]:
|
|
111
|
+
return Overlap.NONE # another engine or port
|
|
112
|
+
if first[0] == "sqlite":
|
|
113
|
+
return Overlap.SAME if first[3] == second[3] else Overlap.NONE
|
|
114
|
+
server = _servers(first[1], second[1])
|
|
115
|
+
if server is Overlap.NONE:
|
|
116
|
+
return server
|
|
117
|
+
if first[3] == "?" or second[3] == "?":
|
|
118
|
+
return Overlap.POSSIBLE
|
|
119
|
+
if first[3] != second[3]:
|
|
120
|
+
return Overlap.NONE
|
|
121
|
+
return server
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _database(key: str) -> tuple[str, str, str, str] | None:
|
|
125
|
+
"""``db://engine/server:port/database`` as (engine, server, port, database)."""
|
|
126
|
+
if not key.startswith("db://"):
|
|
127
|
+
return None
|
|
128
|
+
engine, _, rest = key[len("db://") :].partition("/")
|
|
129
|
+
if engine == "sqlite":
|
|
130
|
+
return engine, "", "", posixpath.normpath(rest)
|
|
131
|
+
address, _, database = rest.rpartition("/")
|
|
132
|
+
server, colon, port = address.rpartition(":")
|
|
133
|
+
if not colon or not port.isdigit():
|
|
134
|
+
server, port = address, ""
|
|
135
|
+
return engine, server.strip("[]") if server.startswith("[") else server, port, database
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _servers(a: str, b: str) -> Overlap:
|
|
139
|
+
if a == b or (a in _LOOPBACK and b in _LOOPBACK):
|
|
140
|
+
return Overlap.SAME
|
|
141
|
+
if a.startswith("unix:") and b.startswith("unix:"):
|
|
142
|
+
return Overlap.NONE # two socket directories, two servers
|
|
143
|
+
if _is_local(a) and _is_local(b):
|
|
144
|
+
return Overlap.POSSIBLE # the default local server, by socket or by TCP
|
|
145
|
+
return Overlap.NONE
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_local(server: str) -> bool:
|
|
149
|
+
return server == _LOCAL or server in _LOOPBACK or server.startswith("unix:")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _paths(a: str, b: str) -> Overlap:
|
|
153
|
+
first, second = _path(a), _path(b)
|
|
154
|
+
if first == second:
|
|
155
|
+
return Overlap.SAME
|
|
156
|
+
separator = "\\" if "\\" in first or "\\" in second else "/"
|
|
157
|
+
if first.startswith(second.rstrip(separator) + separator) or second.startswith(
|
|
158
|
+
first.rstrip(separator) + separator
|
|
159
|
+
):
|
|
160
|
+
return Overlap.POSSIBLE
|
|
161
|
+
return Overlap.NONE
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _path(key: str) -> str:
|
|
165
|
+
path = key.removeprefix("path://")
|
|
166
|
+
if re.match(r"[A-Za-z]:[\\/]", path):
|
|
167
|
+
return ntpath.normcase(ntpath.normpath(path))
|
|
168
|
+
return posixpath.normpath(path)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def path_within(key: str, directory: str) -> bool:
|
|
172
|
+
"""Whether the path of ``key`` (``path://…``) is ``directory`` or lies inside it."""
|
|
173
|
+
path, root = _path(key), _path(f"path://{directory}")
|
|
174
|
+
separator = "\\" if "\\" in path or "\\" in root else "/"
|
|
175
|
+
return path == root or path.startswith(root.rstrip(separator) + separator)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Parsers for configuration text: pure functions, no file or process access."""
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Literal listen ports in script command lines, found by reading the text.
|
|
2
|
+
|
|
3
|
+
Nothing is run or expanded. A command is split like a POSIX shell would split it,
|
|
4
|
+
cut at ``&&``, ``||``, ``;``, ``|`` and ``&``, and each part is searched for the
|
|
5
|
+
spellings dev servers use: ``--port 5173``, ``-p 3001``, ``-l 5000``,
|
|
6
|
+
``--bind 127.0.0.1:8000``, ``-S localhost:8000``, ``PORT=4000 node …`` and the
|
|
7
|
+
argument of ``runserver`` or ``http.server``. Quoted arguments that are commands
|
|
8
|
+
themselves (``concurrently 'vite --port 5173' …``) are searched too.
|
|
9
|
+
|
|
10
|
+
A port given through a variable (``--port $PORT``) is not literal and is skipped; the
|
|
11
|
+
variable's own definition is where the collectors find it. In a part that runs a client
|
|
12
|
+
(``psql -p 5432``, ``ssh -p 2222``) the flags name a server to connect to and are
|
|
13
|
+
ignored.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import shlex
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import PurePosixPath
|
|
22
|
+
|
|
23
|
+
from worktree_shield.models import ResourceKind
|
|
24
|
+
from worktree_shield.names import literal_host, port_number, port_variable
|
|
25
|
+
|
|
26
|
+
_SEPARATORS = frozenset({"&&", "||", ";", "|", "&", ";;", "(", ")"})
|
|
27
|
+
_PORT_FLAGS = frozenset({"--port", "-p", "--listen", "-l"})
|
|
28
|
+
_BIND_FLAGS = frozenset({"--bind", "-b", "-S"})
|
|
29
|
+
_HOST_FLAGS = frozenset({"--host", "--hostname", "-H"})
|
|
30
|
+
_PORT_ARGUMENT_AFTER = frozenset({"runserver", "http.server"})
|
|
31
|
+
_CLIENT_PROGRAMS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"psql",
|
|
34
|
+
"pg_dump",
|
|
35
|
+
"pg_restore",
|
|
36
|
+
"pg_isready",
|
|
37
|
+
"mysql",
|
|
38
|
+
"mysqldump",
|
|
39
|
+
"mysqladmin",
|
|
40
|
+
"redis-cli",
|
|
41
|
+
"mongosh",
|
|
42
|
+
"mongo",
|
|
43
|
+
"ssh",
|
|
44
|
+
"scp",
|
|
45
|
+
"sftp",
|
|
46
|
+
"nc",
|
|
47
|
+
"telnet",
|
|
48
|
+
"curl",
|
|
49
|
+
"wget",
|
|
50
|
+
"docker",
|
|
51
|
+
"podman",
|
|
52
|
+
"kubectl",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
_VARIABLE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
56
|
+
_MAX_DEPTH = 3
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class PortMention:
|
|
61
|
+
"""A listen port in a command; ``fragment`` is the part that names it (never the
|
|
62
|
+
whole command, which may carry other values). ``strict`` is set for Vite: ``True``
|
|
63
|
+
with ``--strictPort``, otherwise ``False`` (it moves to the next free port)."""
|
|
64
|
+
|
|
65
|
+
port: int
|
|
66
|
+
host: str | None
|
|
67
|
+
fragment: str
|
|
68
|
+
strict: bool | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def listen_ports(command: str) -> list[PortMention]:
|
|
72
|
+
"""Every literal listen port in ``command``, in order."""
|
|
73
|
+
return _scan(command, 0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _scan(command: str, depth: int) -> list[PortMention]:
|
|
77
|
+
found: list[PortMention] = []
|
|
78
|
+
part: list[str] = []
|
|
79
|
+
for token in [*_tokens(command), ";"]:
|
|
80
|
+
if token in _SEPARATORS:
|
|
81
|
+
found += _scan_part(part, depth)
|
|
82
|
+
part = []
|
|
83
|
+
else:
|
|
84
|
+
part.append(token)
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _tokens(command: str) -> list[str]:
|
|
89
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
90
|
+
lexer.whitespace_split = True
|
|
91
|
+
lexer.commenters = ""
|
|
92
|
+
try:
|
|
93
|
+
return list(lexer)
|
|
94
|
+
except ValueError: # an unclosed quote: words are still worth a look
|
|
95
|
+
return command.split()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _scan_part(part: list[str], depth: int) -> list[PortMention]:
|
|
99
|
+
if any(PurePosixPath(token).name in _CLIENT_PROGRAMS for token in part):
|
|
100
|
+
return []
|
|
101
|
+
strict = None
|
|
102
|
+
if any(PurePosixPath(token).name == "vite" for token in part):
|
|
103
|
+
strict = "--strictPort" in part or "--strict-port" in part
|
|
104
|
+
host = _host_option(part)
|
|
105
|
+
found: list[PortMention] = []
|
|
106
|
+
for index, token in enumerate(part):
|
|
107
|
+
following = part[index + 1] if index + 1 < len(part) else None
|
|
108
|
+
if any(c.isspace() for c in token):
|
|
109
|
+
if depth < _MAX_DEPTH:
|
|
110
|
+
found += _scan(token, depth + 1)
|
|
111
|
+
continue
|
|
112
|
+
name, equals, value = token.partition("=")
|
|
113
|
+
if equals and _VARIABLE.fullmatch(name):
|
|
114
|
+
port = port_number(value)
|
|
115
|
+
if port is not None and port_variable(name) is ResourceKind.LISTEN_PORT:
|
|
116
|
+
found.append(PortMention(port, None, token, None))
|
|
117
|
+
continue
|
|
118
|
+
argument: str | None
|
|
119
|
+
if equals and name.startswith("-"):
|
|
120
|
+
flag, argument, fragment = name, value, token
|
|
121
|
+
else:
|
|
122
|
+
flag, argument, fragment = token, following, f"{token} {following}"
|
|
123
|
+
if argument is None:
|
|
124
|
+
continue
|
|
125
|
+
if flag in _PORT_FLAGS:
|
|
126
|
+
port = port_number(argument)
|
|
127
|
+
if port is not None:
|
|
128
|
+
found.append(PortMention(port, host, fragment, strict))
|
|
129
|
+
elif flag in _BIND_FLAGS or flag in _PORT_ARGUMENT_AFTER:
|
|
130
|
+
address = _address(argument)
|
|
131
|
+
if address is not None:
|
|
132
|
+
found.append(PortMention(address[1], address[0], fragment, strict))
|
|
133
|
+
return found
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _host_option(part: list[str]) -> str | None:
|
|
137
|
+
for index, token in enumerate(part):
|
|
138
|
+
name, equals, value = token.partition("=")
|
|
139
|
+
if equals and name in _HOST_FLAGS:
|
|
140
|
+
return literal_host(value)
|
|
141
|
+
if token in _HOST_FLAGS and index + 1 < len(part):
|
|
142
|
+
return literal_host(part[index + 1])
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _address(text: str) -> tuple[str | None, int] | None:
|
|
147
|
+
"""``127.0.0.1:8000``, ``[::1]:8000``, ``:8000`` or ``8000`` as host and port."""
|
|
148
|
+
host_text, colon, port_text = text.rpartition(":")
|
|
149
|
+
if not colon:
|
|
150
|
+
port = port_number(text)
|
|
151
|
+
return (None, port) if port is not None else None
|
|
152
|
+
port = port_number(port_text)
|
|
153
|
+
if port is None:
|
|
154
|
+
return None
|
|
155
|
+
if not host_text:
|
|
156
|
+
return None, port
|
|
157
|
+
host = literal_host(host_text)
|
|
158
|
+
return (host, port) if host is not None else None
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The ``ports`` and ``volumes`` syntax of the Compose specification.
|
|
2
|
+
|
|
3
|
+
Only what decides a collision is extracted: the host ports a service publishes (two
|
|
4
|
+
stacks publishing the same host port cannot run at once) and the host paths and named
|
|
5
|
+
volumes it mounts. Values must already be interpolated. An entry that is not valid
|
|
6
|
+
Compose comes back as ``None`` so a report can say it was not understood, rather than
|
|
7
|
+
as a guess.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from worktree_shield.names import literal_host, port_number
|
|
17
|
+
|
|
18
|
+
MAX_RANGE = 256 # ports in one published range; a wider one is reported as unreadable
|
|
19
|
+
_PROTOCOLS = frozenset({"tcp", "udp", "sctp"})
|
|
20
|
+
_RANGE = re.compile(r"([0-9]{1,5})(?:-([0-9]{1,5}))?")
|
|
21
|
+
_WINDOWS_SOURCE = re.compile(r"[A-Za-z]:[\\/]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class PublishedPort:
|
|
26
|
+
"""A host port a service publishes. ``host_ip`` is as written (``0.0.0.0`` stays);
|
|
27
|
+
``None`` means no address was given, which also publishes on every address."""
|
|
28
|
+
|
|
29
|
+
host_ip: str | None
|
|
30
|
+
port: int
|
|
31
|
+
protocol: str = "tcp"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Mount:
|
|
36
|
+
"""One entry of a service's ``volumes``. ``source`` is a host path for ``bind``, a
|
|
37
|
+
volume name for ``volume`` (``None`` for an anonymous one), ``None`` for ``tmpfs``."""
|
|
38
|
+
|
|
39
|
+
kind: str
|
|
40
|
+
source: str | None
|
|
41
|
+
read_only: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def published_ports(entry: Any) -> list[PublishedPort] | None:
|
|
45
|
+
"""The host ports a ``ports`` entry publishes: empty when Docker picks a free one,
|
|
46
|
+
``None`` when the entry is not valid."""
|
|
47
|
+
if isinstance(entry, bool):
|
|
48
|
+
return None
|
|
49
|
+
if isinstance(entry, int):
|
|
50
|
+
return [] if port_number(str(entry)) is not None else None
|
|
51
|
+
if isinstance(entry, str):
|
|
52
|
+
return _short_ports(entry.strip())
|
|
53
|
+
if isinstance(entry, dict):
|
|
54
|
+
return _long_ports(entry)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _short_ports(text: str) -> list[PublishedPort] | None:
|
|
59
|
+
"""``[[IP:]HOST:]CONTAINER[/PROTOCOL]``, ports or ranges, IPv6 in brackets."""
|
|
60
|
+
text, _, protocol = text.partition("/")
|
|
61
|
+
protocol = protocol or "tcp"
|
|
62
|
+
if protocol not in _PROTOCOLS:
|
|
63
|
+
return None
|
|
64
|
+
host_ip: str | None = None
|
|
65
|
+
if text.startswith("["):
|
|
66
|
+
address, bracket, text = text[1:].partition("]")
|
|
67
|
+
if not bracket or not text.startswith(":"):
|
|
68
|
+
return None
|
|
69
|
+
host_ip, text = literal_host(address), text[1:]
|
|
70
|
+
if host_ip is None:
|
|
71
|
+
return None
|
|
72
|
+
parts = text.split(":")
|
|
73
|
+
if len(parts) == 3 and host_ip is None:
|
|
74
|
+
host_ip = literal_host(parts[0])
|
|
75
|
+
if host_ip is None:
|
|
76
|
+
return None
|
|
77
|
+
parts = parts[1:]
|
|
78
|
+
if len(parts) == 1:
|
|
79
|
+
return [] if _ports(parts[0]) else None # container port only
|
|
80
|
+
if len(parts) != 2 or _ports(parts[1]) is None:
|
|
81
|
+
return None
|
|
82
|
+
if parts[0] == "":
|
|
83
|
+
return [] # 127.0.0.1::5000: a free host port on that address
|
|
84
|
+
host_ports = _ports(parts[0])
|
|
85
|
+
if host_ports is None:
|
|
86
|
+
return None
|
|
87
|
+
return [PublishedPort(host_ip, port, protocol) for port in host_ports]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _long_ports(entry: dict[str, Any]) -> list[PublishedPort] | None:
|
|
91
|
+
published = entry.get("published")
|
|
92
|
+
if published is None:
|
|
93
|
+
return [] if "target" in entry else None
|
|
94
|
+
if isinstance(published, bool) or not isinstance(published, (int, str)):
|
|
95
|
+
return None
|
|
96
|
+
host_ports = _ports(str(published))
|
|
97
|
+
protocol = entry.get("protocol", "tcp")
|
|
98
|
+
host_ip = entry.get("host_ip")
|
|
99
|
+
if host_ports is None or protocol not in _PROTOCOLS:
|
|
100
|
+
return None
|
|
101
|
+
if host_ip is not None and (not isinstance(host_ip, str) or literal_host(host_ip) is None):
|
|
102
|
+
return None
|
|
103
|
+
address = literal_host(host_ip) if isinstance(host_ip, str) else None
|
|
104
|
+
return [PublishedPort(address, port, protocol) for port in host_ports]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ports(text: str) -> list[int] | None:
|
|
108
|
+
"""``8000`` or ``8000-8005`` as a list of ports; ``None`` if not valid."""
|
|
109
|
+
match = _RANGE.fullmatch(text)
|
|
110
|
+
if match is None:
|
|
111
|
+
return None
|
|
112
|
+
first = port_number(match.group(1))
|
|
113
|
+
last = port_number(match.group(2)) if match.group(2) else first
|
|
114
|
+
if first is None or last is None or last < first or last - first >= MAX_RANGE:
|
|
115
|
+
return None
|
|
116
|
+
return list(range(first, last + 1))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_mount(entry: Any) -> Mount | None:
|
|
120
|
+
"""One ``volumes`` entry of a service, short or long syntax; ``None`` if not valid."""
|
|
121
|
+
if isinstance(entry, str):
|
|
122
|
+
return _short_mount(entry.strip())
|
|
123
|
+
if isinstance(entry, dict):
|
|
124
|
+
kind = entry.get("type", "volume")
|
|
125
|
+
source = entry.get("source")
|
|
126
|
+
if not isinstance(kind, str) or (source is not None and not isinstance(source, str)):
|
|
127
|
+
return None
|
|
128
|
+
if kind == "bind" and not source:
|
|
129
|
+
return None
|
|
130
|
+
return Mount(kind, source or None, entry.get("read_only") is True)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _short_mount(text: str) -> Mount | None:
|
|
135
|
+
"""``[SOURCE:]TARGET[:MODE]``; a source that looks like a path is a bind mount."""
|
|
136
|
+
if not text:
|
|
137
|
+
return None
|
|
138
|
+
drive = ""
|
|
139
|
+
if _WINDOWS_SOURCE.match(text):
|
|
140
|
+
drive, text = text[:2], text[2:]
|
|
141
|
+
parts = text.split(":")
|
|
142
|
+
if len(parts) == 1:
|
|
143
|
+
return Mount("volume", None) if not drive else None # anonymous volume
|
|
144
|
+
source, mode = drive + parts[0], parts[2] if len(parts) > 2 else ""
|
|
145
|
+
read_only = "ro" in mode.split(",")
|
|
146
|
+
if drive or source.startswith(("/", ".", "~")):
|
|
147
|
+
return Mount("bind", source, read_only)
|
|
148
|
+
return Mount("volume", source, read_only)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Database URLs reduced to what identifies the database: engine, server and name.
|
|
2
|
+
|
|
3
|
+
User names and passwords are dropped while parsing; they reach neither the result nor
|
|
4
|
+
an error message, and an error never repeats the URL. When the server cannot be found
|
|
5
|
+
with certainty (an unencoded ``/``, ``?`` or ``#`` in a password moves the apparent
|
|
6
|
+
host, a libpq host list, an unexpanded ``${DB_HOST}``) the URL is refused with
|
|
7
|
+
:class:`DatabaseURLError` instead of being guessed: a wrong host would report a
|
|
8
|
+
collision that is not there, or hide one that is.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from urllib.parse import parse_qs, unquote
|
|
16
|
+
|
|
17
|
+
_SCHEME = re.compile(r"(?:jdbc:)?([A-Za-z][A-Za-z0-9+.-]*)://(.*)", re.S)
|
|
18
|
+
_ENGINES = {
|
|
19
|
+
"postgres": "postgres",
|
|
20
|
+
"postgresql": "postgres",
|
|
21
|
+
"mysql": "mysql",
|
|
22
|
+
"mariadb": "mysql",
|
|
23
|
+
"redis": "redis",
|
|
24
|
+
"rediss": "redis",
|
|
25
|
+
"mongodb": "mongodb",
|
|
26
|
+
"sqlite": "sqlite",
|
|
27
|
+
}
|
|
28
|
+
_DEFAULT_PORTS = {"postgres": 5432, "mysql": 3306, "redis": 6379, "mongodb": 27017}
|
|
29
|
+
_REFERENCE = re.compile(r"\$(?:\{|[A-Za-z_])")
|
|
30
|
+
_BRACKETED = re.compile(r"\[([^\]]*)\](?::(.*))?")
|
|
31
|
+
_DIGITS = re.compile(r"[0-9]{1,5}") # str.isdigit() also accepts "²", which int() refuses
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DatabaseURLError(ValueError):
|
|
35
|
+
"""A database URL whose server cannot be determined safely; never contains the URL."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class DatabaseTarget:
|
|
40
|
+
"""A database without credentials. ``host`` is lower case, ``None`` for the local
|
|
41
|
+
default or a Unix ``socket``; ``port`` is the engine's default when not given;
|
|
42
|
+
``database`` is ``None`` when the URL leaves it to the server (Postgres then uses
|
|
43
|
+
the user name, which was dropped). For SQLite ``database`` is the file path."""
|
|
44
|
+
|
|
45
|
+
engine: str
|
|
46
|
+
host: str | None
|
|
47
|
+
port: int | None
|
|
48
|
+
database: str | None
|
|
49
|
+
socket: str | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def key(self) -> str:
|
|
53
|
+
"""``db://postgres/localhost:5432/app_dev``: equal keys, same database."""
|
|
54
|
+
if self.engine == "sqlite":
|
|
55
|
+
return f"db://sqlite/{self.database}"
|
|
56
|
+
if self.socket is not None:
|
|
57
|
+
server = f"unix:{self.socket}"
|
|
58
|
+
elif self.host is None:
|
|
59
|
+
server = "local"
|
|
60
|
+
elif ":" in self.host and "," not in self.host:
|
|
61
|
+
server = f"[{self.host}]"
|
|
62
|
+
else:
|
|
63
|
+
server = self.host
|
|
64
|
+
port = "" if self.port is None else f":{self.port}"
|
|
65
|
+
database = self.database if self.database is not None else "?"
|
|
66
|
+
return f"db://{self.engine}/{server}{port}/{database}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_database_url(value: str) -> DatabaseTarget | None:
|
|
70
|
+
"""The database ``value`` points to, or ``None`` when it is not a database URL.
|
|
71
|
+
|
|
72
|
+
Raises :class:`DatabaseURLError` for a database URL that cannot be read safely.
|
|
73
|
+
"""
|
|
74
|
+
match = _SCHEME.fullmatch(value.strip())
|
|
75
|
+
if match is None:
|
|
76
|
+
return None
|
|
77
|
+
scheme, rest = match.group(1).lower(), match.group(2)
|
|
78
|
+
engine = _ENGINES.get(scheme.split("+", 1)[0])
|
|
79
|
+
if engine is None:
|
|
80
|
+
return None
|
|
81
|
+
if engine == "sqlite":
|
|
82
|
+
return _sqlite(rest)
|
|
83
|
+
if _REFERENCE.search(rest):
|
|
84
|
+
raise DatabaseURLError("it refers to another variable, which is not expanded")
|
|
85
|
+
end = min((i for i in (rest.find(c) for c in "/?#") if i >= 0), default=len(rest))
|
|
86
|
+
authority, tail = rest[:end], rest[end:]
|
|
87
|
+
if "@" in tail:
|
|
88
|
+
raise DatabaseURLError(
|
|
89
|
+
"it has an @ after the host part, so an unencoded /, ? or # in the password "
|
|
90
|
+
"may have moved the host; percent-encode the password"
|
|
91
|
+
)
|
|
92
|
+
hostport = authority.rsplit("@", 1)[-1] # everything before the last @ is credentials
|
|
93
|
+
path, _, query_part = tail.partition("#")[0].partition("?")
|
|
94
|
+
query = parse_qs(query_part)
|
|
95
|
+
database = unquote(path.lstrip("/")) or None
|
|
96
|
+
if engine == "mongodb":
|
|
97
|
+
return _mongodb(hostport, database, srv=scheme == "mongodb+srv")
|
|
98
|
+
if "," in hostport:
|
|
99
|
+
raise DatabaseURLError("it lists several hosts")
|
|
100
|
+
host, port = _host_and_port(hostport, _DEFAULT_PORTS[engine])
|
|
101
|
+
socket = None
|
|
102
|
+
if engine == "postgres":
|
|
103
|
+
host = _host(_first(query, "host", host))
|
|
104
|
+
port = _port(_first(query, "port", None), port)
|
|
105
|
+
database = _first(query, "dbname", database)
|
|
106
|
+
if engine == "redis":
|
|
107
|
+
database = _first(query, "db", database) or "0"
|
|
108
|
+
if host is not None and host.startswith("/"):
|
|
109
|
+
host, socket = None, host
|
|
110
|
+
return DatabaseTarget(engine, host, port, database, socket)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _sqlite(rest: str) -> DatabaseTarget | None:
|
|
114
|
+
path = unquote(rest.partition("?")[0].removeprefix("/"))
|
|
115
|
+
if not path or path == ":memory:":
|
|
116
|
+
return None # in memory: nothing on disk to share
|
|
117
|
+
return DatabaseTarget("sqlite", None, None, path)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _mongodb(hostport: str, database: str | None, *, srv: bool) -> DatabaseTarget:
|
|
121
|
+
default = None if srv else _DEFAULT_PORTS["mongodb"]
|
|
122
|
+
servers = [_host_and_port(part, default) for part in hostport.split(",")]
|
|
123
|
+
if len(servers) == 1:
|
|
124
|
+
host, port = servers[0]
|
|
125
|
+
return DatabaseTarget("mongodb", host, port, database)
|
|
126
|
+
names = sorted(f"{host}:{port}" if port is not None else f"{host}" for host, port in servers)
|
|
127
|
+
return DatabaseTarget("mongodb", ",".join(names), None, database)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _host_and_port(hostport: str, default: int | None) -> tuple[str | None, int | None]:
|
|
131
|
+
bracketed = _BRACKETED.fullmatch(hostport)
|
|
132
|
+
if bracketed is not None:
|
|
133
|
+
host, port = bracketed.group(1), bracketed.group(2)
|
|
134
|
+
else:
|
|
135
|
+
host, _, port = hostport.partition(":")
|
|
136
|
+
return _host(unquote(host)), _port(port or None, default)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _host(name: str | None) -> str | None:
|
|
140
|
+
"""Host names are case-insensitive; a socket path (``/run/postgresql``) is not."""
|
|
141
|
+
if not name:
|
|
142
|
+
return None
|
|
143
|
+
return name if name.startswith("/") else name.lower()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _port(text: str | None, default: int | None) -> int | None:
|
|
147
|
+
if text is None:
|
|
148
|
+
return default
|
|
149
|
+
if not _DIGITS.fullmatch(text) or not 1 <= int(text) <= 65535:
|
|
150
|
+
raise DatabaseURLError("its port is not a number from 1 to 65535")
|
|
151
|
+
return int(text)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _first(query: dict[str, list[str]], name: str, default: str | None) -> str | None:
|
|
155
|
+
values = query.get(name)
|
|
156
|
+
return values[0] if values else default
|