4"""Assemble a complete reviewed worktree candidate in a strict private repo."""
6from __future__
import annotations
16from collections.abc
import Callable, Sequence
17from dataclasses
import dataclass
18from pathlib
import Path
19from typing
import NoReturn
21sys.path.insert(0, str(Path(__file__).resolve().parent))
23from git_environment
import sanitized_git_environment, trusted_git_executable
28class CandidateError(RuntimeError):
29 """The source tree could not be frozen into an exact safe candidate."""
32@dataclass(frozen=
True)
34 """NUL-safe identity of every byte and Git input consumed by assembly."""
38 tracked: tuple[str, ...]
39 untracked: tuple[str, ...]
42@dataclass(frozen=True)
44 """Validated immutable routing captured before private assembly."""
49 objects: tuple[Path, ...]
53def _fail(message: str) -> NoReturn:
54 raise CandidateError(message)
61 extra_env: dict[str, str] |
None =
None,
64 environment = sanitized_git_environment()
66 environment.update(extra_env)
67 proc = subprocess.run(
68 [trusted_git_executable(),
"-C", str(root), *args],
73 if check
and proc.returncode != 0:
74 detail = os.fsdecode(proc.stderr).strip()
75 _fail(f
"Git {' '.join(args)} failed: {detail}")
79def _absolute_git_path(
82 extra_env: dict[str, str] |
None =
None,
84 raw = os.fsdecode(_git(root, args, extra_env=extra_env)).strip()
86 if not path.is_absolute()
or "\n" in raw
or "\r" in raw:
87 _fail(f
"Git returned unsafe routing path: {raw!r}")
88 return Path(os.path.realpath(path))
91def _inherited_alternate_dirs() -> tuple[Path, ...]:
92 """Validate inherited alternates without reinterpreting Git quoting rules."""
93 raw = os.environ.get(
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"")
96 if any(char
in raw
for char
in (
"\n",
"\r",
"\0",
"'",
'"')):
97 _fail(
"inherited Git alternates contain quoting or control characters")
98 entries = raw.split(os.pathsep)
99 if any(
not entry
for entry
in entries):
100 _fail(
"inherited Git alternates contain an empty separator entry")
101 normalized: list[Path] = []
102 for entry
in entries:
104 if not path.is_absolute():
105 _fail(f
"inherited Git alternate is not absolute: {entry!r}")
106 path = Path(os.path.realpath(path))
109 except OSError
as exc:
110 message = f
"inherited Git alternate is unavailable: {path}"
111 raise CandidateError(message)
from exc
112 if not stat.S_ISDIR(info.st_mode)
or path.is_symlink():
113 _fail(f
"inherited Git alternate is not a regular directory: {path}")
114 if path
not in normalized:
115 normalized.append(path)
116 return tuple(normalized)
119def _alternate_environment(objects: Sequence[Path]) -> dict[str, str]:
120 """Bind one normalized alternate list for every source-object query."""
123 return {
"GIT_ALTERNATE_OBJECT_DIRECTORIES": os.pathsep.join(map(str, objects))}
126def _normalized_object_dirs(root: Path, inherited: Sequence[Path]) -> tuple[Path, ...]:
127 environment = _alternate_environment(inherited)
128 primary = _absolute_git_path(
131 "--path-format=absolute",
134 extra_env=environment,
136 report = os.fsdecode(_git(root, (
"count-objects",
"-v"), extra_env=environment))
139 Path(line.removeprefix(
"alternate: "))
140 for line
in report.splitlines()
141 if line.startswith(
"alternate: ")
143 normalized: list[Path] = []
145 if not raw.is_absolute()
or any(char
in str(raw)
for char
in (
"\n",
"\r",
"\0")):
146 _fail(f
"Git object path is not safe and absolute: {raw}")
147 path = Path(os.path.realpath(raw))
150 except OSError
as exc:
151 message = f
"Git object path is unavailable: {path}"
152 raise CandidateError(message)
from exc
153 if not stat.S_ISDIR(info.st_mode)
or path.is_symlink():
154 _fail(f
"Git object path is not a regular directory: {path}")
155 if path
not in normalized:
156 normalized.append(path)
157 return tuple(normalized)
160def _capture_routing(source: Path) -> SourceRouting:
161 inherited = _inherited_alternate_dirs()
162 environment = _alternate_environment(inherited)
163 root = _absolute_git_path(source,
"rev-parse",
"--show-toplevel", extra_env=environment)
164 if root != Path(os.path.realpath(source)):
165 _fail(
"source must be the repository toplevel")
166 git_dir = _absolute_git_path(
169 "--path-format=absolute",
170 "--absolute-git-dir",
171 extra_env=environment,
173 index = _absolute_git_path(
176 "--path-format=absolute",
179 extra_env=environment,
183 except OSError
as exc:
184 message =
"active source index is unavailable"
185 raise CandidateError(message)
from exc
186 if not stat.S_ISREG(info.st_mode)
or index.is_symlink():
187 _fail(
"active source index is not a regular non-symlink file")
188 head = os.fsdecode(_git(root, (
"rev-parse",
"--verify",
"HEAD"), extra_env=environment)).strip()
189 if len(head)
not in {40, 64}
or any(char
not in "0123456789abcdef" for char
in head):
190 _fail(
"source HEAD is not a full object identifier")
191 return SourceRouting(root, git_dir, index, _normalized_object_dirs(root, inherited), head)
194def _source_environment(routing: SourceRouting) -> dict[str, str]:
195 """Return the immutable normalized object-routing environment."""
196 return _alternate_environment(routing.objects[1:])
199def _index_entries(routing: SourceRouting) -> tuple[tuple[str, str, str], ...]:
200 records: list[tuple[str, str, str]] = []
203 (
"ls-files",
"--stage",
"-z"),
204 extra_env=_source_environment(routing),
208 metadata, separator, raw_path = row.partition(b
"\t")
209 fields = metadata.split()
210 if not separator
or len(fields) != TREE_ROW_FIELDS:
211 _fail(
"malformed staged-index row")
212 mode, _blob, stage = (os.fsdecode(field)
for field
in fields)
213 path = os.fsdecode(raw_path)
215 _fail(f
"unmerged index entry is not a candidate input: {path}")
216 if mode
not in {
"100644",
"100755",
"120000"}:
217 _fail(f
"unsupported candidate index mode {mode}: {path}")
218 records.append((path, mode, stage))
219 return tuple(records)
222def _untracked(routing: SourceRouting) -> tuple[str, ...]:
225 (
"ls-files",
"--others",
"--exclude-standard",
"-z"),
226 extra_env=_source_environment(routing),
228 paths = tuple(sorted(os.fsdecode(row)
for row
in rows
if row))
230 if "\n" in path
or "\r" in path:
231 _fail(f
"untracked path cannot be represented by the approval manifest: {path!r}")
235def _path_identity(root: Path, relative: str) -> bytes:
236 path = root / relative
239 except FileNotFoundError:
241 prefix = f
"{relative}\0{stat.S_IMODE(info.st_mode):o}\0".encode()
242 if stat.S_ISREG(info.st_mode):
243 return prefix + b
"file\0" + hashlib.sha256(path.read_bytes()).digest()
244 if stat.S_ISLNK(info.st_mode):
245 return prefix + b
"link\0" + hashlib.sha256(os.fsencode(path.readlink())).digest()
246 _fail(f
"special file is not an allowed candidate input: {relative}")
249def _source_state(routing: SourceRouting) -> SourceState:
250 entries = _index_entries(routing)
251 tracked = tuple(path
for path, _mode, _stage
in entries)
252 untracked = _untracked(routing)
253 digest = hashlib.sha256()
254 for path
in (*tracked, *untracked):
255 digest.update(_path_identity(routing.root, path))
258 hashlib.sha256(routing.index.read_bytes()).hexdigest(),
265def _head_policy_population(routing: SourceRouting) -> tuple[tuple[str, str, str], ...]:
268 (
"ls-tree",
"-rz",
"--full-tree", routing.head),
269 extra_env=_source_environment(routing),
271 population: list[tuple[str, str, str]] = []
275 metadata, separator, raw_path = row.partition(b
"\t")
276 fields = metadata.split()
277 if not separator
or len(fields) != TREE_ROW_FIELDS:
278 _fail(
"malformed HEAD tree row")
279 mode, object_type, blob = (os.fsdecode(field)
for field
in fields)
280 path = os.fsdecode(raw_path)
281 if Path(path).name
in {
".gitattributes",
".gitignore"}:
282 if object_type !=
"blob":
283 _fail(f
"HEAD policy path is not a blob: {path}")
284 population.append((path, mode, blob))
285 return tuple(sorted(population))
288def _candidate_policy_population(
289 routing: SourceRouting, state: SourceState
290) -> tuple[tuple[str, str, str], ...]:
291 population: list[tuple[str, str, str]] = []
292 for relative
in (*state.tracked, *state.untracked):
293 if Path(relative).name
not in {
".gitattributes",
".gitignore"}:
295 path = routing.root / relative
298 except FileNotFoundError:
299 _fail(f
"bootstrap policy file was deleted: {relative}")
300 if not stat.S_ISREG(info.st_mode)
or path.is_symlink():
301 _fail(f
"bootstrap policy file is not regular: {relative}")
302 mode =
"100755" if info.st_mode & stat.S_IXUSR
else "100644"
306 (
"hash-object",
"--no-filters",
"--", relative),
307 extra_env=_source_environment(routing),
310 population.append((relative, mode, blob))
311 return tuple(sorted(population))
314def _verify_bootstrap_policy(routing: SourceRouting, state: SourceState) ->
None:
315 head = _head_policy_population(routing)
316 candidate = _candidate_policy_population(routing, state)
317 if candidate != head:
319 "bootstrap requires the complete .gitattributes/.gitignore "
320 "population byte-equal to HEAD"
322 attributes = sum(path.endswith(
".gitattributes")
for path, _mode, _blob
in head)
323 ignores = sum(path.endswith(
".gitignore")
for path, _mode, _blob
in head)
324 if (attributes, ignores) != (6, 27):
325 _fail(f
"bootstrap policy population changed: {attributes} attributes, {ignores} ignores")
328def _approved_untracked(manifest: Path) -> tuple[str, ...]:
330 rows = manifest.read_text(encoding=
"utf-8").splitlines()
331 except (OSError, UnicodeError)
as exc:
332 message = f
"cannot read approved untracked manifest: {manifest}"
333 raise CandidateError(message)
from exc
334 if any(
not row
or "\0" in row
or "\r" in row
for row
in rows):
335 _fail(
"approved untracked manifest contains an invalid row")
336 if len(rows) != len(set(rows))
or rows != sorted(rows):
337 _fail(
"approved untracked manifest must be sorted and unique")
341def _refuse_reappeared_staged_deletion(routing: SourceRouting) ->
None:
344 (
"diff",
"--cached",
"--no-renames",
"--name-status",
"-z", routing.head),
345 extra_env=_source_environment(routing),
347 for offset
in range(0, len(rows) - 1, 2):
348 status_text = os.fsdecode(rows[offset])
349 path = os.fsdecode(rows[offset + 1])
350 if status_text ==
"D" and os.path.lexists(routing.root / path):
351 _fail(f
"staged deletion reappeared in the worktree; disposition required: {path}")
354def _write_alternates(repo: Path, objects: Sequence[Path]) ->
None:
355 target = repo /
".git/objects/info/alternates"
356 target.parent.mkdir(parents=
True, exist_ok=
True)
357 target.write_text(
"".join(f
"{path}\n" for path
in objects), encoding=
"utf-8")
360def _init_private(repo: Path) ->
None:
361 if repo.exists()
or repo.is_symlink():
362 _fail(f
"private candidate target already exists: {repo}")
363 template = repo.with_name(f
"{repo.name}.empty-template")
364 if template.exists()
or template.is_symlink():
365 _fail(f
"private template target already exists: {template}")
366 repo.mkdir(parents=
True)
368 _git(repo, (
"-c", f
"init.templateDir={template}",
"init",
"--quiet"))
372def _assemble_union(routing: SourceRouting, output: Path) -> str:
373 _init_private(output)
374 candidate_index = output /
".git/candidate.index"
375 candidate_objects = output /
".git/candidate-objects"
376 candidate_objects.mkdir()
377 shutil.copyfile(routing.index, candidate_index)
378 _write_alternates(output, routing.objects)
379 alternate_text = os.pathsep.join(str(path)
for path
in routing.objects)
381 "GIT_DIR": str(output /
".git"),
382 "GIT_WORK_TREE": str(routing.root),
383 "GIT_INDEX_FILE": str(candidate_index),
384 "GIT_OBJECT_DIRECTORY": str(candidate_objects),
385 "GIT_ALTERNATE_OBJECT_DIRECTORIES": alternate_text,
386 "GIT_LFS_SKIP_SMUDGE":
"1",
388 _git(routing.root, (
"add",
"-A",
"--",
":/"), extra_env=environment)
389 tree = os.fsdecode(_git(routing.root, (
"write-tree",), extra_env=environment)).strip()
390 _write_alternates(output, (*routing.objects, candidate_objects))
394def _round_trip(output: Path, tree: str, objects: Sequence[Path]) ->
None:
395 materialized = output /
"materialized"
396 _init_private(materialized)
397 _write_alternates(materialized, objects)
398 _git(materialized, (
"read-tree", tree))
399 environment = {
"GIT_LFS_SKIP_SMUDGE":
"1"}
400 _git(materialized, (
"checkout-index",
"--all"), extra_env=environment)
401 if _git(materialized, (
"ls-files",
"--others",
"--exclude-standard",
"-z")):
402 _fail(
"candidate round trip produced untracked paths")
403 _git(materialized, (
"add",
"-A",
"-f"))
404 actual = os.fsdecode(_git(materialized, (
"write-tree",))).strip()
406 _fail(
"candidate round trip changed tree bytes or modes")
412 approved_manifest: Path,
414 after_capture: Callable[[],
None] |
None =
None,
416 """Assemble current index, tracked worktree, and approved untracked bytes."""
417 routing = _capture_routing(Path(os.path.realpath(source)))
418 if Path(os.path.realpath(output)).is_relative_to(routing.root):
419 _fail(
"private candidate target must be outside the source repository")
420 before = _source_state(routing)
421 if before.untracked != _approved_untracked(approved_manifest):
422 _fail(
"nonignored untracked population differs from the reviewed manifest")
423 _verify_bootstrap_policy(routing, before)
424 _refuse_reappeared_staged_deletion(routing)
425 if after_capture
is not None:
427 tree = _assemble_union(routing, output)
428 after = _source_state(routing)
430 _fail(
"source candidate inputs changed during assembly")
431 candidate_objects = output /
".git/candidate-objects"
432 _round_trip(output, tree, (*routing.objects, candidate_objects))
436def _fixture_git(root: Path, *args: str) -> bytes:
437 return _git(root, args)
440def _prepare_source_fixture(base: Path) -> tuple[Path, bytes]:
441 """Create the complete staged/unstaged/untracked assembly fixture."""
442 source = base /
"source"
444 _fixture_git(source,
"init",
"--quiet")
445 _fixture_git(source,
"config",
"user.email",
"selftest@invalid")
446 _fixture_git(source,
"config",
"user.name",
"selftest")
447 (source /
".gitattributes").write_text(
448 "*.txt text eol=crlf\n*.bin filter=lfs diff=lfs -text\n", encoding=
"ascii"
450 (source /
".gitignore").write_text(
"ignored.tmp\n", encoding=
"ascii")
451 for index
in range(1, 6):
452 nested = source / f
"p{index}"
454 (nested /
".gitattributes").write_text(
"*.txt text\n", encoding=
"ascii")
455 for index
in range(1, 27):
456 nested = source / f
"i{index}"
458 (nested /
".gitignore").write_text(
"scratch\n", encoding=
"ascii")
459 (source /
"tracked.txt").write_text(
"base\n", encoding=
"ascii")
460 (source /
"delete.txt").write_text(
"delete\n", encoding=
"ascii")
461 executable = source /
"run.sh"
462 executable.write_text(
"#!/bin/sh\n", encoding=
"ascii")
463 executable.chmod(0o755)
464 (source /
"target").write_text(
"target\n", encoding=
"ascii")
465 (source /
"link").symlink_to(
"target")
466 pointer = b
"version https://git-lfs.github.com/spec/v1\noid sha256:" + b
"0" * 64 + b
"\nsize 0\n"
467 (source /
"asset.bin").write_bytes(pointer)
468 _fixture_git(source,
"add",
"-A")
469 _fixture_git(source,
"commit",
"--quiet",
"-m",
"base")
470 return source, pointer
473def _apply_candidate_changes(base: Path, source: Path) -> Path:
474 """Apply every candidate input class and a source-local filter attack."""
475 marker = base /
"filter.ran"
476 helper = base /
"filter.sh"
477 helper.write_text(f
"#!/bin/sh\nprintf x >>{marker}\ncat\n", encoding=
"ascii")
479 _fixture_git(source,
"config",
"filter.lfs.clean", str(helper))
480 _fixture_git(source,
"config",
"filter.lfs.smudge", str(helper))
481 _fixture_git(source,
"config",
"filter.lfs.required",
"true")
482 (source /
"tracked.txt").write_bytes(b
"changed\r\n")
483 (source /
"delete.txt").unlink()
484 (source /
"new.txt").write_text(
"new\n", encoding=
"ascii")
485 (source /
"ignored.tmp").write_text(
"ignored\n", encoding=
"ascii")
489def _assert_candidate(base: Path, source: Path, pointer: bytes, marker: Path) ->
None:
490 """Verify byte, mode, ignore, deletion, and filter semantics."""
491 manifest = base /
"approved.txt"
492 manifest.write_text(
"new.txt\n", encoding=
"ascii")
494 materialized = base /
"candidate/materialized"
496 _fail(
"source-local clean/smudge filter executed during candidate assembly")
497 if (materialized /
"ignored.tmp").exists()
or (materialized /
"delete.txt").exists():
498 _fail(
"ignored junk or a deleted tracked path entered the candidate")
499 if (materialized /
"asset.bin").read_bytes() != pointer:
500 _fail(
"LFS pointer changed during strict materialization")
501 if (materialized /
"tracked.txt").read_bytes() != b
"changed\r\n":
502 _fail(
"built-in CRLF checkout semantics were not preserved")
503 if not (materialized /
"link").is_symlink()
or not os.access(materialized /
"run.sh", os.X_OK):
504 _fail(
"symlink or executable mode was not preserved")
506 _fail(
"candidate assembly returned an empty tree identity")
509def _assert_toctou_refused(base: Path, source: Path) ->
None:
510 """Prove a worktree byte race invalidates the candidate."""
511 target = base /
"toctou"
512 shutil.copytree(source, target, symlinks=
True, ignore=shutil.ignore_patterns(
".git"))
513 _fixture_git(target,
"init",
"--quiet")
514 _fixture_git(target,
"config",
"user.email",
"selftest@invalid")
515 _fixture_git(target,
"config",
"user.name",
"selftest")
516 _fixture_git(target,
"add",
"-A")
517 _fixture_git(target,
"commit",
"--quiet",
"-m",
"base")
518 manifest = base /
"toctou-approved.txt"
519 manifest.write_text(
"", encoding=
"ascii")
521 def mutate() -> None:
522 (target /
"tracked.txt").write_text(
"raced\n", encoding=
"ascii")
526 except CandidateError
as exc:
527 if "changed during assembly" not in str(exc):
530 _fail(
"candidate assembly accepted a source mutation after capture")
533def _assert_staged_deletion_disposition(base: Path) ->
None:
534 """Accept an absent staged deletion and reject every reappeared entry."""
535 source = base /
"staged-deletion"
537 _fixture_git(source,
"init",
"--quiet")
538 _fixture_git(source,
"config",
"user.email",
"selftest@invalid")
539 _fixture_git(source,
"config",
"user.name",
"selftest")
540 victim = source /
"victim"
541 victim.write_text(
"tracked\n", encoding=
"ascii")
542 _fixture_git(source,
"add",
"victim")
543 _fixture_git(source,
"commit",
"--quiet",
"-m",
"tracked victim")
545 _fixture_git(source,
"add",
"-u",
"--",
"victim")
546 routing = _capture_routing(source)
547 _refuse_reappeared_staged_deletion(routing)
549 victim.write_text(
"resurrected\n", encoding=
"ascii")
551 _refuse_reappeared_staged_deletion(routing)
552 except CandidateError:
555 _fail(
"regular-file resurrection of a staged deletion was accepted")
558 victim.symlink_to(
"missing-target")
559 if victim.exists()
or not os.path.lexists(victim):
560 _fail(
"dangling-symlink control did not prove exists false / lexists true")
562 _refuse_reappeared_staged_deletion(routing)
563 except CandidateError:
566 _fail(
"dangling-symlink resurrection of a staged deletion was accepted")
569def _assert_alternate_routing(base: Path) ->
None:
570 """Prove absolute alternates work and ambiguous inherited forms fail closed."""
571 source = base /
"alternate-source"
573 _fixture_git(source,
"init",
"--quiet")
574 _fixture_git(source,
"config",
"user.email",
"selftest@invalid")
575 _fixture_git(source,
"config",
"user.name",
"selftest")
576 (source /
"tracked.txt").write_text(
"alternate object\n", encoding=
"ascii")
577 _fixture_git(source,
"add",
"tracked.txt")
578 _fixture_git(source,
"commit",
"--quiet",
"-m",
"alternate")
579 objects = source /
".git/objects"
580 alternate = base /
"alternate-objects"
581 objects.rename(alternate)
583 (objects /
"info").mkdir()
584 (objects /
"pack").mkdir()
585 second = base /
"second-alternate"
588 original = os.environ.get(
"GIT_ALTERNATE_OBJECT_DIRECTORIES")
590 os.environ[
"GIT_ALTERNATE_OBJECT_DIRECTORIES"] = os.pathsep.join(
591 (str(alternate), str(second))
593 routing = _capture_routing(source)
594 if alternate
not in routing.objects
or second
not in routing.objects:
595 _fail(
"multiple absolute inherited alternates were not normalized")
596 if not _source_state(routing).tracked:
597 _fail(
"source objects were not readable through an inherited alternate")
601 f
"{alternate}{os.pathsep}",
603 f
"{alternate}\n{second}",
604 str(base /
"missing-alternate"),
606 for value
in invalid:
607 os.environ[
"GIT_ALTERNATE_OBJECT_DIRECTORIES"] = value
609 _capture_routing(source)
610 except CandidateError:
612 _fail(f
"unsafe inherited alternate was accepted: {value!r}")
615 os.environ.pop(
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
None)
617 os.environ[
"GIT_ALTERNATE_OBJECT_DIRECTORIES"] = original
620def selftest() -> None:
621 """Prove union semantics, hostile-filter isolation, and TOCTOU refusal."""
622 with tempfile.TemporaryDirectory(prefix=
"ra8-candidate-selftest-")
as temp:
624 source, pointer = _prepare_source_fixture(base)
625 marker = _apply_candidate_changes(base, source)
626 _assert_candidate(base, source, pointer, marker)
627 _assert_toctou_refused(base, source)
628 _assert_staged_deletion_disposition(base)
629 _assert_alternate_routing(base)
630 print(
"assemble_candidate.py --selftest: PASS")
634 """Parse the strict candidate-assembly command line."""
635 parser = argparse.ArgumentParser(description=__doc__)
636 parser.add_argument(
"--source", type=Path)
637 parser.add_argument(
"--output", type=Path)
638 parser.add_argument(
"--approved-untracked", type=Path)
639 parser.add_argument(
"--selftest", action=
"store_true")
640 args = parser.parse_args()
642 if any(value
is not None for value
in (args.source, args.output, args.approved_untracked)):
643 parser.error(
"--selftest takes no assembly paths")
646 if args.source
is None or args.output
is None or args.approved_untracked
is None:
647 parser.error(
"--source, --output, and --approved-untracked are required")
653if __name__ ==
"__main__":
654 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.