ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
stage_worktree_context.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"""Build and verify an exact archive of selected candidate-worktree paths.
5
6The dev-box Ansible role must provision from the checkout that invoked it,
7including unstaged edits and non-ignored new files. Recursively copying source
8directories also copies ignored workstation residue, while ``git archive``
9cannot see dirty or untracked candidate bytes. This helper uses Git only as the
10census: present cached files plus non-ignored untracked files are archived from
11their current worktree bytes.
12
13Archive metadata is normalized so identical candidate trees produce identical
14bytes on different controllers. Regular files preserve only the current
15worktree's portable executable bit, including unstaged chmod changes; the index
16distinguishes tracked symlinks from regular files. The verifier compares every
17archive member, byte, link target, and normalized mode against an extracted
18tree, including unexpected files.
19"""
20
21from __future__ import annotations
22
23import argparse
24import hashlib
25import io
26import os
27import stat
28import subprocess
29import sys
30import tarfile
31import tempfile
32from dataclasses import dataclass
33from pathlib import Path, PurePosixPath
34
35from git_environment import LOCAL_GIT_ENVIRONMENT, sanitized_git_environment, trusted_git_executable
36
37EXIT_MISMATCH = 1
38EXIT_USAGE = 2
39REGULAR_MODE = 0o644
40EXECUTABLE_MODE = 0o755
41DIRECTORY_MODE = 0o755
42SYMLINK_MODE = 0o777
43GIT_REGULAR_MODE = "100644"
44GIT_EXECUTABLE_MODE = "100755"
45GIT_SYMLINK_MODE = "120000"
46GIT_STAGE_NORMAL = "0"
47GIT_STAGE_FIELD_COUNT = 3
48
49
50class ContextError(RuntimeError):
51 """A candidate census, archive, or verification contract failed."""
52
53
54@dataclass(frozen=True)
55class Entry:
56 """One normalized archive entry."""
57
58 name: str
59 kind: str
60 mode: int
61 data: bytes = b""
62 link: str = ""
63
64
65def _git(root: Path, args: list[str]) -> bytes:
66 """Run one Git query with ``root`` as the only repository selector."""
67 proc = subprocess.run( # noqa: S603 -- fixed git executable and caller-built argv
68 [trusted_git_executable(), *args],
69 cwd=root,
70 env=sanitized_git_environment(),
71 capture_output=True,
72 check=False,
73 )
74 if proc.returncode != 0:
75 detail = os.fsdecode(proc.stderr).strip()
76 message = f"git {' '.join(args)} failed: {detail}"
77 raise ContextError(message)
78 return proc.stdout
79
80
81def _candidate_paths(root: Path, scopes: tuple[str, ...]) -> list[str]:
82 """Return sorted live cached or non-ignored untracked paths in scope."""
83 raw = _git(
84 root,
85 ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *scopes],
86 )
87 paths: list[str] = []
88 for record in raw.split(b"\0"):
89 if not record:
90 continue
91 rel = os.fsdecode(record)
92 source = root / rel
93 try:
94 source.lstat()
95 except FileNotFoundError:
96 continue
97 paths.append(rel)
98 return sorted(set(paths), key=os.fsencode)
99
100
101def _tracked_modes(root: Path, scopes: tuple[str, ...]) -> dict[str, str]:
102 """Return index modes for normal-stage tracked paths in scope."""
103 raw = _git(root, ["ls-files", "-z", "--stage", "--cached", "--", *scopes])
104 modes: dict[str, str] = {}
105 for record in raw.split(b"\0"):
106 if not record:
107 continue
108 header, separator, raw_path = record.partition(b"\t")
109 fields = header.split()
110 if not separator or len(fields) != GIT_STAGE_FIELD_COUNT:
111 message = "git ls-files --stage returned a malformed record"
112 raise ContextError(message)
113 mode, _object_id, stage = (os.fsdecode(field) for field in fields)
114 rel = os.fsdecode(raw_path)
115 if stage != GIT_STAGE_NORMAL:
116 message = f"cannot archive conflicted index entry: {rel}"
117 raise ContextError(message)
118 modes[rel] = mode
119 return modes
120
121
122def _regular_mode(source: Path, git_mode: str | None) -> int:
123 """Normalize the candidate worktree's current executable mode."""
124 if git_mode is not None and git_mode not in (GIT_EXECUTABLE_MODE, GIT_REGULAR_MODE):
125 message = f"unsupported tracked file mode {git_mode}: {source}"
126 raise ContextError(message)
127 return EXECUTABLE_MODE if source.stat().st_mode & stat.S_IXUSR else REGULAR_MODE
128
129
130def _file_entry(root: Path, rel: str, git_mode: str | None) -> Entry:
131 """Read one live worktree path into its normalized representation."""
132 source = root / rel
133 metadata = source.lstat()
134 if stat.S_ISLNK(metadata.st_mode):
135 if git_mode not in (None, GIT_SYMLINK_MODE):
136 message = f"worktree type disagrees with index for {rel}"
137 raise ContextError(message)
138 return Entry(rel, "symlink", SYMLINK_MODE, link=str(source.readlink()))
139 if stat.S_ISREG(metadata.st_mode):
140 if git_mode == GIT_SYMLINK_MODE:
141 message = f"worktree type disagrees with index for {rel}"
142 raise ContextError(message)
143 return Entry(rel, "file", _regular_mode(source, git_mode), data=source.read_bytes())
144 message = f"unsupported worktree entry type: {rel}"
145 raise ContextError(message)
146
147
148def collect_entries(root: Path, scopes: tuple[str, ...]) -> list[Entry]:
149 """Collect normalized directories and candidate file entries."""
150 modes = _tracked_modes(root, scopes)
151 files = [_file_entry(root, rel, modes.get(rel)) for rel in _candidate_paths(root, scopes)]
152 directories: set[str] = set()
153 for entry in files:
154 parent = PurePosixPath(entry.name).parent
155 while parent != PurePosixPath("."):
156 directories.add(parent.as_posix())
157 parent = parent.parent
158 dirs = [Entry(name, "directory", DIRECTORY_MODE) for name in directories]
159 return sorted([*dirs, *files], key=lambda entry: os.fsencode(entry.name))
160
161
162def _tar_info(entry: Entry) -> tarfile.TarInfo:
163 """Create normalized tar metadata for one entry."""
164 info = tarfile.TarInfo(entry.name)
165 info.uid = 0
166 info.gid = 0
167 info.uname = "root"
168 info.gname = "root"
169 info.mtime = 0
170 info.mode = entry.mode
171 if entry.kind == "directory":
172 info.type = tarfile.DIRTYPE
173 elif entry.kind == "symlink":
174 info.type = tarfile.SYMTYPE
175 info.linkname = entry.link
176 else:
177 info.type = tarfile.REGTYPE
178 info.size = len(entry.data)
179 return info
180
181
182def create_archive(root: Path, output: Path, scopes: tuple[str, ...]) -> str:
183 """Write an atomic deterministic tar and return its SHA-256 digest."""
184 entries = collect_entries(root, scopes)
185 if not entries:
186 message = "candidate census is empty"
187 raise ContextError(message)
188 output.parent.mkdir(parents=True, exist_ok=True)
189 temporary = output.with_name(f".{output.name}.tmp-{os.getpid()}")
190 try:
191 with tarfile.open(temporary, "w", format=tarfile.GNU_FORMAT) as archive:
192 for entry in entries:
193 info = _tar_info(entry)
194 payload = io.BytesIO(entry.data) if entry.kind == "file" else None
195 archive.addfile(info, payload)
196 temporary.replace(output)
197 finally:
198 temporary.unlink(missing_ok=True)
199 return hashlib.sha256(output.read_bytes()).hexdigest()
200
201
202def _safe_name(raw_name: str) -> str:
203 """Return a canonical relative member name or reject the archive."""
204 name = raw_name.rstrip("/")
205 path = PurePosixPath(name)
206 if not name or path.is_absolute() or ".." in path.parts or path.as_posix() != name:
207 message = f"unsafe or non-canonical archive member: {raw_name!r}"
208 raise ContextError(message)
209 return name
210
211
212def _archive_entries(archive_path: Path) -> dict[str, Entry]:
213 """Read normalized entries from a trusted candidate archive."""
214 entries: dict[str, Entry] = {}
215 with tarfile.open(archive_path, "r:") as archive:
216 for member in archive.getmembers():
217 name = _safe_name(member.name)
218 if name in entries:
219 message = f"duplicate archive member: {name}"
220 raise ContextError(message)
221 if member.isdir():
222 entry = Entry(name, "directory", member.mode & 0o777)
223 elif member.issym():
224 entry = Entry(name, "symlink", SYMLINK_MODE, link=member.linkname)
225 elif member.isfile():
226 source = archive.extractfile(member)
227 if source is None:
228 message = f"cannot read archive member: {name}"
229 raise ContextError(message)
230 entry = Entry(name, "file", member.mode & 0o777, data=source.read())
231 else:
232 message = f"unsupported archive member type: {name}"
233 raise ContextError(message)
234 entries[name] = entry
235 if not entries:
236 message = "archive contains no entries"
237 raise ContextError(message)
238 return entries
239
240
241def _tree_entries(directory: Path) -> dict[str, Entry]:
242 """Read every file, directory, and symlink below an extracted root."""
243 entries: dict[str, Entry] = {}
244 if not directory.is_dir():
245 return entries
246 for base, dirs, files in os.walk(directory, followlinks=False):
247 base_path = Path(base)
248 names = sorted([*dirs, *files], key=os.fsencode)
249 for name in names:
250 path = base_path / name
251 rel = path.relative_to(directory).as_posix()
252 metadata = path.lstat()
253 if stat.S_ISLNK(metadata.st_mode):
254 entries[rel] = Entry(rel, "symlink", SYMLINK_MODE, link=str(path.readlink()))
255 if name in dirs:
256 dirs.remove(name)
257 elif stat.S_ISDIR(metadata.st_mode):
258 entries[rel] = Entry(rel, "directory", metadata.st_mode & 0o777)
259 elif stat.S_ISREG(metadata.st_mode):
260 entries[rel] = Entry(rel, "file", metadata.st_mode & 0o777, data=path.read_bytes())
261 else:
262 message = f"unsupported staged entry type: {rel}"
263 raise ContextError(message)
264 return entries
265
266
267def verify_archive(archive_path: Path, directory: Path) -> list[str]:
268 """Return exact archive-versus-directory mismatch descriptions."""
269 expected = _archive_entries(archive_path)
270 actual = _tree_entries(directory)
271 findings = [
272 f"missing: {name}" for name in sorted(expected.keys() - actual.keys(), key=os.fsencode)
273 ]
274 findings.extend(
275 f"unexpected: {name}" for name in sorted(actual.keys() - expected.keys(), key=os.fsencode)
276 )
277 for name in sorted(expected.keys() & actual.keys(), key=os.fsencode):
278 wanted = expected[name]
279 got = actual[name]
280 if wanted.kind != got.kind:
281 findings.append(f"type mismatch: {name}")
282 elif wanted.mode != got.mode:
283 findings.append(f"mode mismatch: {name}")
284 elif wanted.kind == "file" and wanted.data != got.data:
285 findings.append(f"content mismatch: {name}")
286 elif wanted.kind == "symlink" and wanted.link != got.link:
287 findings.append(f"link mismatch: {name}")
288 return findings
289
290
291def _git_init(root: Path) -> None:
292 """Initialize the selftest repository with deterministic identity."""
293 root.mkdir()
294 _git(root, ["init", "-q"])
295 _git(root, ["config", "user.email", "context@example.invalid"])
296 _git(root, ["config", "user.name", "Context Selftest"])
297
298
299def _write_fixture(root: Path) -> None:
300 """Create tracked, dirty, deleted, ignored, untracked, and link inputs."""
301 (root / "scripts/tool dir").mkdir(parents=True) # PATHREF-OK: fixture
302 (root / ".devcontainer").mkdir()
303 (root / ".gitignore").write_text("__pycache__/\n*.pyc\n", encoding="ascii")
304 (root / ".devcontainer/Dockerfile").write_text("FROM scratch\n", encoding="ascii")
305 tracked = root / "scripts/tracked.sh" # PATHREF-OK: fixture
306 tracked.write_text("#!/bin/sh\necho index\n", encoding="ascii")
307 tracked.chmod(REGULAR_MODE)
308 deleted = root / "scripts/deleted.py" # PATHREF-OK: fixture
309 deleted.write_text("old = True\n", encoding="ascii")
310 (root / "scripts/tracked-link").symlink_to("tracked.sh") # PATHREF-OK: fixture
311 _git(root, ["add", ".gitignore", ".devcontainer", "scripts"])
312 _git(root, ["commit", "-qm", "fixture"])
313 tracked.write_text("#!/bin/sh\necho worktree\n", encoding="ascii")
314 tracked.chmod(EXECUTABLE_MODE)
315 deleted.unlink()
316 (root / "scripts/tool dir/new helper.py").write_text( # PATHREF-OK: fixture
317 "value = 1\n", encoding="ascii"
318 )
319 (root / "scripts/tool dir/__pycache__").mkdir() # PATHREF-OK: fixture
320 (root / "scripts/tool dir/__pycache__/helper.pyc").write_bytes( # PATHREF-OK: fixture
321 b"ignored"
322 )
323 (root / "outside.txt").write_text("outside\n", encoding="ascii")
324
325
326def _extract_for_selftest(archive_path: Path, target: Path) -> None:
327 """Extract a helper-produced archive after validating every member name."""
328 with tarfile.open(archive_path, "r:") as archive:
329 for member in archive.getmembers():
330 destination = target / _safe_name(member.name)
331 if member.isdir():
332 destination.mkdir(parents=True, exist_ok=True)
333 destination.chmod(member.mode)
334 elif member.issym():
335 destination.parent.mkdir(parents=True, exist_ok=True)
336 destination.symlink_to(member.linkname)
337 elif member.isfile():
338 source = archive.extractfile(member)
339 if source is None:
340 message = f"cannot read archive member: {member.name}"
341 raise ContextError(message)
342 destination.parent.mkdir(parents=True, exist_ok=True)
343 destination.write_bytes(source.read())
344 destination.chmod(member.mode)
345 else:
346 message = f"unsupported archive member type: {member.name}"
347 raise ContextError(message)
348
349
350def _selftest_fixture(root: Path) -> list[str]:
351 """Run the exact create/verify path and return assertion failures."""
352 _git_init(root)
353 _write_fixture(root)
354 archive_one = root.parent / "context one.tar"
355 archive_two = root.parent / "context two.tar"
356 digest_one = create_archive(root, archive_one, (".devcontainer", "scripts"))
357 digest_two = create_archive(root, archive_two, (".devcontainer", "scripts"))
358 names = set(_archive_entries(archive_one))
359 failures: list[str] = []
360 required = {
361 ".devcontainer/Dockerfile",
362 "scripts/tracked.sh", # PATHREF-OK: fixture
363 "scripts/tracked-link", # PATHREF-OK: fixture
364 "scripts/tool dir/new helper.py", # PATHREF-OK: fixture
365 }
366 excluded = {
367 "scripts/deleted.py", # PATHREF-OK: fixture
368 "scripts/tool dir/__pycache__/helper.pyc", # PATHREF-OK: fixture
369 "outside.txt",
370 }
371 if not required.issubset(names):
372 failures.append("tracked, symlink, or spaced untracked input was omitted")
373 if names & excluded:
374 failures.append("deleted, ignored, or out-of-scope input entered the archive")
375 if digest_one != digest_two or archive_one.read_bytes() != archive_two.read_bytes():
376 failures.append("identical candidate inputs did not produce identical archive bytes")
377 extracted = root.parent / "extracted"
378 extracted.mkdir()
379 _extract_for_selftest(archive_one, extracted)
380 if verify_archive(archive_one, extracted):
381 failures.append("an exact extraction did not verify")
382 if (extracted / "scripts/tracked.sh").read_text(encoding="ascii") != ( # PATHREF-OK: fixture
383 "#!/bin/sh\necho worktree\n"
384 ):
385 failures.append("tracked worktree bytes were not archived")
386 if (extracted / "scripts/tracked.sh").stat().st_mode & 0o777 != ( # PATHREF-OK: fixture
387 EXECUTABLE_MODE
388 ):
389 failures.append("tracked executable mode was not preserved")
390 if not (extracted / "scripts/tracked-link").is_symlink(): # PATHREF-OK: fixture
391 failures.append("tracked symlink mode was not preserved")
392 return failures
393
394
395def _outer_repo_snapshot(root: Path) -> tuple[bytes, bytes, bytes, bytes, bytes]:
396 """Capture the outer repository state a nested fixture must not change."""
397 return (
398 _git(root, ["rev-parse", "HEAD"]),
399 (root / ".git/index").read_bytes(),
400 (root / ".git/config").read_bytes(),
401 _git(root, ["status", "--porcelain=v1", "-z"]),
402 (root / "outer.txt").read_bytes(),
403 )
404
405
406def _hook_environment_selftest(base: Path) -> list[str]:
407 """Prove hook-local Git routing cannot escape into an outer repository."""
408 base.mkdir()
409 outer = base / "outer"
410 _git_init(outer)
411 (outer / "outer.txt").write_bytes(b"outer-state\n")
412 _git(outer, ["add", "outer.txt"])
413 _git(outer, ["commit", "-qm", "outer seed"])
414 before = _outer_repo_snapshot(outer)
415 hook_env = {
416 "GIT_DIR": str(outer / ".git"),
417 "GIT_INDEX_FILE": str(outer / ".git/index"),
418 "GIT_PREFIX": "",
419 "GIT_WORK_TREE": str(outer),
420 }
421 previous = {name: os.environ.get(name) for name in hook_env}
422 failures: list[str] = []
423 try:
424 os.environ.update(hook_env)
425 failures.extend(_selftest_fixture(base / "inner"))
426 except (ContextError, OSError, tarfile.TarError) as exc:
427 failures.append(f"inner fixture failed under outer hook environment: {exc}")
428 finally:
429 for name, value in previous.items():
430 if value is None:
431 os.environ.pop(name, None)
432 else:
433 os.environ[name] = value
434 if _outer_repo_snapshot(outer) != before:
435 failures.append(
436 "inner fixture changed the outer repository HEAD/index/config/status/worktree"
437 )
438 reported = set(os.fsdecode(_git(outer, ["rev-parse", "--local-env-vars"])).splitlines())
439 if not reported.issubset(LOCAL_GIT_ENVIRONMENT):
440 failures.append("LOCAL_GIT_ENVIRONMENT omits a name from `git rev-parse --local-env-vars`")
441 return failures
442
443
444def run_selftest() -> int:
445 """Prove inclusion, exclusion, reproducibility, and drift detection."""
446 with tempfile.TemporaryDirectory(prefix="ra8-context-selftest-") as temp:
447 base = Path(temp)
448 failures = _selftest_fixture(base / "repo")
449 failures.extend(_hook_environment_selftest(base / "hook-env"))
450 archive = base / "context one.tar"
451 extracted = base / "extracted"
452 extra = extracted / "scripts/ignored.pyc" # PATHREF-OK: fixture
453 extra.write_bytes(b"residue")
454 findings = verify_archive(archive, extracted)
455 if "unexpected: scripts/ignored.pyc" not in findings: # PATHREF-OK: fixture
456 failures.append("an unexpected ignored-style residue did not fail verification")
457 extra.unlink()
458 target = extracted / "scripts/tracked.sh" # PATHREF-OK: fixture
459 target.chmod(REGULAR_MODE)
460 findings = verify_archive(archive, extracted)
461 if "mode mismatch: scripts/tracked.sh" not in findings: # PATHREF-OK: fixture
462 failures.append("executable mode drift did not fail verification")
463 if failures:
464 for failure in failures:
465 print(f"FAIL: {failure}", file=sys.stderr)
466 return EXIT_MISMATCH
467 print("stage_worktree_context.py selftest: PASS")
468 return 0
469
470
471def _parser() -> argparse.ArgumentParser:
472 """Build the command-line parser."""
473 parser = argparse.ArgumentParser(description=__doc__)
474 parser.add_argument("--selftest", action="store_true")
475 subparsers = parser.add_subparsers(dest="command")
476 create = subparsers.add_parser("create", help="create a deterministic candidate archive")
477 create.add_argument("--root", type=Path, required=True)
478 create.add_argument("--output", type=Path, required=True)
479 create.add_argument("paths", nargs="+")
480 verify = subparsers.add_parser("verify", help="compare an archive to an extracted directory")
481 verify.add_argument("--archive", type=Path, required=True)
482 verify.add_argument("--directory", type=Path, required=True)
483 return parser
484
485
486def main() -> int:
487 """Dispatch archive creation, exact verification, or the selftest."""
488 args = _parser().parse_args()
489 if args.selftest:
490 if args.command is not None:
491 print("--selftest cannot be combined with a command", file=sys.stderr)
492 return EXIT_USAGE
493 return run_selftest()
494 try:
495 if args.command == "create":
496 digest = create_archive(args.root.resolve(), args.output.resolve(), tuple(args.paths))
497 print(digest)
498 return 0
499 if args.command == "verify":
500 findings = verify_archive(args.archive, args.directory)
501 for finding in findings:
502 print(finding, file=sys.stderr)
503 return EXIT_MISMATCH if findings else 0
504 except (ContextError, OSError, tarfile.TarError) as exc:
505 print(f"stage_worktree_context.py: {exc}", file=sys.stderr)
506 return EXIT_USAGE
507 print("choose create, verify, or --selftest", file=sys.stderr)
508 return EXIT_USAGE
509
510
511if __name__ == "__main__":
512 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298