autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/materialize.py
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"""Byte-exact materialization of a REMOTE candidate, with a hash-back proof (#171).
|
|
2
|
+
|
|
3
|
+
A candidate is a commit id. :func:`materialize` writes the tree that commit
|
|
4
|
+
names into a fresh private directory and proves, before handing the
|
|
5
|
+
directory out, that what is on disk hashes back to that tree (ADR 0006
|
|
6
|
+
§2.6). The merge gate's export (:func:`autoforge.premerge.export_commit_tree`)
|
|
7
|
+
uses it, and candidate validation (#172) will.
|
|
8
|
+
|
|
9
|
+
- **Source (D6.1).** The commit, and every blob its tree names, must be in
|
|
10
|
+
the store the controller's own git reads: the operator's shared object
|
|
11
|
+
store, never an issue's private object directory, whose objects reach the
|
|
12
|
+
shared store only through the strict import. A missing commit or blob is
|
|
13
|
+
:class:`~autoforge.errors.CandidateNotMaterializableError`. The tree id is
|
|
14
|
+
read from the commit itself.
|
|
15
|
+
- **Writer (D6.2).** ``git ls-tree -r -t -z --full-tree`` lists the tree;
|
|
16
|
+
the blobs are streamed with ``git cat-file --batch``. Nothing else of git
|
|
17
|
+
takes part: no index, no ``checkout-index``, no attribute, eol conversion,
|
|
18
|
+
``ident``, filter driver, ``core.autocrlf``, ``info/attributes`` or
|
|
19
|
+
``core.attributesFile``, so the bytes on disk are the committed bytes and
|
|
20
|
+
no program the operator configured runs on candidate content. Names stay
|
|
21
|
+
bytes; blob content reaches the disk as the executor reads it, through
|
|
22
|
+
:attr:`~autoforge.executor.ExecutionRequest.on_chunk`, so it is never
|
|
23
|
+
decoded, never cut at a capture bound and never held whole in memory, and
|
|
24
|
+
the git process stays the executor's (ADR 0002). Every entry is created
|
|
25
|
+
exclusively, relative to the descriptor of the directory created for its
|
|
26
|
+
parent tree, never through a link: a directory for a tree, a ``0644`` or
|
|
27
|
+
``0755`` file, a symlink holding its link text, and an empty directory
|
|
28
|
+
for a gitlink (counted; the submodule commit is never read). There is no
|
|
29
|
+
``.git``. A name that is ``.``, ``..`` or ``.git`` in any case, empty, or
|
|
30
|
+
not below a tree the listing holds (a name carrying ``/``) is refused
|
|
31
|
+
before anything is written.
|
|
32
|
+
- **Proof (D6.3).** The controller's own walk, with no git process: every
|
|
33
|
+
entry is ``lstat``-ed relative to its directory's descriptor, never
|
|
34
|
+
followed; blob ids are recomputed from the bytes on disk (a symlink's
|
|
35
|
+
from its link text), the mode from the file type and the owner's execute
|
|
36
|
+
bit, and tree ids bottom up in git's entry order, in the repository's
|
|
37
|
+
object format. A gitlink path must be an empty directory, and its entry
|
|
38
|
+
is the one the listing recorded. Every directory must hash to the tree
|
|
39
|
+
the listing names for it and the root to the commit's tree, with no entry
|
|
40
|
+
extra or missing. A mismatch is a
|
|
41
|
+
:class:`~autoforge.errors.MaterializationError` naming the entry.
|
|
42
|
+
|
|
43
|
+
Every git process is the hardened local request
|
|
44
|
+
(:func:`~autoforge.git_transport.local_git_request`, ADR 0004 D7.3, D7.5):
|
|
45
|
+
no replacement objects, no commit-graph, no hooks, an allow-listed
|
|
46
|
+
environment. The directory is removed when the context exits, whatever
|
|
47
|
+
happened; a killed process leaves only that directory, which is never read
|
|
48
|
+
again.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import hashlib
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import shutil
|
|
57
|
+
import stat
|
|
58
|
+
import tempfile
|
|
59
|
+
import threading
|
|
60
|
+
from collections.abc import Callable, Iterator
|
|
61
|
+
from contextlib import contextmanager
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
from .errors import CandidateNotMaterializableError, MaterializationError
|
|
66
|
+
from .executor import ExecutionRequest, ExecutionResult
|
|
67
|
+
from .git_transport import local_git_request
|
|
68
|
+
from .redaction import redact
|
|
69
|
+
|
|
70
|
+
Runner = Callable[[ExecutionRequest], ExecutionResult]
|
|
71
|
+
|
|
72
|
+
# The method identifier a validation policy digest names (ADR 0006 D8.1).
|
|
73
|
+
METHOD = "byte-exact-v1"
|
|
74
|
+
|
|
75
|
+
# Listing and streaming a large tree are bounded by time, as the export was.
|
|
76
|
+
_GIT_TIMEOUT_SECONDS = 600
|
|
77
|
+
# What the executor keeps of a streamed process's output: the stream goes
|
|
78
|
+
# to disk through ``on_chunk``, so the capture serves only the error tail.
|
|
79
|
+
_STREAM_CAPTURE_BYTES = 64 * 1024
|
|
80
|
+
# A ``cat-file --batch`` header is ``<id> <type> <size>``.
|
|
81
|
+
_MAX_HEADER_BYTES = 256
|
|
82
|
+
# A symlink's target is held in memory until it is created; the kernel
|
|
83
|
+
# refuses one of PATH_MAX or more anyway.
|
|
84
|
+
_MAX_LINK_BYTES = 4096
|
|
85
|
+
_READ_CHUNK_BYTES = 1024 * 1024
|
|
86
|
+
|
|
87
|
+
_HEX_LENGTH = {"sha1": 40, "sha256": 64}
|
|
88
|
+
_HEX_RE = re.compile(rb"[0-9a-f]+")
|
|
89
|
+
|
|
90
|
+
# Modes as a tree object writes them, and the type ``ls-tree`` prints.
|
|
91
|
+
_FILE = b"100644"
|
|
92
|
+
_EXECUTABLE = b"100755"
|
|
93
|
+
_SYMLINK = b"120000"
|
|
94
|
+
_TREE = b"40000"
|
|
95
|
+
_GITLINK = b"160000"
|
|
96
|
+
_TYPES = {
|
|
97
|
+
_FILE: b"blob",
|
|
98
|
+
_EXECUTABLE: b"blob",
|
|
99
|
+
_SYMLINK: b"blob",
|
|
100
|
+
_TREE: b"tree",
|
|
101
|
+
_GITLINK: b"commit",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
105
|
+
_NEW_FILE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
106
|
+
# Non-blocking, so a FIFO swapped in after the ``lstat`` cannot stall the proof.
|
|
107
|
+
_READ_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class MaterializedTree:
|
|
112
|
+
"""A candidate written out under ``root`` and proven to be its tree.
|
|
113
|
+
|
|
114
|
+
``files`` counts the blobs written (regular files and symlinks);
|
|
115
|
+
``gitlinks`` the unpopulated submodule directories. ``root`` is removed
|
|
116
|
+
when the context that produced it exits.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
commit: str
|
|
120
|
+
tree: str
|
|
121
|
+
object_format: str
|
|
122
|
+
root: Path
|
|
123
|
+
files: int
|
|
124
|
+
gitlinks: int
|
|
125
|
+
method: str = METHOD
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class _Entry:
|
|
130
|
+
"""One ``ls-tree`` record: ``path`` is ``parent`` and ``name`` joined by ``/``."""
|
|
131
|
+
|
|
132
|
+
path: bytes
|
|
133
|
+
parent: bytes
|
|
134
|
+
name: bytes
|
|
135
|
+
mode: bytes
|
|
136
|
+
oid: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class _Listing:
|
|
141
|
+
"""A tree's entries in ``ls-tree`` order: every tree before what it holds."""
|
|
142
|
+
|
|
143
|
+
tree: str
|
|
144
|
+
entries: tuple[_Entry, ...]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _shown(path: bytes) -> str:
|
|
148
|
+
"""``path`` for a message: escaped, bounded and redacted, never decoded raw."""
|
|
149
|
+
text = path.decode("utf-8", "backslashreplace")
|
|
150
|
+
text = "".join(c if c.isprintable() else f"\\x{ord(c):02x}" for c in text)
|
|
151
|
+
if len(text) > 200:
|
|
152
|
+
text = text[:200] + "..."
|
|
153
|
+
return redact(text) or "(root)"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _request(repo: str, args: list[str]) -> ExecutionRequest:
|
|
157
|
+
return local_git_request(["-C", repo, *args], timeout_seconds=_GIT_TIMEOUT_SECONDS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _failure(res: ExecutionResult, args: list[str]) -> str:
|
|
161
|
+
"""Why a git process did not answer, or "" when it did (exit 0, whole)."""
|
|
162
|
+
shown = " ".join(["git", *args])
|
|
163
|
+
if res.timed_out:
|
|
164
|
+
return f"`{shown}` timed out after {_GIT_TIMEOUT_SECONDS}s"
|
|
165
|
+
if res.exit_code != 0:
|
|
166
|
+
tail = redact((res.stderr or res.stdout or "").strip()[-500:])
|
|
167
|
+
return f"`{shown}` failed (exit {res.exit_code}): {tail}"
|
|
168
|
+
if res.capture_abandoned:
|
|
169
|
+
return f"`{shown}` left its output open past its exit"
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _git(runner: Runner, repo: str, args: list[str], stdin: bytes | None = None) -> str:
|
|
174
|
+
"""One git plumbing command whose whole, short output is the answer."""
|
|
175
|
+
req = _request(repo, args)
|
|
176
|
+
req.stdin_data = stdin
|
|
177
|
+
res = runner(req)
|
|
178
|
+
problem = _failure(res, args)
|
|
179
|
+
if not problem and res.stdout_truncated:
|
|
180
|
+
problem = f"`git {' '.join(args)}` output was truncated at the executor's capture bound"
|
|
181
|
+
if problem:
|
|
182
|
+
raise MaterializationError(problem)
|
|
183
|
+
return res.stdout or ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def read_object_format(runner: Runner, repo: str) -> str:
|
|
187
|
+
"""The object format of ``repo``'s store: ``sha1`` or ``sha256``."""
|
|
188
|
+
found = _git(runner, repo, ["rev-parse", "--show-object-format"]).strip()
|
|
189
|
+
if found not in _HEX_LENGTH:
|
|
190
|
+
raise MaterializationError(f"{repo} uses the object format {found!r}, which is not known")
|
|
191
|
+
return found
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_oid(value: bytes, object_format: str) -> bool:
|
|
195
|
+
return len(value) == _HEX_LENGTH[object_format] and _HEX_RE.fullmatch(value) is not None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _commit_tree(runner: Runner, repo: str, commit: str, object_format: str) -> str:
|
|
199
|
+
"""The tree ``commit`` names, after it is found to be a commit in the store."""
|
|
200
|
+
if object_format not in _HEX_LENGTH:
|
|
201
|
+
raise MaterializationError(f"unknown object format {object_format!r}")
|
|
202
|
+
if not _is_oid(commit.encode("ascii", "replace"), object_format):
|
|
203
|
+
raise CandidateNotMaterializableError(
|
|
204
|
+
f"{commit!r} is not a full {object_format} commit id of {repo}"
|
|
205
|
+
)
|
|
206
|
+
answer = _git(runner, repo, ["cat-file", "--batch-check"], f"{commit}\n".encode()).split()
|
|
207
|
+
if answer == [commit, "missing"]:
|
|
208
|
+
raise CandidateNotMaterializableError(
|
|
209
|
+
f"commit {commit} is not in the shared object store of {repo}"
|
|
210
|
+
)
|
|
211
|
+
if len(answer) != 3 or answer[0] != commit or answer[1] != "commit":
|
|
212
|
+
kind = answer[1] if len(answer) == 3 and answer[0] == commit else "unreadable"
|
|
213
|
+
raise CandidateNotMaterializableError(
|
|
214
|
+
f"{commit} in {repo} is a {kind} object, not a commit"
|
|
215
|
+
)
|
|
216
|
+
tree = _git(runner, repo, ["rev-parse", "--verify", "--quiet", f"{commit}^{{tree}}"]).strip()
|
|
217
|
+
if not _is_oid(tree.encode("ascii", "replace"), object_format):
|
|
218
|
+
raise MaterializationError(f"commit {commit} names no readable tree: {tree[:100]!r}")
|
|
219
|
+
return tree
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class _Collector:
|
|
223
|
+
"""An ``on_chunk`` hook keeping a process's stdout bytes as they were written."""
|
|
224
|
+
|
|
225
|
+
def __init__(self) -> None:
|
|
226
|
+
self.data = bytearray()
|
|
227
|
+
|
|
228
|
+
def __call__(self, stream: str, chunk: bytes) -> None:
|
|
229
|
+
if stream == "stdout":
|
|
230
|
+
self.data += chunk
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _check_name(name: bytes, path: bytes, commit: str) -> None:
|
|
234
|
+
"""Refuse a name no tree entry may carry into the export (D6.2)."""
|
|
235
|
+
problem = ""
|
|
236
|
+
if not name:
|
|
237
|
+
problem = "an empty name"
|
|
238
|
+
elif name in (b".", b".."):
|
|
239
|
+
problem = f"the name {name.decode()!r}"
|
|
240
|
+
elif name.lower() == b".git":
|
|
241
|
+
problem = "a .git entry"
|
|
242
|
+
elif b"\0" in name:
|
|
243
|
+
problem = "a NUL in its name"
|
|
244
|
+
if problem:
|
|
245
|
+
raise MaterializationError(
|
|
246
|
+
f"commit {commit}: the entry {_shown(path)} is refused: {problem}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _list_tree(runner: Runner, repo: str, commit: str, tree: str, object_format: str) -> _Listing:
|
|
251
|
+
"""Every entry of ``tree``, validated before anything is written."""
|
|
252
|
+
args = ["ls-tree", "-r", "-t", "-z", "--full-tree", tree]
|
|
253
|
+
collector = _Collector()
|
|
254
|
+
req = _request(repo, args)
|
|
255
|
+
req.on_chunk = collector
|
|
256
|
+
req.max_output_bytes = _STREAM_CAPTURE_BYTES
|
|
257
|
+
problem = _failure(runner(req), args)
|
|
258
|
+
if problem:
|
|
259
|
+
raise MaterializationError(f"commit {commit}: listing its tree failed: {problem}")
|
|
260
|
+
raw = bytes(collector.data)
|
|
261
|
+
if raw and not raw.endswith(b"\0"):
|
|
262
|
+
raise MaterializationError(f"commit {commit}: the listing of {tree} ends mid-record")
|
|
263
|
+
trees: set[bytes] = {b""}
|
|
264
|
+
entries: list[_Entry] = []
|
|
265
|
+
seen: set[bytes] = set()
|
|
266
|
+
for record in raw.split(b"\0")[:-1]:
|
|
267
|
+
meta, tab, path = record.partition(b"\t")
|
|
268
|
+
fields = meta.split(b" ")
|
|
269
|
+
if not tab or len(fields) != 3:
|
|
270
|
+
raise MaterializationError(
|
|
271
|
+
f"commit {commit}: unreadable listing record {_shown(record)}"
|
|
272
|
+
)
|
|
273
|
+
mode, kind, oid = fields
|
|
274
|
+
if mode == b"040000":
|
|
275
|
+
mode = _TREE
|
|
276
|
+
if _TYPES.get(mode) != kind or not _is_oid(oid, object_format):
|
|
277
|
+
raise MaterializationError(
|
|
278
|
+
f"commit {commit}: the entry {_shown(path)} has the unsupported mode and type "
|
|
279
|
+
f"{_shown(mode)} {_shown(kind)}"
|
|
280
|
+
)
|
|
281
|
+
parent, slash, name = path.rpartition(b"/")
|
|
282
|
+
_check_name(name, path, commit)
|
|
283
|
+
if parent not in trees or (slash and not parent):
|
|
284
|
+
# ``ls-tree`` joins names with ``/``: a path whose parent was not
|
|
285
|
+
# listed as a tree, or that starts with ``/``, came from a name
|
|
286
|
+
# holding ``/`` (a tree below a gitlink is never listed).
|
|
287
|
+
raise MaterializationError(
|
|
288
|
+
f"commit {commit}: the entry {_shown(path)} is refused: it is not below a tree "
|
|
289
|
+
"of the listing (a name holding '/')"
|
|
290
|
+
)
|
|
291
|
+
if path in seen:
|
|
292
|
+
raise MaterializationError(f"commit {commit}: the entry {_shown(path)} is listed twice")
|
|
293
|
+
seen.add(path)
|
|
294
|
+
if mode == _TREE:
|
|
295
|
+
trees.add(path)
|
|
296
|
+
entries.append(_Entry(path, parent, name, mode, oid.decode("ascii")))
|
|
297
|
+
return _Listing(tree=tree, entries=tuple(entries))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class _Writer:
|
|
301
|
+
"""Creates a listing's entries below ``root``, in listing order.
|
|
302
|
+
|
|
303
|
+
Trees and gitlinks are created as they are reached; a blob is created
|
|
304
|
+
when its content arrives (:meth:`open_blob`, :meth:`write`,
|
|
305
|
+
:meth:`close_blob`). The descriptors of the directories on the current
|
|
306
|
+
path are held open, so every entry is created relative to the one made
|
|
307
|
+
for its parent, never through a path a link could redirect.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def __init__(self, root: Path, listing: _Listing, commit: str) -> None:
|
|
311
|
+
self._entries = listing.entries
|
|
312
|
+
self._commit = commit
|
|
313
|
+
self._next = 0
|
|
314
|
+
self._stack: list[tuple[bytes, int]] = [(b"", os.open(root, _DIR_FLAGS))]
|
|
315
|
+
self._blob: _Entry | None = None
|
|
316
|
+
self._fd: int | None = None
|
|
317
|
+
self._link: bytearray | None = None
|
|
318
|
+
|
|
319
|
+
def _fail(self, entry: _Entry, what: str, exc: OSError | None = None) -> MaterializationError:
|
|
320
|
+
reason = f": {exc.strerror or exc}" if exc is not None else ""
|
|
321
|
+
return MaterializationError(
|
|
322
|
+
f"commit {self._commit}: {what} {_shown(entry.path)} failed{reason}"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _parent_fd(self, entry: _Entry) -> int:
|
|
326
|
+
while self._stack and self._stack[-1][0] != entry.parent:
|
|
327
|
+
os.close(self._stack.pop()[1])
|
|
328
|
+
if not self._stack:
|
|
329
|
+
raise MaterializationError(
|
|
330
|
+
f"commit {self._commit}: the entry {_shown(entry.path)} is listed after its "
|
|
331
|
+
"parent tree was complete"
|
|
332
|
+
)
|
|
333
|
+
return self._stack[-1][1]
|
|
334
|
+
|
|
335
|
+
def _directory(self, entry: _Entry) -> None:
|
|
336
|
+
parent = self._parent_fd(entry)
|
|
337
|
+
try:
|
|
338
|
+
os.mkdir(entry.name, 0o755, dir_fd=parent)
|
|
339
|
+
fd = os.open(entry.name, _DIR_FLAGS, dir_fd=parent)
|
|
340
|
+
except OSError as exc:
|
|
341
|
+
raise self._fail(entry, "creating the directory", exc) from exc
|
|
342
|
+
try:
|
|
343
|
+
os.fchmod(fd, 0o755)
|
|
344
|
+
except OSError as exc:
|
|
345
|
+
os.close(fd)
|
|
346
|
+
raise self._fail(entry, "creating the directory", exc) from exc
|
|
347
|
+
if entry.mode == _TREE:
|
|
348
|
+
self._stack.append((entry.path, fd))
|
|
349
|
+
else:
|
|
350
|
+
os.close(fd)
|
|
351
|
+
|
|
352
|
+
def next_blob(self) -> _Entry | None:
|
|
353
|
+
"""Create every tree and gitlink up to the next blob, and return that blob."""
|
|
354
|
+
while self._next < len(self._entries):
|
|
355
|
+
entry = self._entries[self._next]
|
|
356
|
+
self._next += 1
|
|
357
|
+
if entry.mode in (_TREE, _GITLINK):
|
|
358
|
+
self._directory(entry)
|
|
359
|
+
else:
|
|
360
|
+
return entry
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
def open_blob(self, entry: _Entry) -> None:
|
|
364
|
+
parent = self._parent_fd(entry)
|
|
365
|
+
self._blob = entry
|
|
366
|
+
if entry.mode == _SYMLINK:
|
|
367
|
+
self._link = bytearray()
|
|
368
|
+
return
|
|
369
|
+
try:
|
|
370
|
+
self._fd = os.open(entry.name, _NEW_FILE_FLAGS, 0o600, dir_fd=parent)
|
|
371
|
+
except OSError as exc:
|
|
372
|
+
raise self._fail(entry, "creating the file", exc) from exc
|
|
373
|
+
|
|
374
|
+
def write(self, data: memoryview) -> None:
|
|
375
|
+
assert self._blob is not None
|
|
376
|
+
if self._link is not None:
|
|
377
|
+
self._link += data
|
|
378
|
+
if len(self._link) > _MAX_LINK_BYTES:
|
|
379
|
+
raise MaterializationError(
|
|
380
|
+
f"commit {self._commit}: the symlink {_shown(self._blob.path)} holds more "
|
|
381
|
+
f"than {_MAX_LINK_BYTES} bytes of link text"
|
|
382
|
+
)
|
|
383
|
+
return
|
|
384
|
+
assert self._fd is not None
|
|
385
|
+
try:
|
|
386
|
+
while data:
|
|
387
|
+
written = os.write(self._fd, data)
|
|
388
|
+
data = data[written:]
|
|
389
|
+
except OSError as exc:
|
|
390
|
+
raise self._fail(self._blob, "writing", exc) from exc
|
|
391
|
+
|
|
392
|
+
def close_blob(self) -> None:
|
|
393
|
+
entry = self._blob
|
|
394
|
+
assert entry is not None
|
|
395
|
+
try:
|
|
396
|
+
if self._link is not None:
|
|
397
|
+
os.symlink(bytes(self._link), entry.name, dir_fd=self._parent_fd(entry))
|
|
398
|
+
else:
|
|
399
|
+
assert self._fd is not None
|
|
400
|
+
os.fchmod(self._fd, 0o755 if entry.mode == _EXECUTABLE else 0o644)
|
|
401
|
+
except OSError as exc:
|
|
402
|
+
raise self._fail(entry, "creating", exc) from exc
|
|
403
|
+
finally:
|
|
404
|
+
self._close_file()
|
|
405
|
+
self._link = None
|
|
406
|
+
self._blob = None
|
|
407
|
+
|
|
408
|
+
def _close_file(self) -> None:
|
|
409
|
+
if self._fd is not None:
|
|
410
|
+
fd, self._fd = self._fd, None
|
|
411
|
+
os.close(fd)
|
|
412
|
+
|
|
413
|
+
def finish(self) -> None:
|
|
414
|
+
"""Create what follows the last blob; every entry must have been reached."""
|
|
415
|
+
if self.next_blob() is not None:
|
|
416
|
+
raise MaterializationError(
|
|
417
|
+
f"commit {self._commit}: git sent fewer blobs than the tree lists"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def close(self) -> None:
|
|
421
|
+
self._close_file()
|
|
422
|
+
while self._stack:
|
|
423
|
+
os.close(self._stack.pop()[1])
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class _BlobStream:
|
|
427
|
+
"""An ``on_chunk`` hook parsing ``git cat-file --batch`` into the writer.
|
|
428
|
+
|
|
429
|
+
Each blob arrives as ``<id> blob <size>\\n<content>\\n``, in the order
|
|
430
|
+
the ids were asked for. The first problem is kept and ``stop`` is set,
|
|
431
|
+
so the executor ends the process; nothing is read after it, or after
|
|
432
|
+
:meth:`close`.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
def __init__(self, writer: _Writer, commit: str) -> None:
|
|
436
|
+
self._writer = writer
|
|
437
|
+
self._commit = commit
|
|
438
|
+
self._header = bytearray()
|
|
439
|
+
self._blob: _Entry | None = None
|
|
440
|
+
self._remaining = 0
|
|
441
|
+
self._trailer = False
|
|
442
|
+
self._lock = threading.Lock()
|
|
443
|
+
self._closed = False
|
|
444
|
+
self.error: Exception | None = None
|
|
445
|
+
self.stop = threading.Event()
|
|
446
|
+
self.done = False
|
|
447
|
+
self._expect()
|
|
448
|
+
|
|
449
|
+
def _expect(self) -> None:
|
|
450
|
+
self._blob = self._writer.next_blob()
|
|
451
|
+
self.done = self._blob is None
|
|
452
|
+
|
|
453
|
+
def __call__(self, stream: str, chunk: bytes) -> None:
|
|
454
|
+
if stream != "stdout":
|
|
455
|
+
return
|
|
456
|
+
with self._lock:
|
|
457
|
+
if self._closed or self.error is not None:
|
|
458
|
+
return
|
|
459
|
+
try:
|
|
460
|
+
self._feed(memoryview(chunk))
|
|
461
|
+
except (MaterializationError, CandidateNotMaterializableError, OSError) as exc:
|
|
462
|
+
self.error = exc
|
|
463
|
+
self.stop.set()
|
|
464
|
+
|
|
465
|
+
def _feed(self, chunk: memoryview) -> None:
|
|
466
|
+
while chunk:
|
|
467
|
+
if self.done:
|
|
468
|
+
raise MaterializationError(
|
|
469
|
+
f"commit {self._commit}: git sent more blobs than the tree lists"
|
|
470
|
+
)
|
|
471
|
+
if self._trailer:
|
|
472
|
+
if chunk[0] != 0x0A:
|
|
473
|
+
raise MaterializationError(
|
|
474
|
+
f"commit {self._commit}: unexpected git output after a blob's content"
|
|
475
|
+
)
|
|
476
|
+
chunk = chunk[1:]
|
|
477
|
+
self._trailer = False
|
|
478
|
+
self._writer.close_blob()
|
|
479
|
+
self._expect()
|
|
480
|
+
elif self._remaining:
|
|
481
|
+
part = chunk[: self._remaining]
|
|
482
|
+
self._writer.write(part)
|
|
483
|
+
self._remaining -= len(part)
|
|
484
|
+
chunk = chunk[len(part) :]
|
|
485
|
+
self._trailer = not self._remaining
|
|
486
|
+
else:
|
|
487
|
+
room = _MAX_HEADER_BYTES - len(self._header)
|
|
488
|
+
end = bytes(chunk[: room + 1]).find(b"\n")
|
|
489
|
+
if end < 0:
|
|
490
|
+
if len(chunk) > room:
|
|
491
|
+
raise MaterializationError(
|
|
492
|
+
f"commit {self._commit}: an unreadable `git cat-file` header"
|
|
493
|
+
)
|
|
494
|
+
self._header += chunk
|
|
495
|
+
return
|
|
496
|
+
self._header += chunk[:end]
|
|
497
|
+
chunk = chunk[end + 1 :]
|
|
498
|
+
header, self._header = bytes(self._header), bytearray()
|
|
499
|
+
self._start(header)
|
|
500
|
+
|
|
501
|
+
def _start(self, header: bytes) -> None:
|
|
502
|
+
blob = self._blob
|
|
503
|
+
assert blob is not None
|
|
504
|
+
oid = blob.oid.encode("ascii")
|
|
505
|
+
fields = header.split(b" ")
|
|
506
|
+
if fields == [oid, b"missing"]:
|
|
507
|
+
raise CandidateNotMaterializableError(
|
|
508
|
+
f"commit {self._commit}: the blob {blob.oid} of {_shown(blob.path)} is not in "
|
|
509
|
+
"the shared object store"
|
|
510
|
+
)
|
|
511
|
+
if len(fields) != 3 or fields[0] != oid or fields[1] != b"blob" or not fields[2].isdigit():
|
|
512
|
+
raise MaterializationError(
|
|
513
|
+
f"commit {self._commit}: git answered {_shown(header)} for the blob of "
|
|
514
|
+
f"{_shown(blob.path)}"
|
|
515
|
+
)
|
|
516
|
+
self._writer.open_blob(blob)
|
|
517
|
+
self._remaining = int(fields[2])
|
|
518
|
+
self._trailer = not self._remaining
|
|
519
|
+
|
|
520
|
+
def close(self) -> None:
|
|
521
|
+
with self._lock:
|
|
522
|
+
self._closed = True
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _write(runner: Runner, repo: str, commit: str, listing: _Listing, root: Path) -> None:
|
|
526
|
+
"""Write ``listing`` below ``root``, the blobs streamed from the store."""
|
|
527
|
+
writer = _Writer(root, listing, commit)
|
|
528
|
+
try:
|
|
529
|
+
stream = _BlobStream(writer, commit)
|
|
530
|
+
blobs = [e.oid for e in listing.entries if e.mode not in (_TREE, _GITLINK)]
|
|
531
|
+
if blobs:
|
|
532
|
+
args = ["cat-file", "--batch", "--buffer"]
|
|
533
|
+
req = _request(repo, args)
|
|
534
|
+
req.stdin_data = "".join(f"{oid}\n" for oid in blobs).encode("ascii")
|
|
535
|
+
req.on_chunk = stream
|
|
536
|
+
req.stop = stream.stop
|
|
537
|
+
req.max_output_bytes = _STREAM_CAPTURE_BYTES
|
|
538
|
+
try:
|
|
539
|
+
res = runner(req)
|
|
540
|
+
finally:
|
|
541
|
+
stream.close()
|
|
542
|
+
if stream.error is not None:
|
|
543
|
+
raise stream.error
|
|
544
|
+
problem = _failure(res, args)
|
|
545
|
+
if problem:
|
|
546
|
+
raise MaterializationError(
|
|
547
|
+
f"commit {commit}: streaming its blobs failed: {problem}"
|
|
548
|
+
)
|
|
549
|
+
if not stream.done:
|
|
550
|
+
raise MaterializationError(
|
|
551
|
+
f"commit {commit}: git sent fewer blobs than the tree lists"
|
|
552
|
+
)
|
|
553
|
+
writer.finish()
|
|
554
|
+
finally:
|
|
555
|
+
writer.close()
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _blob_id(algorithm: str, content: bytes) -> str:
|
|
559
|
+
return hashlib.new(algorithm, b"blob %d\0" % len(content) + content).hexdigest()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _file_id(algorithm: str, dir_fd: int, name: bytes, seen: os.stat_result) -> tuple[bytes, str]:
|
|
563
|
+
"""The mode and blob id of the regular file ``name``, from its bytes on disk."""
|
|
564
|
+
fd = os.open(name, _READ_FLAGS, dir_fd=dir_fd)
|
|
565
|
+
try:
|
|
566
|
+
st = os.fstat(fd)
|
|
567
|
+
if not stat.S_ISREG(st.st_mode) or (st.st_dev, st.st_ino) != (seen.st_dev, seen.st_ino):
|
|
568
|
+
raise OSError(0, "replaced while it was proved")
|
|
569
|
+
digest = hashlib.new(algorithm, b"blob %d\0" % st.st_size)
|
|
570
|
+
total = 0
|
|
571
|
+
while chunk := os.read(fd, _READ_CHUNK_BYTES):
|
|
572
|
+
digest.update(chunk)
|
|
573
|
+
total += len(chunk)
|
|
574
|
+
if total != st.st_size:
|
|
575
|
+
raise OSError(0, "changed size while it was proved")
|
|
576
|
+
finally:
|
|
577
|
+
os.close(fd)
|
|
578
|
+
return (_EXECUTABLE if st.st_mode & stat.S_IXUSR else _FILE), digest.hexdigest()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _tree_id(algorithm: str, entries: list[tuple[bytes, bytes, str]]) -> str:
|
|
582
|
+
"""The id of a tree holding ``entries`` (name, mode, id), in git's order.
|
|
583
|
+
|
|
584
|
+
A tree entry sorts as its name followed by ``/``; every other entry,
|
|
585
|
+
a gitlink included, as its name.
|
|
586
|
+
"""
|
|
587
|
+
entries.sort(key=lambda e: e[0] + b"/" if e[1] == _TREE else e[0])
|
|
588
|
+
body = b"".join(mode + b" " + name + b"\0" + bytes.fromhex(oid) for name, mode, oid in entries)
|
|
589
|
+
return hashlib.new(algorithm, b"tree %d\0" % len(body) + body).hexdigest()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@dataclass
|
|
593
|
+
class _Frame:
|
|
594
|
+
"""One directory of the proof's walk: its names still to visit, its entries so far."""
|
|
595
|
+
|
|
596
|
+
path: bytes
|
|
597
|
+
fd: int
|
|
598
|
+
pending: list[bytes]
|
|
599
|
+
entries: list[tuple[bytes, bytes, str]]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _join(parent: bytes, name: bytes) -> bytes:
|
|
603
|
+
return parent + b"/" + name if parent else name
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _prove(root: Path, listing: _Listing, commit: str, object_format: str) -> None:
|
|
607
|
+
"""Prove that ``root`` holds exactly ``listing``'s tree (D6.3), or raise."""
|
|
608
|
+
expected = {listed.path: listed for listed in listing.entries}
|
|
609
|
+
children: dict[bytes, set[bytes]] = {b"": set()}
|
|
610
|
+
for listed in listing.entries:
|
|
611
|
+
children.setdefault(listed.parent, set()).add(listed.name)
|
|
612
|
+
if listed.mode == _TREE:
|
|
613
|
+
children.setdefault(listed.path, set())
|
|
614
|
+
|
|
615
|
+
def fail(path: bytes, what: str) -> MaterializationError:
|
|
616
|
+
return MaterializationError(
|
|
617
|
+
f"commit {commit}: the export does not hash back to its tree {listing.tree}: "
|
|
618
|
+
f"{_shown(path)} {what}"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
frames: list[_Frame] = []
|
|
622
|
+
|
|
623
|
+
def enter(path: bytes, fd: int) -> None:
|
|
624
|
+
# Held by a frame at once, so the ``finally`` below closes it.
|
|
625
|
+
frames.append(_Frame(path, fd, [], []))
|
|
626
|
+
names = [os.fsencode(name) for name in os.listdir(fd)]
|
|
627
|
+
missing = sorted(children.get(path, set()) - set(names))
|
|
628
|
+
if missing:
|
|
629
|
+
raise fail(_join(path, missing[0]), "is missing")
|
|
630
|
+
frames[-1].pending = sorted(names, reverse=True)
|
|
631
|
+
|
|
632
|
+
path = b""
|
|
633
|
+
try:
|
|
634
|
+
enter(b"", os.open(root, _DIR_FLAGS))
|
|
635
|
+
while frames:
|
|
636
|
+
top = frames[-1]
|
|
637
|
+
if not top.pending:
|
|
638
|
+
path = top.path
|
|
639
|
+
oid = _tree_id(object_format, top.entries)
|
|
640
|
+
want = listing.tree if not top.path else expected[top.path].oid
|
|
641
|
+
if oid != want:
|
|
642
|
+
raise fail(top.path, f"hashes to the tree {oid}, the commit holds {want}")
|
|
643
|
+
frames.pop()
|
|
644
|
+
os.close(top.fd)
|
|
645
|
+
if frames:
|
|
646
|
+
frames[-1].entries.append((top.path.rpartition(b"/")[2], _TREE, oid))
|
|
647
|
+
continue
|
|
648
|
+
name = top.pending.pop()
|
|
649
|
+
path = _join(top.path, name)
|
|
650
|
+
entry = expected.get(path)
|
|
651
|
+
if entry is None or entry.parent != top.path:
|
|
652
|
+
raise fail(path, "is not in the commit")
|
|
653
|
+
st = os.lstat(name, dir_fd=top.fd)
|
|
654
|
+
if entry.mode == _GITLINK:
|
|
655
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
656
|
+
raise fail(path, "is a gitlink, but not a directory")
|
|
657
|
+
fd = os.open(name, _DIR_FLAGS, dir_fd=top.fd)
|
|
658
|
+
try:
|
|
659
|
+
if os.listdir(fd):
|
|
660
|
+
raise fail(path, "is a gitlink, but its directory is not empty")
|
|
661
|
+
finally:
|
|
662
|
+
os.close(fd)
|
|
663
|
+
top.entries.append((name, _GITLINK, entry.oid))
|
|
664
|
+
continue
|
|
665
|
+
if stat.S_ISDIR(st.st_mode):
|
|
666
|
+
if entry.mode != _TREE:
|
|
667
|
+
raise fail(path, f"is a directory, the commit holds mode {entry.mode.decode()}")
|
|
668
|
+
enter(path, os.open(name, _DIR_FLAGS, dir_fd=top.fd))
|
|
669
|
+
continue
|
|
670
|
+
if stat.S_ISREG(st.st_mode):
|
|
671
|
+
mode, oid = _file_id(object_format, top.fd, name, st)
|
|
672
|
+
elif stat.S_ISLNK(st.st_mode):
|
|
673
|
+
mode, oid = _SYMLINK, _blob_id(object_format, os.readlink(name, dir_fd=top.fd))
|
|
674
|
+
else:
|
|
675
|
+
raise fail(path, "is neither a regular file, a symlink nor a directory")
|
|
676
|
+
if (mode, oid) != (entry.mode, entry.oid):
|
|
677
|
+
raise fail(
|
|
678
|
+
path,
|
|
679
|
+
f"is {mode.decode()} {oid} on disk, the commit holds "
|
|
680
|
+
f"{entry.mode.decode()} {entry.oid}",
|
|
681
|
+
)
|
|
682
|
+
top.entries.append((name, mode, oid))
|
|
683
|
+
except OSError as exc:
|
|
684
|
+
raise fail(path, f"could not be proved: {exc.strerror or exc}") from exc
|
|
685
|
+
finally:
|
|
686
|
+
for open_frame in frames:
|
|
687
|
+
os.close(open_frame.fd)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@contextmanager
|
|
691
|
+
def materialize(
|
|
692
|
+
runner: Runner, repo: str, commit: str, object_format: str, *, prefix: str
|
|
693
|
+
) -> Iterator[MaterializedTree]:
|
|
694
|
+
"""Write ``commit``'s tree into a fresh private directory, prove it, then remove it.
|
|
695
|
+
|
|
696
|
+
The directory is created by :func:`tempfile.mkdtemp` (mode ``0700``) in
|
|
697
|
+
the system temporary directory, its name starting with ``prefix``; it is
|
|
698
|
+
yielded only once proven, and removed when the context exits, on an
|
|
699
|
+
exception too.
|
|
700
|
+
|
|
701
|
+
Raises :class:`~autoforge.errors.CandidateNotMaterializableError` when
|
|
702
|
+
the commit or a blob it names is not in ``repo``'s object store, and
|
|
703
|
+
:class:`~autoforge.errors.MaterializationError` when the tree cannot be
|
|
704
|
+
listed, written or proven.
|
|
705
|
+
"""
|
|
706
|
+
root = Path(tempfile.mkdtemp(prefix=prefix))
|
|
707
|
+
try:
|
|
708
|
+
tree = _commit_tree(runner, repo, commit, object_format)
|
|
709
|
+
listing = _list_tree(runner, repo, commit, tree, object_format)
|
|
710
|
+
_write(runner, repo, commit, listing, root)
|
|
711
|
+
_prove(root, listing, commit, object_format)
|
|
712
|
+
modes = [entry.mode for entry in listing.entries]
|
|
713
|
+
yield MaterializedTree(
|
|
714
|
+
commit=commit,
|
|
715
|
+
tree=tree,
|
|
716
|
+
object_format=object_format,
|
|
717
|
+
root=root,
|
|
718
|
+
files=sum(mode not in (_TREE, _GITLINK) for mode in modes),
|
|
719
|
+
gitlinks=modes.count(_GITLINK),
|
|
720
|
+
)
|
|
721
|
+
finally:
|
|
722
|
+
shutil.rmtree(root, ignore_errors=True)
|