ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
rig_env_parse.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"""Parse HIL rig literals and delegate their values to the Bash authority.
5
6Both the interactive loader and Ansible use this parser. It recognizes a small
7literal-only subset of Bash assignments for the declared rig fields, ignores
8unrelated keys, and never executes or expands the input. Every resulting value
9is validated by fixed argv through ``lib/rig_contract.sh``.
10"""
11
12from __future__ import annotations
13
14import argparse
15import json
16import os
17import re
18import stat
19import sys
20import tempfile
21from dataclasses import dataclass
22from pathlib import Path
23from typing import NoReturn
24
25CONTRACT = Path(__file__).resolve().parent / "lib" / "rig_contract.sh"
26SCHEMA_COLUMNS = 4
27PROTECTED_MODE = 0o600
28ACTOR_FIELDS = 3
29ACTOR_QUOTE_DELIMITERS = 2
30ASSIGNMENT_RE = re.compile(r"(?:export )?([A-Za-z_][A-Za-z0-9_]*)=(.*)", re.DOTALL)
31LITERAL_RE = re.compile(
32 r"(?:([A-Za-z0-9_@./-]*)|'([A-Za-z0-9_@./-]*)'|\"([A-Za-z0-9_@./-]*)\")"
33 r"(?:[ \t]+(?:#.*)?)?"
34)
35INTERACTIVE_FIELDS = frozenset(
36 {
37 "C6_CONSOLE_TTY",
38 "RA8_BENCH_ACTORS",
39 "RA8_BENCH_WAIT",
40 "RA8_BENCH_WAIT_S",
41 "RA8_CONSOLE_TTY",
42 }
43)
44WAIT_RE = re.compile(r"[0-9]+(?:[smh])?")
45TTY_RE = re.compile(r"/dev/[A-Za-z0-9_.:/-]+")
46ACTOR_NAME_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*")
47TRANSPORT_WORD_RE = re.compile(r"[A-Za-z0-9_./:@=+-]+")
48
49
50class RigConfigError(ValueError):
51 """A protected rig file or its typed value violates the contract."""
52
53
54@dataclass(frozen=True)
55class FieldSpec:
56 """One row reported by the single Bash contract authority."""
57
58 name: str
59 kind: str
60 required: bool
61 default: str
62
63
64@dataclass(frozen=True)
65class CommandResult:
66 """Captured status and streams from one fixed-argv child process."""
67
68 returncode: int
69 stdout: str
70 stderr: str
71
72
73def _fail(message: str) -> NoReturn:
74 raise RigConfigError(message)
75
76
77def run_fixed_command(arguments: tuple[str, ...], environment: dict[str, str]) -> CommandResult:
78 """Run fixed argv through posix_spawn, capturing bytes without a shell."""
79 with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
80 actions = (
81 (os.POSIX_SPAWN_DUP2, stdout_file.fileno(), 1),
82 (os.POSIX_SPAWN_DUP2, stderr_file.fileno(), 2),
83 )
84 try:
85 process = os.posix_spawn(arguments[0], arguments, environment, file_actions=actions)
86 _, status = os.waitpid(process, 0)
87 except OSError as error:
88 _fail(f"fixed command could not run: {error}")
89 stdout_file.seek(0)
90 stderr_file.seek(0)
91 stdout = stdout_file.read().decode("utf-8", errors="replace")
92 stderr = stderr_file.read().decode("utf-8", errors="replace")
93 return CommandResult(os.waitstatus_to_exitcode(status), stdout, stderr)
94
95
96def _run_contract(*arguments: str) -> CommandResult:
97 """Invoke the fixed adjacent authority without shell startup channels."""
98 environment = os.environ.copy()
99 environment.pop("BASH_ENV", None)
100 environment.pop("ENV", None)
101 return run_fixed_command(("/bin/bash", "-p", str(CONTRACT), *arguments), environment)
102
103
104def load_schema() -> dict[str, FieldSpec]:
105 """Read the field list, kinds, required state, and defaults from Bash."""
106 result = _run_contract("--describe")
107 if result.returncode != 0:
108 _fail("rig contract description failed")
109 schema: dict[str, FieldSpec] = {}
110 for number, line in enumerate(result.stdout.splitlines(), 1):
111 parts = line.split("\t")
112 if len(parts) != SCHEMA_COLUMNS:
113 _fail(f"rig contract description line {number} is malformed")
114 name, kind, presence, default = parts
115 if name in schema:
116 _fail(f"rig contract description duplicates {name}")
117 if presence not in {"required", "optional"}:
118 _fail(f"rig contract description has invalid presence for {name}")
119 schema[name] = FieldSpec(name, kind, presence == "required", default)
120 if set(schema) != {"PI_HOST", "JLINK_SN", "JLINK_DEVICE", "PI_REPO"}:
121 _fail("rig contract description does not declare the exact field set")
122 return schema
123
124
125def validate_value(name: str, value: str) -> None:
126 """Require the Bash authority to accept one parsed value."""
127 if "\0" in value:
128 _fail(f"{name} contains a NUL byte")
129 result = _run_contract("--validate", name, value)
130 if result.returncode != 0:
131 reason = result.stderr.strip().splitlines()
132 detail = reason[-1] if reason else "typed validation failed"
133 _fail(f"{name}: {detail.removeprefix('error: ')}")
134
135
136def _malformed_allowlisted_key(line: str, allowed: frozenset[str]) -> str | None:
137 """Return a declared key when a row resembles but is not our grammar."""
138 names = "|".join(re.escape(name) for name in sorted(allowed))
139 match = re.match(rf"^[ \t]*(?:export[ \t]+)?({names})\b", line)
140 return match.group(1) if match is not None else None
141
142
143def _parse_literal(encoded: str, key: str, number: int) -> str:
144 """Decode one complete, expansion-free Bash literal subset."""
145 match = LITERAL_RE.fullmatch(encoded)
146 if match is None:
147 _fail(
148 f"{key} at line {number} must be one literal value without "
149 "expansion, command syntax, or an attached comment"
150 )
151 for value in match.groups():
152 if value is not None:
153 return value
154 message = "literal grammar matched without a value"
155 raise AssertionError(message)
156
157
158def _parse_actor_literal(encoded: str, number: int) -> str:
159 """Decode the documented multiline single-quoted actor roster."""
160 match = re.fullmatch(r"'([^']*)'(?:[ \t]+(?:#.*)?)?", encoded, re.DOTALL)
161 if match is None:
162 _fail(f"RA8_BENCH_ACTORS at line {number} must be one single-quoted literal")
163 return match.group(1)
164
165
166def _validate_actor_roster(value: str, number: int) -> None:
167 """Reject shell syntax before bench_contention consumes transport words."""
168 for offset, raw in enumerate(value.splitlines(), 1):
169 line = raw.strip()
170 if not line or line.startswith("#"):
171 continue
172 parts = [part.strip() for part in line.split("|")]
173 if len(parts) != ACTOR_FIELDS:
174 _fail(f"RA8_BENCH_ACTORS line {number + offset} must contain two pipes")
175 name, host, transport = parts
176 if ACTOR_NAME_RE.fullmatch(name) is None:
177 _fail(f"RA8_BENCH_ACTORS line {number + offset} has an invalid name")
178 validate_value("PI_HOST", host)
179 words = transport.split()
180 if not words or any(TRANSPORT_WORD_RE.fullmatch(word) is None for word in words):
181 _fail(f"RA8_BENCH_ACTORS line {number + offset} has unsafe transport words")
182
183
184def _parse_interactive_value(encoded: str, key: str, number: int) -> str:
185 """Decode and validate one explicitly supported non-secret workstation field."""
186 if key == "RA8_BENCH_ACTORS":
187 value = _parse_actor_literal(encoded, number)
188 _validate_actor_roster(value, number)
189 return value
190 value = _parse_literal(encoded, key, number)
191 if key == "RA8_BENCH_WAIT" and value and WAIT_RE.fullmatch(value) is None:
192 _fail(f"{key} at line {number} must be seconds or a duration ending in s/m/h")
193 if key == "RA8_BENCH_WAIT_S" and value and not value.isdecimal():
194 _fail(f"{key} at line {number} must be decimal seconds")
195 if key.endswith("_CONSOLE_TTY") and value and TTY_RE.fullmatch(value) is None:
196 _fail(f"{key} at line {number} must be one absolute /dev path")
197 return value
198
199
200def _logical_rows(source: str, include_interactive: bool) -> list[tuple[int, str]]:
201 """Join only the documented multiline actor literal; preserve other rows."""
202 lines = source.splitlines()
203 rows: list[tuple[int, str]] = []
204 index = 0
205 while index < len(lines):
206 number = index + 1
207 line = lines[index]
208 if include_interactive and re.match(r"^RA8_BENCH_ACTORS='", line):
209 parts = [line]
210 while sum(part.count("'") for part in parts) < ACTOR_QUOTE_DELIMITERS:
211 index += 1
212 if index >= len(lines):
213 _fail(f"RA8_BENCH_ACTORS at line {number} has no closing quote")
214 parts.append(lines[index])
215 line = "\n".join(parts)
216 rows.append((number, line))
217 index += 1
218 return rows
219
220
221def _read_source(path: Path) -> str:
222 """Read one owner-owned mode-0600 regular source without following links."""
223 flags = os.O_RDONLY
224 if hasattr(os, "O_CLOEXEC"):
225 flags |= os.O_CLOEXEC
226 if hasattr(os, "O_NOFOLLOW"):
227 flags |= os.O_NOFOLLOW
228 if hasattr(os, "O_NONBLOCK"):
229 flags |= os.O_NONBLOCK
230 try:
231 descriptor = os.open(path, flags)
232 info = os.fstat(descriptor)
233 if not stat.S_ISREG(info.st_mode):
234 _fail("rig environment input must be a regular non-symlink file")
235 if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != PROTECTED_MODE:
236 _fail("rig environment input must be owner-owned mode 0600")
237 with os.fdopen(descriptor, encoding="utf-8") as stream:
238 descriptor = -1
239 return stream.read()
240 except (OSError, UnicodeError) as error:
241 _fail(f"cannot read rig environment as UTF-8: {error}")
242 finally:
243 if "descriptor" in locals() and descriptor >= 0:
244 os.close(descriptor)
245
246
247def _parse_allowlisted_line(
248 line: str,
249 number: int,
250 schema: dict[str, FieldSpec],
251 include_interactive: bool,
252) -> tuple[str, str] | None:
253 """Return one parsed allowlisted assignment or None for unrelated input."""
254 allowed = frozenset(schema) | (INTERACTIVE_FIELDS if include_interactive else frozenset())
255 assignment = ASSIGNMENT_RE.fullmatch(line)
256 if assignment is None:
257 malformed = _malformed_allowlisted_key(line, allowed)
258 if malformed is not None:
259 _fail(f"{malformed} at line {number} has unsupported assignment syntax")
260 return None
261 key, encoded = assignment.groups()
262 if key not in allowed:
263 return None
264 if key in INTERACTIVE_FIELDS:
265 value = _parse_interactive_value(encoded, key, number)
266 return key, value
267 value = _parse_literal(encoded, key, number)
268 if not value:
269 value = schema[key].default
270 if value:
271 validate_value(key, value)
272 return key, value
273
274
275def _apply_defaults(found: dict[str, str], schema: dict[str, FieldSpec]) -> None:
276 """Fill and validate optional declared defaults in-place."""
277 for name, spec in schema.items():
278 if name not in found and spec.default:
279 validate_value(name, spec.default)
280 found[name] = spec.default
281
282
283def parse_rig_environment(
284 path: Path, required: frozenset[str], *, include_interactive: bool = False
285) -> dict[str, str]:
286 """Parse the allowlist, reject duplicates/malformed rows, and apply defaults."""
287 schema = load_schema()
288 unknown_required = required - schema.keys()
289 if unknown_required:
290 _fail("unknown required rig fields: " + ", ".join(sorted(unknown_required)))
291 found: dict[str, str] = {}
292 source = _read_source(path)
293
294 for number, raw in _logical_rows(source, include_interactive):
295 if not raw.strip() or raw.lstrip().startswith("#"):
296 continue
297 parsed = _parse_allowlisted_line(raw, number, schema, include_interactive)
298 if parsed is None:
299 continue
300 key, value = parsed
301 if key in found:
302 _fail(f"duplicate {key} at line {number}")
303 found[key] = value
304
305 missing = {name for name in required if not found.get(name)}
306 if missing:
307 _fail("missing required HIL keys: " + ", ".join(sorted(missing)))
308 _apply_defaults(found, schema)
309 return found
310
311
312def write_result(path: Path, values: dict[str, str], output_format: str = "json") -> None:
313 """Write JSON through the preallocated protected regular-file descriptor."""
314 flags = os.O_WRONLY | os.O_TRUNC
315 if hasattr(os, "O_NOFOLLOW"):
316 flags |= os.O_NOFOLLOW
317 try:
318 descriptor = os.open(path, flags)
319 info = os.fstat(descriptor)
320 if not stat.S_ISREG(info.st_mode):
321 _fail("rig result must be a regular file")
322 if info.st_uid != os.getuid() or info.st_mode & 0o077:
323 _fail("rig result must be owner-owned mode 0600")
324 if output_format == "nul":
325 with os.fdopen(descriptor, "wb") as stream:
326 descriptor = -1
327 for name in sorted(values):
328 stream.write(name.encode("ascii") + b"\0")
329 stream.write(values[name].encode("utf-8") + b"\0")
330 stream.flush()
331 os.fsync(stream.fileno())
332 return
333 with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
334 descriptor = -1
335 if output_format == "json":
336 json.dump(values, stream, sort_keys=True)
337 stream.write("\n")
338 elif output_format == "tsv":
339 for name in sorted(values):
340 stream.write(f"{name}\t{values[name]}\n")
341 else:
342 message = f"unknown protected output format: {output_format}"
343 raise AssertionError(message)
344 stream.flush()
345 os.fsync(stream.fileno())
346 except OSError as error:
347 _fail(f"cannot write protected rig result: {error}")
348 finally:
349 if "descriptor" in locals() and descriptor >= 0:
350 os.close(descriptor)
351
352
353def _expect_failure(
354 text: str,
355 expected: str,
356 required: frozenset[str],
357 *,
358 include_interactive: bool = False,
359) -> None:
360 with tempfile.TemporaryDirectory(prefix="ra8-rig-parser-") as temporary:
361 source = Path(temporary) / "rig.env"
362 source.write_text(text, encoding="utf-8")
363 source.chmod(0o600)
364 try:
365 parse_rig_environment(source, required, include_interactive=include_interactive)
366 except RigConfigError as error:
367 if expected not in str(error):
368 _fail(f"selftest expected {expected!r}, got {error!s}")
369 else:
370 _fail(f"selftest unsafe fixture passed; expected {expected!r}")
371
372
373def _expect_path_failure(source: Path, expected: str, required: frozenset[str]) -> None:
374 try:
375 parse_rig_environment(source, required)
376 except RigConfigError as error:
377 if expected not in str(error):
378 _fail(f"selftest expected {expected!r}, got {error!s}")
379 else:
380 _fail(f"selftest unsafe path passed; expected {expected!r}")
381
382
383def _valid_parse_selftest(required: frozenset[str]) -> None:
384 """Exercise one complete valid parse and protected output round trip."""
385 with tempfile.TemporaryDirectory(prefix="ra8-rig-parser-") as temporary:
386 root = Path(temporary)
387 source = root / "rig.env"
388 output = root / "result.json"
389 source.write_text(
390 "# unrelated secrets remain unreachable\n"
391 "TAPO_PASS='not copied'\n"
392 "export PI_HOST='sikar@10.0.40.103' # bench route\n"
393 "JLINK_SN=123456789\n"
394 "JLINK_DEVICE=\n"
395 "PI_REPO=/home/ra8-hil/ra8-firmware\n",
396 encoding="utf-8",
397 )
398 source.chmod(0o600)
399 output.write_text("", encoding="utf-8")
400 output.chmod(0o600)
401 values = parse_rig_environment(source, required)
402 expected = {
403 "PI_HOST": "sikar@10.0.40.103",
404 "JLINK_SN": "123456789",
405 "JLINK_DEVICE": "R7KA8D2KF_CPU0",
406 "PI_REPO": "/home/ra8-hil/ra8-firmware",
407 }
408 if values != expected:
409 _fail(f"selftest valid parse differed: {values!r}")
410 write_result(output, values)
411 if json.loads(output.read_text(encoding="utf-8")) != expected:
412 _fail("selftest protected result did not round-trip")
413 write_result(output, values, "tsv")
414 expected_rows = "".join(f"{name}\t{expected[name]}\n" for name in sorted(expected))
415 if output.read_text(encoding="utf-8") != expected_rows:
416 _fail("selftest protected TSV result did not round-trip")
417
418
419def _interactive_parse_selftest(required: frozenset[str]) -> None:
420 """Preserve documented non-secret workstation controls without execution."""
421 with tempfile.TemporaryDirectory(prefix="ra8-rig-parser-") as temporary:
422 root = Path(temporary)
423 source = root / "rig.env"
424 output = root / "result.nul"
425 source.write_text(
426 "PI_HOST=host\nJLINK_SN=1\nRA8_BENCH_WAIT=10m\n"
427 "RA8_CONSOLE_TTY=/dev/cu.usbmodem1\n"
428 "RA8_BENCH_ACTORS='\n"
429 "local | host | /bin/bash -p -s\n"
430 "dev | user@dev.local | /usr/bin/ssh dev /bin/bash -p -s\n"
431 "'\nTAPO_PASS='not exported'\n",
432 encoding="utf-8",
433 )
434 source.chmod(0o600)
435 output.write_bytes(b"")
436 output.chmod(0o600)
437 values = parse_rig_environment(source, required, include_interactive=True)
438 if values.get("RA8_BENCH_WAIT") != "10m" or "TAPO_PASS" in values:
439 _fail("selftest interactive allowlist leaked or lost a field")
440 if "dev | user@dev.local" not in values.get("RA8_BENCH_ACTORS", ""):
441 _fail("selftest multiline actor roster did not round-trip")
442 write_result(output, values, "nul")
443 fields = output.read_bytes().split(b"\0")
444 if fields[-1] or b"RA8_BENCH_ACTORS" not in fields:
445 _fail("selftest protected NUL result did not round-trip")
446
447
448def _protected_path_selftest(required: frozenset[str]) -> None:
449 """Reject permissive and symlinked protected input paths."""
450 with tempfile.TemporaryDirectory(prefix="ra8-rig-parser-") as temporary:
451 root = Path(temporary)
452 source = root / "rig.env"
453 source.write_text("PI_HOST=host\nJLINK_SN=1\n", encoding="utf-8")
454 source.chmod(0o644)
455 _expect_path_failure(source, "owner-owned mode 0600", required)
456 target = root / "target.env"
457 target.write_text("PI_HOST=host\nJLINK_SN=1\n", encoding="utf-8")
458 target.chmod(0o600)
459 source.unlink()
460 source.symlink_to(target)
461 _expect_path_failure(source, "cannot read rig environment", required)
462 source.unlink()
463 os.mkfifo(source, mode=0o600)
464 _expect_path_failure(source, "regular non-symlink", required)
465
466
467def selftest() -> None:
468 """Exercise parser and delegated value validation in both directions."""
469 required = frozenset({"PI_HOST", "JLINK_SN"})
470 _valid_parse_selftest(required)
471 _interactive_parse_selftest(required)
472 failures = (
473 ("PI_HOST=host\nPI_HOST=other\nJLINK_SN=1\n", "duplicate PI_HOST"),
474 ("export PI_HOST\nJLINK_SN=1\n", "unsupported assignment syntax"),
475 ("PI_HOST = host\nJLINK_SN=1\n", "unsupported assignment syntax"),
476 (" PI_HOST=host\nJLINK_SN=1\n", "unsupported assignment syntax"),
477 ("export PI_HOST=host\nJLINK_SN=1\n", "unsupported assignment syntax"),
478 ("PI_HOST='unterminated\nJLINK_SN=1\n", "one literal value"),
479 ("PI_HOST=host words\nJLINK_SN=1\n", "one literal value"),
480 ("PI_HOST=host#not-a-comment\nJLINK_SN=1\n", "one literal value"),
481 ("PI_HOST=$(hostname)\nJLINK_SN=1\n", "one literal value"),
482 ("PI_HOST=`hostname`\nJLINK_SN=1\n", "one literal value"),
483 ("PI_HOST=$HOSTNAME\nJLINK_SN=1\n", "one literal value"),
484 ("PI_HOST=host; id\nJLINK_SN=1\n", "one literal value"),
485 ("PI_HOST=-oProxyCommand=bad\nJLINK_SN=1\n", "one literal value"),
486 ("PI_HOST=user@@host\nJLINK_SN=1\n", "one user separator"),
487 ("PI_HOST=1.2.3.999\nJLINK_SN=1\n", "malformed IPv4"),
488 ("PI_HOST=host\nJLINK_SN=-1\n", "cannot start with punctuation"),
489 ("PI_HOST=host\nJLINK_SN=1\nPI_REPO=../repo\n", "unsafe path segment"),
490 ("PI_HOST=host\nJLINK_SN=1\nPI_REPO=repo'bad\n", "one literal value"),
491 ("JLINK_SN=1\n", "missing required HIL keys"),
492 )
493 for text, expected in failures:
494 _expect_failure(text, expected, required)
495 _protected_path_selftest(required)
496 _expect_failure(
497 "PI_HOST=host\nJLINK_SN=1\nRA8_BENCH_WAIT=$(id)\n",
498 "one literal value",
499 required,
500 include_interactive=True,
501 )
502 count = len(failures) + 4
503 print(f"rig_env_parse.py --selftest: PASS (2 must-pass, {count} must-fire)")
504
505
506def main() -> int:
507 """Run parser selftests or validate one protected input/output pair."""
508 parser = argparse.ArgumentParser(description=__doc__)
509 parser.add_argument("--selftest", action="store_true")
510 parser.add_argument("--input", type=Path)
511 parser.add_argument("--output", type=Path)
512 parser.add_argument("--format", choices=("json", "nul", "tsv"), default="json")
513 parser.add_argument("--include-interactive", action="store_true")
514 parser.add_argument("--require", action="append", default=[])
515 arguments = parser.parse_args()
516 try:
517 if arguments.selftest:
518 if arguments.input is not None or arguments.output is not None:
519 parser.error("--selftest does not accept input/output")
520 selftest()
521 return 0
522 if arguments.input is None or arguments.output is None:
523 parser.error("--input and --output are required")
524 values = parse_rig_environment(
525 arguments.input,
526 frozenset(arguments.require),
527 include_interactive=arguments.include_interactive,
528 )
529 write_result(arguments.output, values, arguments.format)
530 except RigConfigError as error:
531 print(f"rig_env_parse: {error}", file=sys.stderr)
532 return 2
533 print("validated HIL keys: " + ", ".join(sorted(values)))
534 return 0
535
536
537if __name__ == "__main__":
538 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298