ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
assemble_candidate.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Assemble a complete reviewed worktree candidate in a strict private repo."""
5
6from __future__ import annotations
7
8import argparse
9import hashlib
10import os
11import shutil
12import stat
13import subprocess
14import sys
15import tempfile
16from collections.abc import Callable, Sequence
17from dataclasses import dataclass
18from pathlib import Path
19from typing import NoReturn
20
21sys.path.insert(0, str(Path(__file__).resolve().parent))
22
23from git_environment import sanitized_git_environment, trusted_git_executable
24
25TREE_ROW_FIELDS = 3
26
27
28class CandidateError(RuntimeError):
29 """The source tree could not be frozen into an exact safe candidate."""
30
31
32@dataclass(frozen=True)
33class SourceState:
34 """NUL-safe identity of every byte and Git input consumed by assembly."""
35
36 index_sha256: str
37 inputs_sha256: str
38 tracked: tuple[str, ...]
39 untracked: tuple[str, ...]
40
41
42@dataclass(frozen=True)
43class SourceRouting:
44 """Validated immutable routing captured before private assembly."""
45
46 root: Path
47 git_dir: Path
48 index: Path
49 objects: tuple[Path, ...]
50 head: str
51
52
53def _fail(message: str) -> NoReturn:
54 raise CandidateError(message)
55
56
57def _git(
58 root: Path,
59 args: Sequence[str],
60 *,
61 extra_env: dict[str, str] | None = None,
62 check: bool = True,
63) -> bytes:
64 environment = sanitized_git_environment()
65 if extra_env:
66 environment.update(extra_env)
67 proc = subprocess.run( # noqa: S603 -- fixed absolute Git authority and audited argv
68 [trusted_git_executable(), "-C", str(root), *args],
69 env=environment,
70 capture_output=True,
71 check=False,
72 )
73 if check and proc.returncode != 0:
74 detail = os.fsdecode(proc.stderr).strip()
75 _fail(f"Git {' '.join(args)} failed: {detail}")
76 return proc.stdout
77
78
79def _absolute_git_path(
80 root: Path,
81 *args: str,
82 extra_env: dict[str, str] | None = None,
83) -> Path:
84 raw = os.fsdecode(_git(root, args, extra_env=extra_env)).strip()
85 path = Path(raw)
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))
89
90
91def _inherited_alternate_dirs() -> tuple[Path, ...]:
92 """Validate inherited alternates without reinterpreting Git quoting rules."""
93 raw = os.environ.get("GIT_ALTERNATE_OBJECT_DIRECTORIES", "")
94 if not raw:
95 return ()
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:
103 path = Path(entry)
104 if not path.is_absolute():
105 _fail(f"inherited Git alternate is not absolute: {entry!r}")
106 path = Path(os.path.realpath(path))
107 try:
108 info = path.lstat()
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)
117
118
119def _alternate_environment(objects: Sequence[Path]) -> dict[str, str]:
120 """Bind one normalized alternate list for every source-object query."""
121 if not objects:
122 return {}
123 return {"GIT_ALTERNATE_OBJECT_DIRECTORIES": os.pathsep.join(map(str, objects))}
124
125
126def _normalized_object_dirs(root: Path, inherited: Sequence[Path]) -> tuple[Path, ...]:
127 environment = _alternate_environment(inherited)
128 primary = _absolute_git_path(
129 root,
130 "rev-parse",
131 "--path-format=absolute",
132 "--git-path",
133 "objects",
134 extra_env=environment,
135 )
136 report = os.fsdecode(_git(root, ("count-objects", "-v"), extra_env=environment))
137 paths = [primary]
138 paths.extend(
139 Path(line.removeprefix("alternate: "))
140 for line in report.splitlines()
141 if line.startswith("alternate: ")
142 )
143 normalized: list[Path] = []
144 for raw in paths:
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))
148 try:
149 info = path.lstat()
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)
158
159
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(
167 root,
168 "rev-parse",
169 "--path-format=absolute",
170 "--absolute-git-dir",
171 extra_env=environment,
172 )
173 index = _absolute_git_path(
174 root,
175 "rev-parse",
176 "--path-format=absolute",
177 "--git-path",
178 "index",
179 extra_env=environment,
180 )
181 try:
182 info = index.lstat()
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)
192
193
194def _source_environment(routing: SourceRouting) -> dict[str, str]:
195 """Return the immutable normalized object-routing environment."""
196 return _alternate_environment(routing.objects[1:])
197
198
199def _index_entries(routing: SourceRouting) -> tuple[tuple[str, str, str], ...]:
200 records: list[tuple[str, str, str]] = []
201 for row in _git(
202 routing.root,
203 ("ls-files", "--stage", "-z"),
204 extra_env=_source_environment(routing),
205 ).split(b"\0"):
206 if not row:
207 continue
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)
214 if stage != "0":
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)
220
221
222def _untracked(routing: SourceRouting) -> tuple[str, ...]:
223 rows = _git(
224 routing.root,
225 ("ls-files", "--others", "--exclude-standard", "-z"),
226 extra_env=_source_environment(routing),
227 ).split(b"\0")
228 paths = tuple(sorted(os.fsdecode(row) for row in rows if row))
229 for path in paths:
230 if "\n" in path or "\r" in path:
231 _fail(f"untracked path cannot be represented by the approval manifest: {path!r}")
232 return paths
233
234
235def _path_identity(root: Path, relative: str) -> bytes:
236 path = root / relative
237 try:
238 info = path.lstat()
239 except FileNotFoundError:
240 return b"absent"
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}")
247
248
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))
256 digest.update(b"\0")
257 return SourceState(
258 hashlib.sha256(routing.index.read_bytes()).hexdigest(),
259 digest.hexdigest(),
260 tracked,
261 untracked,
262 )
263
264
265def _head_policy_population(routing: SourceRouting) -> tuple[tuple[str, str, str], ...]:
266 rows = _git(
267 routing.root,
268 ("ls-tree", "-rz", "--full-tree", routing.head),
269 extra_env=_source_environment(routing),
270 ).split(b"\0")
271 population: list[tuple[str, str, str]] = []
272 for row in rows:
273 if not row:
274 continue
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))
286
287
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"}:
294 continue
295 path = routing.root / relative
296 try:
297 info = path.lstat()
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"
303 blob = os.fsdecode(
304 _git(
305 routing.root,
306 ("hash-object", "--no-filters", "--", relative),
307 extra_env=_source_environment(routing),
308 )
309 ).strip()
310 population.append((relative, mode, blob))
311 return tuple(sorted(population))
312
313
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:
318 _fail(
319 "bootstrap requires the complete .gitattributes/.gitignore "
320 "population byte-equal to HEAD"
321 )
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")
326
327
328def _approved_untracked(manifest: Path) -> tuple[str, ...]:
329 try:
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")
338 return tuple(rows)
339
340
341def _refuse_reappeared_staged_deletion(routing: SourceRouting) -> None:
342 rows = _git(
343 routing.root,
344 ("diff", "--cached", "--no-renames", "--name-status", "-z", routing.head),
345 extra_env=_source_environment(routing),
346 ).split(b"\0")
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}")
352
353
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")
358
359
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)
367 template.mkdir()
368 _git(repo, ("-c", f"init.templateDir={template}", "init", "--quiet"))
369 template.rmdir()
370
371
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)
380 environment = {
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",
387 }
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))
391 return tree
392
393
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()
405 if actual != tree:
406 _fail("candidate round trip changed tree bytes or modes")
407
408
410 source: Path,
411 output: Path,
412 approved_manifest: Path,
413 *,
414 after_capture: Callable[[], None] | None = None,
415) -> str:
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:
426 after_capture()
427 tree = _assemble_union(routing, output)
428 after = _source_state(routing)
429 if after != before:
430 _fail("source candidate inputs changed during assembly")
431 candidate_objects = output / ".git/candidate-objects"
432 _round_trip(output, tree, (*routing.objects, candidate_objects))
433 return tree
434
435
436def _fixture_git(root: Path, *args: str) -> bytes:
437 return _git(root, args)
438
439
440def _prepare_source_fixture(base: Path) -> tuple[Path, bytes]:
441 """Create the complete staged/unstaged/untracked assembly fixture."""
442 source = base / "source"
443 source.mkdir()
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"
449 )
450 (source / ".gitignore").write_text("ignored.tmp\n", encoding="ascii")
451 for index in range(1, 6):
452 nested = source / f"p{index}"
453 nested.mkdir()
454 (nested / ".gitattributes").write_text("*.txt text\n", encoding="ascii")
455 for index in range(1, 27):
456 nested = source / f"i{index}"
457 nested.mkdir()
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
471
472
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")
478 helper.chmod(0o755)
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")
486 return marker
487
488
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")
493 tree = assemble_candidate(source, base / "candidate", manifest)
494 materialized = base / "candidate/materialized"
495 if marker.exists():
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")
505 if not tree:
506 _fail("candidate assembly returned an empty tree identity")
507
508
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")
520
521 def mutate() -> None:
522 (target / "tracked.txt").write_text("raced\n", encoding="ascii")
523
524 try:
525 assemble_candidate(target, base / "toctou-candidate", manifest, after_capture=mutate)
526 except CandidateError as exc:
527 if "changed during assembly" not in str(exc):
528 raise
529 else:
530 _fail("candidate assembly accepted a source mutation after capture")
531
532
533def _assert_staged_deletion_disposition(base: Path) -> None:
534 """Accept an absent staged deletion and reject every reappeared entry."""
535 source = base / "staged-deletion"
536 source.mkdir()
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")
544 victim.unlink()
545 _fixture_git(source, "add", "-u", "--", "victim")
546 routing = _capture_routing(source)
547 _refuse_reappeared_staged_deletion(routing)
548
549 victim.write_text("resurrected\n", encoding="ascii")
550 try:
551 _refuse_reappeared_staged_deletion(routing)
552 except CandidateError:
553 pass
554 else:
555 _fail("regular-file resurrection of a staged deletion was accepted")
556
557 victim.unlink()
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")
561 try:
562 _refuse_reappeared_staged_deletion(routing)
563 except CandidateError:
564 pass
565 else:
566 _fail("dangling-symlink resurrection of a staged deletion was accepted")
567
568
569def _assert_alternate_routing(base: Path) -> None:
570 """Prove absolute alternates work and ambiguous inherited forms fail closed."""
571 source = base / "alternate-source"
572 source.mkdir()
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)
582 objects.mkdir()
583 (objects / "info").mkdir()
584 (objects / "pack").mkdir()
585 second = base / "second-alternate"
586 second.mkdir()
587
588 original = os.environ.get("GIT_ALTERNATE_OBJECT_DIRECTORIES")
589 try:
590 os.environ["GIT_ALTERNATE_OBJECT_DIRECTORIES"] = os.pathsep.join(
591 (str(alternate), str(second))
592 )
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")
598
599 invalid = (
600 "relative-objects",
601 f"{alternate}{os.pathsep}",
602 f'"{alternate}"',
603 f"{alternate}\n{second}",
604 str(base / "missing-alternate"),
605 )
606 for value in invalid:
607 os.environ["GIT_ALTERNATE_OBJECT_DIRECTORIES"] = value
608 try:
609 _capture_routing(source)
610 except CandidateError:
611 continue
612 _fail(f"unsafe inherited alternate was accepted: {value!r}")
613 finally:
614 if original is None:
615 os.environ.pop("GIT_ALTERNATE_OBJECT_DIRECTORIES", None)
616 else:
617 os.environ["GIT_ALTERNATE_OBJECT_DIRECTORIES"] = original
618
619
620def selftest() -> None:
621 """Prove union semantics, hostile-filter isolation, and TOCTOU refusal."""
622 with tempfile.TemporaryDirectory(prefix="ra8-candidate-selftest-") as temp:
623 base = Path(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")
631
632
633def main() -> int:
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()
641 if args.selftest:
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")
644 selftest()
645 return 0
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")
648 tree = assemble_candidate(args.source, args.output, args.approved_untracked)
649 print(tree)
650 return 0
651
652
653if __name__ == "__main__":
654 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298