4"""Parse HIL rig literals and delegate their values to the Bash authority.
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``.
12from __future__
import annotations
21from dataclasses
import dataclass
22from pathlib
import Path
23from typing
import NoReturn
25CONTRACT = Path(__file__).resolve().parent /
"lib" /
"rig_contract.sh"
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]+(?:#.*)?)?"
35INTERACTIVE_FIELDS = frozenset(
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_./:@=+-]+")
50class RigConfigError(ValueError):
51 """A protected rig file or its typed value violates the contract."""
54@dataclass(frozen=
True)
56 """One row reported by the single Bash contract authority."""
64@dataclass(frozen=True)
66 """Captured status and streams from one fixed-argv child process."""
73def _fail(message: str) -> NoReturn:
74 raise RigConfigError(message)
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:
81 (os.POSIX_SPAWN_DUP2, stdout_file.fileno(), 1),
82 (os.POSIX_SPAWN_DUP2, stderr_file.fileno(), 2),
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}")
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)
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)
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
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")
125def validate_value(name: str, value: str) ->
None:
126 """Require the Bash authority to accept one parsed 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: ')}")
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
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)
148 f
"{key} at line {number} must be one literal value without "
149 "expansion, command syntax, or an attached comment"
151 for value
in match.groups():
152 if value
is not None:
154 message =
"literal grammar matched without a value"
155 raise AssertionError(message)
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)
162 _fail(f
"RA8_BENCH_ACTORS at line {number} must be one single-quoted literal")
163 return match.group(1)
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):
170 if not line
or line.startswith(
"#"):
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")
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)
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")
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]] = []
205 while index < len(lines):
208 if include_interactive
and re.match(
r"^RA8_BENCH_ACTORS='", line):
210 while sum(part.count(
"'")
for part
in parts) < ACTOR_QUOTE_DELIMITERS:
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))
221def _read_source(path: Path) -> str:
222 """Read one owner-owned mode-0600 regular source without following links."""
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
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:
240 except (OSError, UnicodeError)
as error:
241 _fail(f
"cannot read rig environment as UTF-8: {error}")
243 if "descriptor" in locals()
and descriptor >= 0:
247def _parse_allowlisted_line(
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")
261 key, encoded = assignment.groups()
262 if key
not in allowed:
264 if key
in INTERACTIVE_FIELDS:
265 value = _parse_interactive_value(encoded, key, number)
267 value = _parse_literal(encoded, key, number)
269 value = schema[key].default
271 validate_value(key, value)
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
283def parse_rig_environment(
284 path: Path, required: frozenset[str], *, include_interactive: bool =
False
286 """Parse the allowlist, reject duplicates/malformed rows, and apply defaults."""
287 schema = load_schema()
288 unknown_required = required - schema.keys()
290 _fail(
"unknown required rig fields: " +
", ".join(sorted(unknown_required)))
291 found: dict[str, str] = {}
292 source = _read_source(path)
294 for number, raw
in _logical_rows(source, include_interactive):
295 if not raw.strip()
or raw.lstrip().startswith(
"#"):
297 parsed = _parse_allowlisted_line(raw, number, schema, include_interactive)
302 _fail(f
"duplicate {key} at line {number}")
305 missing = {name
for name
in required
if not found.get(name)}
307 _fail(
"missing required HIL keys: " +
", ".join(sorted(missing)))
308 _apply_defaults(found, schema)
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
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:
327 for name
in sorted(values):
328 stream.write(name.encode(
"ascii") + b
"\0")
329 stream.write(values[name].encode(
"utf-8") + b
"\0")
331 os.fsync(stream.fileno())
333 with os.fdopen(descriptor,
"w", encoding=
"utf-8")
as stream:
335 if output_format ==
"json":
336 json.dump(values, stream, sort_keys=
True)
338 elif output_format ==
"tsv":
339 for name
in sorted(values):
340 stream.write(f
"{name}\t{values[name]}\n")
342 message = f
"unknown protected output format: {output_format}"
343 raise AssertionError(message)
345 os.fsync(stream.fileno())
346 except OSError
as error:
347 _fail(f
"cannot write protected rig result: {error}")
349 if "descriptor" in locals()
and descriptor >= 0:
356 required: frozenset[str],
358 include_interactive: bool =
False,
360 with tempfile.TemporaryDirectory(prefix=
"ra8-rig-parser-")
as temporary:
361 source = Path(temporary) /
"rig.env"
362 source.write_text(text, encoding=
"utf-8")
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}")
370 _fail(f
"selftest unsafe fixture passed; expected {expected!r}")
373def _expect_path_failure(source: Path, expected: str, required: frozenset[str]) ->
None:
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}")
380 _fail(f
"selftest unsafe path passed; expected {expected!r}")
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"
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"
395 "PI_REPO=/home/ra8-hil/ra8-firmware\n",
399 output.write_text(
"", encoding=
"utf-8")
401 values = parse_rig_environment(source, required)
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",
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")
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"
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",
435 output.write_bytes(b
"")
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")
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")
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")
460 source.symlink_to(target)
461 _expect_path_failure(source,
"cannot read rig environment", required)
463 os.mkfifo(source, mode=0o600)
464 _expect_path_failure(source,
"regular non-symlink", required)
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)
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"),
493 for text, expected
in failures:
494 _expect_failure(text, expected, required)
495 _protected_path_selftest(required)
497 "PI_HOST=host\nJLINK_SN=1\nRA8_BENCH_WAIT=$(id)\n",
500 include_interactive=
True,
502 count = len(failures) + 4
503 print(f
"rig_env_parse.py --selftest: PASS (2 must-pass, {count} must-fire)")
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()
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")
522 if arguments.input
is None or arguments.output
is None:
523 parser.error(
"--input and --output are required")
524 values = parse_rig_environment(
526 frozenset(arguments.require),
527 include_interactive=arguments.include_interactive,
529 write_result(arguments.output, values, arguments.format)
530 except RigConfigError
as error:
531 print(f
"rig_env_parse: {error}", file=sys.stderr)
533 print(
"validated HIL keys: " +
", ".join(sorted(values)))
537if __name__ ==
"__main__":
538 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.