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,115 @@
|
|
|
1
|
+
"""The collision graph: claims of different worktrees on overlapping resources.
|
|
2
|
+
|
|
3
|
+
Pure: claims in, collisions out. Keys of one kind are joined when any two of them
|
|
4
|
+
overlap (:func:`~worktree_shield.overlap.overlap`), so ``0.0.0.0:8000`` links
|
|
5
|
+
``127.0.0.1:8000`` and ``127.0.0.2:8000`` into one collision even though those two do
|
|
6
|
+
not overlap each other. A group claimed by one worktree only is no collision.
|
|
7
|
+
|
|
8
|
+
Certainty
|
|
9
|
+
*confirmed* when a running server (a runtime claim) of one worktree certainly holds
|
|
10
|
+
what another worktree's configuration needs; *probable* when the configured
|
|
11
|
+
resources certainly overlap; *possible* when the overlap depends on how a name
|
|
12
|
+
resolves or how a path is used, or when every server involved moves to the next free
|
|
13
|
+
port (Vite without ``strictPort``). Static evidence alone never confirms.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from itertools import combinations
|
|
20
|
+
|
|
21
|
+
from worktree_shield.models import (
|
|
22
|
+
Certainty,
|
|
23
|
+
Collision,
|
|
24
|
+
Observation,
|
|
25
|
+
ResourceClaim,
|
|
26
|
+
ResourceKind,
|
|
27
|
+
)
|
|
28
|
+
from worktree_shield.overlap import Overlap, overlap
|
|
29
|
+
|
|
30
|
+
_KIND_ORDER = {kind: index for index, kind in enumerate(ResourceKind)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_collisions(
|
|
34
|
+
claims: tuple[ResourceClaim, ...] | list[ResourceClaim],
|
|
35
|
+
) -> tuple[Collision, ...]:
|
|
36
|
+
"""Every collision among ``claims``, ordered by kind and key."""
|
|
37
|
+
by_kind: dict[ResourceKind, list[ResourceClaim]] = defaultdict(list)
|
|
38
|
+
for claim in claims:
|
|
39
|
+
if claim.kind is not ResourceKind.CLIENT_ENDPOINT:
|
|
40
|
+
by_kind[claim.kind].append(claim)
|
|
41
|
+
found: list[Collision] = []
|
|
42
|
+
for kind, members in by_kind.items():
|
|
43
|
+
for group in _groups(kind, members):
|
|
44
|
+
if len({claim.worktree for claim in group}) > 1:
|
|
45
|
+
found.append(_collision(kind, group))
|
|
46
|
+
return tuple(sorted(found, key=lambda c: (_KIND_ORDER[c.kind], c.key)))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _groups(kind: ResourceKind, claims: list[ResourceClaim]) -> list[list[ResourceClaim]]:
|
|
50
|
+
"""Claims joined by overlapping keys (connected components over the keys)."""
|
|
51
|
+
keys = sorted({claim.key for claim in claims})
|
|
52
|
+
parent = {key: key for key in keys}
|
|
53
|
+
|
|
54
|
+
def root(key: str) -> str:
|
|
55
|
+
while parent[key] != key:
|
|
56
|
+
parent[key] = parent[parent[key]]
|
|
57
|
+
key = parent[key]
|
|
58
|
+
return key
|
|
59
|
+
|
|
60
|
+
for a, b in combinations(keys, 2):
|
|
61
|
+
if overlap(kind, a, b) is not Overlap.NONE:
|
|
62
|
+
parent[root(a)] = root(b)
|
|
63
|
+
groups: dict[str, list[ResourceClaim]] = defaultdict(list)
|
|
64
|
+
for claim in claims:
|
|
65
|
+
groups[root(claim.key)].append(claim)
|
|
66
|
+
return list(groups.values())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _collision(kind: ResourceKind, claims: list[ResourceClaim]) -> Collision:
|
|
70
|
+
pairs = [
|
|
71
|
+
(a, b, overlap(kind, a.key, b.key))
|
|
72
|
+
for a, b in combinations(claims, 2)
|
|
73
|
+
if a.worktree != b.worktree
|
|
74
|
+
]
|
|
75
|
+
certain = [(a, b) for a, b, result in pairs if result is Overlap.SAME]
|
|
76
|
+
if any(Observation.RUNTIME in (a.observation, b.observation) for a, b in certain):
|
|
77
|
+
certainty = Certainty.CONFIRMED
|
|
78
|
+
elif not certain or (
|
|
79
|
+
kind is ResourceKind.LISTEN_PORT and all(c.strict is False for c in claims)
|
|
80
|
+
):
|
|
81
|
+
certainty = Certainty.POSSIBLE
|
|
82
|
+
else:
|
|
83
|
+
certainty = Certainty.PROBABLE
|
|
84
|
+
key = min(claim.key for claim in claims)
|
|
85
|
+
worktrees = len({claim.worktree for claim in claims})
|
|
86
|
+
return Collision(kind, key, certainty, _reason(kind, key, worktrees, claims), tuple(claims))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _reason(kind: ResourceKind, key: str, worktrees: int, claims: list[ResourceClaim]) -> str:
|
|
90
|
+
name = key.split("://", 1)[-1]
|
|
91
|
+
count = f"{worktrees} worktrees"
|
|
92
|
+
if kind is ResourceKind.LISTEN_PORT:
|
|
93
|
+
port = name.rpartition(":")[2]
|
|
94
|
+
reason = f"{count} start a server on port {port}; only one can listen on it at a time"
|
|
95
|
+
if all(claim.strict is False for claim in claims):
|
|
96
|
+
reason += ", but Vite moves to the next free port unless strictPort is set"
|
|
97
|
+
return reason
|
|
98
|
+
if kind is ResourceKind.DATABASE:
|
|
99
|
+
return (
|
|
100
|
+
f"{count} use the same database; migrations, seed data and tests of one "
|
|
101
|
+
"change the data of the other"
|
|
102
|
+
)
|
|
103
|
+
if kind is ResourceKind.COMPOSE_PROJECT:
|
|
104
|
+
return (
|
|
105
|
+
f"{count} have the Compose project name {name}; docker compose up in one "
|
|
106
|
+
"takes over the containers, networks and volumes of the other"
|
|
107
|
+
)
|
|
108
|
+
if kind is ResourceKind.CONTAINER_NAME:
|
|
109
|
+
return (
|
|
110
|
+
f"{count} name a container {name}; a Docker daemon runs one container of that "
|
|
111
|
+
"name, so starting the second stack replaces or fails on the first"
|
|
112
|
+
)
|
|
113
|
+
if kind is ResourceKind.VOLUME:
|
|
114
|
+
return f"{count} mount the Docker volume {name}, so they read and write the same data"
|
|
115
|
+
return f"{count} write to {name} or a directory inside it"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The repository policy, ``.worktree-shield.toml``: TOML read as data, never run.
|
|
2
|
+
|
|
3
|
+
Precedence is flags, then the file in the current worktree, then the defaults in
|
|
4
|
+
:class:`~worktree_shield.models.Policy`; there is no user-wide policy. Anything the
|
|
5
|
+
parser does not know is an error rather than a silent default, so a misspelt key cannot
|
|
6
|
+
quietly turn a ``deny`` back into a ``warn``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import tomllib
|
|
13
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from worktree_shield.files import RefusedError, read_text_inside
|
|
17
|
+
from worktree_shield.models import Policy, PolicyLevel, Repository
|
|
18
|
+
|
|
19
|
+
POLICY_FILE = ".worktree-shield.toml"
|
|
20
|
+
MAX_POLICY_BYTES = 64 * 1024
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
MAX_FILE_BYTES_LIMIT = 16 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
_SCAN_FLAGS = ("include_runtime", "include_env_files")
|
|
25
|
+
_LEVELS = (
|
|
26
|
+
"shared_host_ports",
|
|
27
|
+
"shared_database",
|
|
28
|
+
"shared_compose_projects",
|
|
29
|
+
"fixed_container_names",
|
|
30
|
+
"shared_absolute_paths",
|
|
31
|
+
)
|
|
32
|
+
_SECTIONS: dict[str, tuple[str, ...]] = {
|
|
33
|
+
"scan": (*_SCAN_FLAGS, "max_file_bytes"),
|
|
34
|
+
"policy": _LEVELS,
|
|
35
|
+
"allow": ("ports", "paths"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PolicyError(Exception):
|
|
40
|
+
"""The policy file cannot be used; the message names the file and the problem."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_policy(text: str, source: str) -> Policy:
|
|
44
|
+
"""The policy in ``text``; ``source`` names the file in messages and in the result."""
|
|
45
|
+
try:
|
|
46
|
+
data = tomllib.loads(text)
|
|
47
|
+
except tomllib.TOMLDecodeError as error:
|
|
48
|
+
raise PolicyError(f"{source} is not valid TOML: {error}") from None
|
|
49
|
+
try:
|
|
50
|
+
return _policy(data, source)
|
|
51
|
+
except _Invalid as error:
|
|
52
|
+
raise PolicyError(f"{source}: {error}") from None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_policy(worktree: Path) -> Policy:
|
|
56
|
+
"""The policy of a worktree, or the defaults when it has no policy file.
|
|
57
|
+
|
|
58
|
+
The file must be a regular file inside the worktree (a link may point elsewhere in
|
|
59
|
+
it) and at most :data:`MAX_POLICY_BYTES` long.
|
|
60
|
+
"""
|
|
61
|
+
path = worktree / POLICY_FILE
|
|
62
|
+
try:
|
|
63
|
+
text = read_text_inside(worktree, POLICY_FILE, MAX_POLICY_BYTES)
|
|
64
|
+
except RefusedError as error:
|
|
65
|
+
raise PolicyError(f"{path} {error}") from None
|
|
66
|
+
if text is None:
|
|
67
|
+
return Policy()
|
|
68
|
+
return parse_policy(text, str(path))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def policy_of(repository: Repository) -> Policy:
|
|
72
|
+
"""The policy of the current worktree; a bare repository has the defaults only."""
|
|
73
|
+
if repository.current is None:
|
|
74
|
+
return Policy()
|
|
75
|
+
return load_policy(Path(repository.current))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def apply_overrides(
|
|
79
|
+
policy: Policy,
|
|
80
|
+
*,
|
|
81
|
+
include_runtime: bool | None = None,
|
|
82
|
+
include_env_files: bool | None = None,
|
|
83
|
+
) -> Policy:
|
|
84
|
+
"""The policy with the settings given on the command line; ``None`` keeps the file's."""
|
|
85
|
+
changes: dict[str, Any] = {}
|
|
86
|
+
if include_runtime is not None:
|
|
87
|
+
changes["include_runtime"] = include_runtime
|
|
88
|
+
if include_env_files is not None:
|
|
89
|
+
changes["include_env_files"] = include_env_files
|
|
90
|
+
return dataclasses.replace(policy, **changes) if changes else policy
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _Invalid(Exception):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _policy(data: dict[str, Any], source: str) -> Policy:
|
|
98
|
+
for key, value in data.items():
|
|
99
|
+
if key == "schema_version":
|
|
100
|
+
continue
|
|
101
|
+
if key not in _SECTIONS:
|
|
102
|
+
unknown = f"section [{key}]" if isinstance(value, dict) else f"key {key}"
|
|
103
|
+
raise _Invalid(f"unknown {unknown}")
|
|
104
|
+
if not isinstance(value, dict):
|
|
105
|
+
raise _Invalid(f"{key} must be a section, written [{key}]")
|
|
106
|
+
for name in value:
|
|
107
|
+
if name not in _SECTIONS[key]:
|
|
108
|
+
raise _Invalid(f"unknown key {key}.{name}")
|
|
109
|
+
_schema_version(data)
|
|
110
|
+
scan = data.get("scan", {})
|
|
111
|
+
levels = data.get("policy", {})
|
|
112
|
+
allow = data.get("allow", {})
|
|
113
|
+
settings: dict[str, Any] = {name: _flag(scan, name) for name in _SCAN_FLAGS if name in scan}
|
|
114
|
+
if "max_file_bytes" in scan:
|
|
115
|
+
settings["max_file_bytes"] = _max_file_bytes(scan["max_file_bytes"])
|
|
116
|
+
settings |= {name: _level(levels, name) for name in _LEVELS if name in levels}
|
|
117
|
+
if "ports" in allow:
|
|
118
|
+
settings["allow_ports"] = _ports(allow["ports"])
|
|
119
|
+
if "paths" in allow:
|
|
120
|
+
settings["allow_paths"] = _paths(allow["paths"])
|
|
121
|
+
return Policy(**settings, source=source)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _schema_version(data: dict[str, Any]) -> None:
|
|
125
|
+
if "schema_version" not in data:
|
|
126
|
+
raise _Invalid(
|
|
127
|
+
f"schema_version is missing; start the file with schema_version = {SCHEMA_VERSION}"
|
|
128
|
+
)
|
|
129
|
+
version = data["schema_version"]
|
|
130
|
+
if not _is_int(version):
|
|
131
|
+
raise _Invalid(f"schema_version must be the number {SCHEMA_VERSION}, not {_shown(version)}")
|
|
132
|
+
if version != SCHEMA_VERSION:
|
|
133
|
+
raise _Invalid(
|
|
134
|
+
f"schema_version {version} is not supported; this version reads {SCHEMA_VERSION}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _flag(section: dict[str, Any], name: str) -> bool:
|
|
139
|
+
value = section[name]
|
|
140
|
+
if not isinstance(value, bool):
|
|
141
|
+
raise _Invalid(f"scan.{name} must be true or false, not {_shown(value)}")
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _max_file_bytes(value: Any) -> int:
|
|
146
|
+
if not _is_int(value) or not 1 <= value <= MAX_FILE_BYTES_LIMIT:
|
|
147
|
+
raise _Invalid(
|
|
148
|
+
f"scan.max_file_bytes must be a whole number from 1 to {MAX_FILE_BYTES_LIMIT}, "
|
|
149
|
+
f"not {_shown(value)}"
|
|
150
|
+
)
|
|
151
|
+
result: int = value
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _level(section: dict[str, Any], name: str) -> PolicyLevel:
|
|
156
|
+
value = section[name]
|
|
157
|
+
allowed = ", ".join(level.value for level in PolicyLevel)
|
|
158
|
+
if not isinstance(value, str) or value not in {level.value for level in PolicyLevel}:
|
|
159
|
+
raise _Invalid(f"policy.{name} must be one of {allowed}, not {_shown(value)}")
|
|
160
|
+
return PolicyLevel(value)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _ports(value: Any) -> tuple[int, ...]:
|
|
164
|
+
if not isinstance(value, list) or not all(_is_int(p) and 1 <= p <= 65535 for p in value):
|
|
165
|
+
raise _Invalid("allow.ports must be port numbers from 1 to 65535 in a list, like [5432]")
|
|
166
|
+
return tuple(sorted(set(value)))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _paths(value: Any) -> tuple[str, ...]:
|
|
170
|
+
if not isinstance(value, list) or not all(_is_absolute(p) for p in value):
|
|
171
|
+
raise _Invalid('allow.paths must be absolute paths in a list, like ["/srv/cache"]')
|
|
172
|
+
return tuple(dict.fromkeys(value))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _is_int(value: Any) -> bool:
|
|
176
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_absolute(value: Any) -> bool:
|
|
180
|
+
if not isinstance(value, str) or any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
181
|
+
return False
|
|
182
|
+
return PurePosixPath(value).is_absolute() or PureWindowsPath(value).is_absolute()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _shown(value: Any) -> str:
|
|
186
|
+
"""A value from the file for a message: repr() escapes control characters."""
|
|
187
|
+
shown = repr(value)
|
|
188
|
+
return shown if len(shown) <= 40 else shown[:39] + "…"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exit codes are a contract: scripts and CI jobs depend on them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExitCode(IntEnum):
|
|
9
|
+
OK = 0
|
|
10
|
+
"""The scan ran. Findings, even failing ones, do not change this."""
|
|
11
|
+
|
|
12
|
+
STRICT_FAILURE = 1
|
|
13
|
+
"""``--strict`` was given and a finding reached the policy's fail level (``deny``)."""
|
|
14
|
+
|
|
15
|
+
ERROR = 2
|
|
16
|
+
"""Wrong usage, or the tool itself failed."""
|
|
17
|
+
|
|
18
|
+
CONTEXT_UNAVAILABLE = 3
|
|
19
|
+
"""Something the command needs is missing, e.g. the directory is not in a Git repository."""
|
|
20
|
+
|
|
21
|
+
INTERRUPTED = 130
|
|
22
|
+
"""Stopped with Ctrl+C (128 + SIGINT, as shells report it)."""
|
worktree_shield/files.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Reading a configuration file of a worktree, and nothing outside it.
|
|
2
|
+
|
|
3
|
+
Every file worktree-shield reads from a repository goes through :func:`read_inside`:
|
|
4
|
+
links may point elsewhere in the worktree but not out of it, only regular files are
|
|
5
|
+
opened (a named pipe would block, a device could be endless), and a file larger than
|
|
6
|
+
the cap is refused rather than read in part, since half a file can mean something else.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_FLAGS = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
16
|
+
_FLAGS |= getattr(os, "O_BINARY", 0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RefusedError(Exception):
|
|
20
|
+
"""The file exists but was not read; ``str()`` completes "<path> …" for a report."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_inside(root: Path, relative: str | Path, max_bytes: int) -> bytes | None:
|
|
24
|
+
"""The bytes of ``root / relative``, or ``None`` when there is no such file.
|
|
25
|
+
|
|
26
|
+
Raises :class:`RefusedError` when the file must not be read: a link out of ``root``,
|
|
27
|
+
not a regular file, larger than ``max_bytes`` or unreadable.
|
|
28
|
+
"""
|
|
29
|
+
path = root / relative
|
|
30
|
+
if not os.path.lexists(path):
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
target = path.resolve(strict=True)
|
|
34
|
+
if not target.is_relative_to(root.resolve(strict=True)):
|
|
35
|
+
raise RefusedError("is a link to a file outside the worktree")
|
|
36
|
+
if not stat.S_ISREG(target.stat().st_mode): # Windows cannot open() a directory
|
|
37
|
+
raise RefusedError("is not a regular file")
|
|
38
|
+
# Checked again on what was opened: a file swapped for a pipe after the check
|
|
39
|
+
# cannot block (O_NONBLOCK) and is refused by fstat().
|
|
40
|
+
fd = os.open(target, _FLAGS)
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
raise RefusedError("could not be read (a link to a file that does not exist)") from None
|
|
43
|
+
except OSError as error:
|
|
44
|
+
raise RefusedError(f"could not be read ({error.strerror or error})") from None
|
|
45
|
+
try:
|
|
46
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
47
|
+
raise RefusedError("is not a regular file")
|
|
48
|
+
chunks: list[bytes] = []
|
|
49
|
+
size = 0
|
|
50
|
+
while size <= max_bytes and (chunk := os.read(fd, max_bytes + 1 - size)):
|
|
51
|
+
chunks.append(chunk)
|
|
52
|
+
size += len(chunk)
|
|
53
|
+
except OSError as error:
|
|
54
|
+
raise RefusedError(f"could not be read ({error.strerror or error})") from None
|
|
55
|
+
finally:
|
|
56
|
+
os.close(fd)
|
|
57
|
+
if size > max_bytes:
|
|
58
|
+
raise RefusedError(f"is larger than {max_bytes} bytes")
|
|
59
|
+
return b"".join(chunks)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_text_inside(root: Path, relative: str | Path, max_bytes: int) -> str | None:
|
|
63
|
+
"""Like :func:`read_inside`, decoded as UTF-8 (a byte order mark is dropped)."""
|
|
64
|
+
raw = read_inside(root, relative, max_bytes)
|
|
65
|
+
if raw is None:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return raw.decode("utf-8-sig")
|
|
69
|
+
except UnicodeDecodeError:
|
|
70
|
+
raise RefusedError("is not UTF-8 text") from None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reading the repository through Git's machine-readable interfaces, never changing it."""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Find the repository and its worktrees, without changing anything in ``.git``.
|
|
2
|
+
|
|
3
|
+
Every git command runs as ``git -c core.fsmonitor=false -C <dir> …`` with
|
|
4
|
+
``GIT_OPTIONAL_LOCKS=0`` (no index refresh, so no ``index.lock``),
|
|
5
|
+
``GIT_TERMINAL_PROMPT=0`` (never wait for credentials) and ``LC_ALL=C``. Disabling the
|
|
6
|
+
fsmonitor stops a repository-configured program from being run. Only ``rev-parse``
|
|
7
|
+
and ``worktree list`` are used; nothing is pruned, repaired or locked.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from worktree_shield.git.porcelain import PorcelainError, parse_worktree_list
|
|
15
|
+
from worktree_shield.models import FailureKind, ProbeFailure, Repository
|
|
16
|
+
from worktree_shield.runner import CommandResult, CommandRunner, probe_failure
|
|
17
|
+
|
|
18
|
+
GIT_TIMEOUT = 10.0
|
|
19
|
+
"""Seconds; worktree listing reads a few small files."""
|
|
20
|
+
|
|
21
|
+
GIT_ENV = {"GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotARepositoryError(Exception):
|
|
25
|
+
"""The directory is not inside a Git repository (exit code 3, not a failure)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def git_argv(directory: Path, *args: str) -> list[str]:
|
|
29
|
+
return ["git", "-c", "core.fsmonitor=false", "-C", str(directory), *args]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _git(runner: CommandRunner, directory: Path, *args: str) -> CommandResult:
|
|
33
|
+
return runner.run(git_argv(directory, *args), timeout=GIT_TIMEOUT, env_overrides=GIT_ENV)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def discover(
|
|
37
|
+
runner: CommandRunner, start: Path
|
|
38
|
+
) -> tuple[Repository | None, tuple[ProbeFailure, ...]]:
|
|
39
|
+
"""The repository containing ``start`` and all its worktrees.
|
|
40
|
+
|
|
41
|
+
Raises :class:`NotARepositoryError` when ``start`` is not in a repository; any
|
|
42
|
+
other problem (git missing, a repository owned by another user, a timeout) is a
|
|
43
|
+
ProbeFailure and no repository.
|
|
44
|
+
"""
|
|
45
|
+
result = _git(
|
|
46
|
+
runner,
|
|
47
|
+
start,
|
|
48
|
+
"rev-parse",
|
|
49
|
+
"--path-format=absolute",
|
|
50
|
+
"--git-common-dir",
|
|
51
|
+
"--is-bare-repository",
|
|
52
|
+
)
|
|
53
|
+
if not result.ok and "not a git repository" in result.stderr:
|
|
54
|
+
raise NotARepositoryError(result.stderr.strip().splitlines()[0])
|
|
55
|
+
failure = probe_failure("git rev-parse", result)
|
|
56
|
+
if failure is not None:
|
|
57
|
+
return None, (failure,)
|
|
58
|
+
lines = result.stdout.splitlines()
|
|
59
|
+
if len(lines) != 2 or lines[1] not in {"true", "false"}:
|
|
60
|
+
return None, (_malformed("git rev-parse", "expected the common directory and true/false"),)
|
|
61
|
+
common_dir, bare = lines[0], lines[1] == "true"
|
|
62
|
+
|
|
63
|
+
current: str | None = None
|
|
64
|
+
if not bare:
|
|
65
|
+
result = _git(runner, start, "rev-parse", "--path-format=absolute", "--show-toplevel")
|
|
66
|
+
failure = probe_failure("git rev-parse --show-toplevel", result)
|
|
67
|
+
if failure is not None:
|
|
68
|
+
return None, (failure,)
|
|
69
|
+
current = result.stdout.strip() or None
|
|
70
|
+
|
|
71
|
+
result = _git(runner, start, "worktree", "list", "--porcelain", "-z")
|
|
72
|
+
failure = probe_failure("git worktree list", result)
|
|
73
|
+
if failure is not None:
|
|
74
|
+
return None, (failure,)
|
|
75
|
+
if result.stdout_truncated:
|
|
76
|
+
return None, (_malformed("git worktree list", "the list was longer than the output cap"),)
|
|
77
|
+
try:
|
|
78
|
+
worktrees = parse_worktree_list(result.stdout)
|
|
79
|
+
except PorcelainError as exc:
|
|
80
|
+
return None, (_malformed("git worktree list", str(exc)),)
|
|
81
|
+
return Repository(common_dir, current, bare, worktrees), ()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _malformed(probe: str, detail: str) -> ProbeFailure:
|
|
85
|
+
return ProbeFailure(probe, FailureKind.MALFORMED_OUTPUT, detail)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Parse ``git worktree list --porcelain -z``.
|
|
2
|
+
|
|
3
|
+
With ``-z`` every attribute ends with NUL and every record with one more NUL, so paths
|
|
4
|
+
with spaces, newlines or any other byte except NUL come through intact. This is the
|
|
5
|
+
format Git documents as stable for scripts; the human-readable list is never parsed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from worktree_shield.models import Worktree
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PorcelainError(ValueError):
|
|
14
|
+
"""The output is not a worktree list."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_worktree_list(text: str) -> tuple[Worktree, ...]:
|
|
18
|
+
"""The worktrees in Git's order: the main worktree first, then the linked ones.
|
|
19
|
+
|
|
20
|
+
Unknown attributes are skipped, so a newer Git that adds one does not break the
|
|
21
|
+
scan. A record without a ``worktree`` line is an error.
|
|
22
|
+
"""
|
|
23
|
+
worktrees: list[Worktree] = []
|
|
24
|
+
for record in text.split("\0\0"):
|
|
25
|
+
attributes = [item for item in record.split("\0") if item]
|
|
26
|
+
if not attributes:
|
|
27
|
+
continue
|
|
28
|
+
name, _, path = attributes[0].partition(" ")
|
|
29
|
+
if name != "worktree" or not path:
|
|
30
|
+
raise PorcelainError(f"a record starts with {name!r}, not 'worktree <path>'")
|
|
31
|
+
head = branch = locked = prunable = None
|
|
32
|
+
bare = detached = False
|
|
33
|
+
for attribute in attributes[1:]:
|
|
34
|
+
key, space, value = attribute.partition(" ")
|
|
35
|
+
if key == "HEAD":
|
|
36
|
+
head = value or None
|
|
37
|
+
elif key == "branch":
|
|
38
|
+
branch = value or None
|
|
39
|
+
elif key == "bare":
|
|
40
|
+
bare = True
|
|
41
|
+
elif key == "detached":
|
|
42
|
+
detached = True
|
|
43
|
+
elif key == "locked":
|
|
44
|
+
locked = value if space else ""
|
|
45
|
+
elif key == "prunable":
|
|
46
|
+
prunable = value if space else ""
|
|
47
|
+
worktrees.append(Worktree(path, head, branch, bare, detached, locked, prunable))
|
|
48
|
+
return tuple(worktrees)
|