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.
Files changed (56) hide show
  1. worktree_shield/__init__.py +8 -0
  2. worktree_shield/__main__.py +6 -0
  3. worktree_shield/cli.py +377 -0
  4. worktree_shield/collectors/__init__.py +1 -0
  5. worktree_shield/collectors/base.py +63 -0
  6. worktree_shield/collectors/compose.py +315 -0
  7. worktree_shield/collectors/envfiles.py +130 -0
  8. worktree_shield/collectors/layout.py +106 -0
  9. worktree_shield/collectors/node.py +87 -0
  10. worktree_shield/collectors/procfile.py +134 -0
  11. worktree_shield/collectors/python.py +128 -0
  12. worktree_shield/collectors/runtime.py +167 -0
  13. worktree_shield/collectors/static.py +25 -0
  14. worktree_shield/collectors/vite.py +133 -0
  15. worktree_shield/collisions.py +115 -0
  16. worktree_shield/config.py +188 -0
  17. worktree_shield/exit_codes.py +22 -0
  18. worktree_shield/files.py +70 -0
  19. worktree_shield/git/__init__.py +1 -0
  20. worktree_shield/git/discovery.py +85 -0
  21. worktree_shield/git/porcelain.py +48 -0
  22. worktree_shield/models.py +356 -0
  23. worktree_shield/names.py +83 -0
  24. worktree_shield/orchestrator.py +121 -0
  25. worktree_shield/overlap.py +175 -0
  26. worktree_shield/parsers/__init__.py +1 -0
  27. worktree_shield/parsers/commands.py +158 -0
  28. worktree_shield/parsers/compose.py +148 -0
  29. worktree_shield/parsers/dburl.py +156 -0
  30. worktree_shield/parsers/dotenv.py +91 -0
  31. worktree_shield/parsers/interpolate.py +142 -0
  32. worktree_shield/parsers/jsonc.py +48 -0
  33. worktree_shield/parsers/jstext.py +142 -0
  34. worktree_shield/plan.py +175 -0
  35. worktree_shield/privacy.py +237 -0
  36. worktree_shield/reports/__init__.py +1 -0
  37. worktree_shield/reports/findings.py +176 -0
  38. worktree_shield/reports/json_report.py +61 -0
  39. worktree_shield/reports/markdown.py +103 -0
  40. worktree_shield/reports/sarif.py +116 -0
  41. worktree_shield/reports/terminal.py +327 -0
  42. worktree_shield/rules/__init__.py +1 -0
  43. worktree_shield/rules/catalog.py +28 -0
  44. worktree_shield/rules/common.py +61 -0
  45. worktree_shield/rules/context.py +201 -0
  46. worktree_shield/rules/engine.py +60 -0
  47. worktree_shield/rules/explain.py +27 -0
  48. worktree_shield/rules/rules.md +112 -0
  49. worktree_shield/rules/runtime.py +164 -0
  50. worktree_shield/rules/shared.py +216 -0
  51. worktree_shield/runner.py +273 -0
  52. worktree_shield-0.1.0.dist-info/METADATA +151 -0
  53. worktree_shield-0.1.0.dist-info/RECORD +56 -0
  54. worktree_shield-0.1.0.dist-info/WHEEL +4 -0
  55. worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
  56. 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