mirror of
https://github.com/ratspeak/C6-Reticulum-ASM
synced 2026-08-12 18:07:18 -04:00
Installs the full Godmode verifier stack — Cryptol 3.5.0, SAW 1.5, Binsec 0.11.1, angr 9.2.213 + pypcode 3.3.3, TLA+ Tools 2.19, Sail 0.20.1 + sail-riscv 4d5530b, fiat-crypto 412e8af, GHC 9.6.7, cabal 3.10.3.0, Coq 9.1.1 — symlinked into toolchain/local/bin via the new install.sh, with Python deps in toolchain/venv. ADR-0009 codifies the per-tier responsibility (A=algorithmic Cryptol/SAW, B=ct Binsec/Rel, C=binary-equivalence angr+pcode, D=state-machine TLA+, E=ISA reference Sail) and the comma-separated @verify path syntax the dispatcher now consumes. The Tier C-future path (true SAW-on-RV32 via macaw-riscv + a forked saw-script) is documented; GHC + cabal are pre-installed against that day. sha256_init flips from tested to verified — first crypto function to clear the new bar. Cryptol model proves the FIPS 180-4 §5.3.3 IVs, LE serialisation, and word_to_le_bytes bijection over [32]; SAW driver discharges them via z3; angr proves the RV32IMC binary writes the 8 IV words LE, zeros length_bits + block_len, leaves the partial-block buffer + pad untouched, and preserves all 14 callee- saved registers, returning within a bounded basic-block budget. End-to-end ./verify sha256_init runs in ~3 s. Cleanup found while sweeping: state/ files have no verifiable function so are skipped by parse_spec; @ct accepts an optional parenthesized rationale (matches existing AES annotations); five AES @tests fields pointed at non-existent per-function test files and now point at the combined ones that actually exercise them.
366 lines
13 KiB
Python
Executable file
366 lines
13 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Spec-block parser for assembly source files.
|
|
|
|
Per ADR-0007, every `src/**/*.S` file begins with a machine-readable spec block.
|
|
This parser extracts the block, validates every required field, and cross-checks
|
|
referenced files and ADRs against the filesystem.
|
|
|
|
Usage:
|
|
parse_spec.py <path/to/file.S> parse + validate, exit 0 on success
|
|
parse_spec.py --json <path/to/file.S> JSON output to stdout
|
|
parse_spec.py --all all .S files under src/
|
|
parse_spec.py --all --json JSON list of all spec blocks
|
|
|
|
Exits 0 if every parsed block validates; non-zero otherwise. Errors go to stderr
|
|
in human-readable form regardless of --json mode (so an agent reading JSON also
|
|
sees diagnostics).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
REQUIRED_FIELDS = (
|
|
"function",
|
|
"module",
|
|
"inputs",
|
|
"outputs",
|
|
"clobbers",
|
|
"preserves",
|
|
"stack",
|
|
"cycles",
|
|
"ct",
|
|
"spec",
|
|
"verify",
|
|
"tests",
|
|
"adrs",
|
|
"status",
|
|
)
|
|
|
|
VALID_STATUS = {"draft", "review", "tested", "verified"}
|
|
VALID_CT = {"required", "not-required"}
|
|
|
|
# Header / footer fence the spec block. The prefix changed from `;;` to `#`
|
|
# in ADR-0008 because RISC-V GAS does not treat `;;` as a comment. We accept
|
|
# both prefixes during the transition; new files should use `#`.
|
|
FENCE_RE = re.compile(r"^\s*(?:#|;;)\s*={8,}\s*$")
|
|
FIELD_RE = re.compile(
|
|
r"^\s*(?:#|;;)\s*@(?P<key>[a-zA-Z_]+)\s*:\s*(?P<value>.*?)\s*$"
|
|
)
|
|
COMMENT_RE = re.compile(r"^\s*(?:#|;;).*$")
|
|
ADR_FILE_RE = re.compile(r"^(\d{4})-[a-z0-9-]+\.md$")
|
|
KAT_ONLY_RE = re.compile(r"^kat-only(\s|$|;)")
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class SpecBlock:
|
|
"""Parsed spec block. All fields are strings as written; validation
|
|
interprets them.
|
|
"""
|
|
|
|
path: Path
|
|
fields: dict[str, str]
|
|
line_range: tuple[int, int] # 1-indexed, inclusive
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"path": str(self.path.relative_to(REPO_ROOT))
|
|
if self.path.is_absolute()
|
|
else str(self.path),
|
|
"fields": dict(self.fields),
|
|
"line_range": list(self.line_range),
|
|
}
|
|
|
|
|
|
class SpecError(Exception):
|
|
"""Raised when a spec block cannot be parsed (structural error). Validation
|
|
errors are returned as a list, not raised.
|
|
"""
|
|
|
|
|
|
def _strip_inline_comment(value: str) -> str:
|
|
"""Drop trailing `# ...` rationale comments from a field value."""
|
|
# Only strip a `#` that is preceded by whitespace, so values legitimately
|
|
# containing `#` (rare in our format) are not corrupted.
|
|
m = re.search(r"\s+#.*$", value)
|
|
return value[: m.start()].rstrip() if m else value.rstrip()
|
|
|
|
|
|
def parse_spec(path: Path) -> SpecBlock:
|
|
"""Parse the spec block at the top of an assembly file.
|
|
|
|
Raises SpecError if the block is missing or structurally malformed.
|
|
"""
|
|
text = path.read_text(encoding="utf-8")
|
|
lines = text.splitlines()
|
|
|
|
# Find the opening fence. We tolerate up to 5 leading blank lines or
|
|
# comment lines that are not part of a spec block (none in practice, but
|
|
# the rule is "spec block is at the top," not "line 1 is a fence").
|
|
start_idx: int | None = None
|
|
for i, line in enumerate(lines[:10]):
|
|
if FENCE_RE.match(line):
|
|
start_idx = i
|
|
break
|
|
|
|
if start_idx is None:
|
|
raise SpecError(f"{path}: no spec block fence found in first 10 lines")
|
|
|
|
# Find the closing fence after start.
|
|
end_idx: int | None = None
|
|
for j in range(start_idx + 1, len(lines)):
|
|
if FENCE_RE.match(lines[j]):
|
|
end_idx = j
|
|
break
|
|
|
|
if end_idx is None:
|
|
raise SpecError(f"{path}: spec block opening fence has no closing fence")
|
|
|
|
fields: dict[str, str] = {}
|
|
body = lines[start_idx + 1 : end_idx]
|
|
for k, line in enumerate(body, start=start_idx + 2): # 1-indexed line numbers
|
|
if not line.strip():
|
|
continue
|
|
m = FIELD_RE.match(line)
|
|
if not m:
|
|
# Tolerate unannotated comment lines inside the block (rationale text)
|
|
# only if they start with `;;`. Anything else is an error.
|
|
if COMMENT_RE.match(line):
|
|
continue
|
|
raise SpecError(f"{path}:{k}: malformed line inside spec block: {line!r}")
|
|
key = m.group("key")
|
|
value = _strip_inline_comment(m.group("value"))
|
|
if key in fields:
|
|
raise SpecError(f"{path}:{k}: duplicate field @{key}")
|
|
fields[key] = value
|
|
|
|
return SpecBlock(
|
|
path=path,
|
|
fields=fields,
|
|
line_range=(start_idx + 1, end_idx + 1),
|
|
)
|
|
|
|
|
|
def _accepted_adrs(repo_root: Path) -> set[str]:
|
|
"""Return the set of ADR numbers (zero-padded 4-digit strings) whose status
|
|
line begins with `Accepted` or `Accepted (`.
|
|
"""
|
|
adr_dir = repo_root / "docs" / "adr"
|
|
accepted: set[str] = set()
|
|
if not adr_dir.is_dir():
|
|
return accepted
|
|
for entry in adr_dir.iterdir():
|
|
m = ADR_FILE_RE.match(entry.name)
|
|
if not m:
|
|
continue
|
|
adr_num = m.group(1)
|
|
head = entry.read_text(encoding="utf-8").splitlines()[:6]
|
|
for line in head:
|
|
sline = line.strip().lower()
|
|
if sline.startswith("- **status:**") or sline.startswith("**status:**"):
|
|
if "accepted" in sline:
|
|
accepted.add(adr_num)
|
|
break
|
|
return accepted
|
|
|
|
|
|
def validate_spec(
|
|
spec: SpecBlock,
|
|
*,
|
|
repo_root: Path = REPO_ROOT,
|
|
accepted_adrs: set[str] | None = None,
|
|
) -> list[str]:
|
|
"""Return a list of human-readable validation errors. Empty means valid."""
|
|
errors: list[str] = []
|
|
f = spec.fields
|
|
rel = spec.path.relative_to(repo_root) if spec.path.is_absolute() else spec.path
|
|
|
|
# 1. Required fields present.
|
|
for name in REQUIRED_FIELDS:
|
|
if name not in f:
|
|
errors.append(f"{rel}: missing field @{name}")
|
|
if errors:
|
|
# Don't proceed with cross-checks if structural fields are missing.
|
|
return errors
|
|
|
|
# 2. @function matches the file basename.
|
|
expected_fn = spec.path.stem
|
|
if f["function"] != expected_fn:
|
|
errors.append(
|
|
f"{rel}: @function {f['function']!r} does not match file basename "
|
|
f"{expected_fn!r}"
|
|
)
|
|
|
|
# 3. @status is one of the legal values.
|
|
if f["status"] not in VALID_STATUS:
|
|
errors.append(
|
|
f"{rel}: @status {f['status']!r} not in {sorted(VALID_STATUS)}"
|
|
)
|
|
|
|
# 4. @ct is one of the legal values, optionally followed by a parenthesized
|
|
# rationale (e.g., "required (xtime is branch-free; loop is data-independent)").
|
|
ct_word = f["ct"].strip().split(" ", 1)[0].split("(", 1)[0].strip()
|
|
if ct_word not in VALID_CT:
|
|
errors.append(f"{rel}: @ct {f['ct']!r} not in {sorted(VALID_CT)}")
|
|
|
|
# 5. @stack is a non-negative integer.
|
|
try:
|
|
stack = int(f["stack"])
|
|
if stack < 0:
|
|
errors.append(f"{rel}: @stack {stack} is negative")
|
|
except ValueError:
|
|
errors.append(f"{rel}: @stack {f['stack']!r} is not an integer")
|
|
|
|
# 6. @cycles is an integer or "unbounded"; if unbounded, @ct must be not-required.
|
|
cycles_raw = f["cycles"]
|
|
cycles_unbounded = cycles_raw.strip().lower() == "unbounded"
|
|
if not cycles_unbounded:
|
|
# Permit forms like "~50", "bounded (~50)" with a digit somewhere — we
|
|
# only require there be at least one decimal number for tooling to use.
|
|
# Anything stricter forbids legitimate "approximate" annotations.
|
|
if not re.search(r"\d", cycles_raw):
|
|
errors.append(
|
|
f"{rel}: @cycles {cycles_raw!r} has no integer and is not 'unbounded'"
|
|
)
|
|
else:
|
|
if f["ct"] != "not-required":
|
|
errors.append(
|
|
f"{rel}: @cycles unbounded requires @ct: not-required (got {f['ct']!r})"
|
|
)
|
|
|
|
# 7. @adrs references exist as Accepted ADRs.
|
|
if accepted_adrs is None:
|
|
accepted_adrs = _accepted_adrs(repo_root)
|
|
adrs_field = f["adrs"].strip()
|
|
if adrs_field and adrs_field != "—":
|
|
for raw in re.split(r"[,\s]+", adrs_field):
|
|
if not raw:
|
|
continue
|
|
num = raw.zfill(4)
|
|
if not re.fullmatch(r"\d{4}", num):
|
|
errors.append(f"{rel}: @adrs entry {raw!r} is not a number")
|
|
continue
|
|
if num not in accepted_adrs:
|
|
errors.append(
|
|
f"{rel}: @adrs references ADR-{num} which is not Accepted"
|
|
)
|
|
|
|
# 8. @verify is either "kat-only [rationale]" or a comma-separated list of
|
|
# existing verifier files (per ADR-0009: .cry, .saw, .py, .tla, .cfg, .smt2).
|
|
verify_field = f["verify"].strip()
|
|
if KAT_ONLY_RE.match(verify_field):
|
|
# Require a rationale after kat-only — bare "kat-only" is too easy to
|
|
# leave on a function that should have a real verifier.
|
|
if verify_field.strip().lower() == "kat-only":
|
|
errors.append(
|
|
f"{rel}: @verify 'kat-only' must include a rationale "
|
|
f"(e.g. 'kat-only; entry-point semantics, no functional contract')"
|
|
)
|
|
else:
|
|
for raw in (p.strip() for p in verify_field.split(",")):
|
|
if not raw:
|
|
continue
|
|
verify_path = repo_root / raw
|
|
if not verify_path.exists():
|
|
errors.append(f"{rel}: @verify path {raw!r} does not exist")
|
|
|
|
# 9. @tests points at a real file (or "none" with rationale, future).
|
|
tests_field = f["tests"].strip()
|
|
tests_path = repo_root / tests_field
|
|
if not tests_path.exists():
|
|
errors.append(f"{rel}: @tests path {tests_field!r} does not exist")
|
|
|
|
# 10. @module matches the directory under src/ that the file lives in.
|
|
try:
|
|
rel_to_src = spec.path.resolve().relative_to((repo_root / "src").resolve())
|
|
# Module is everything up to (but not including) the file.
|
|
module_from_path = "/".join(rel_to_src.parts[:-1])
|
|
# A few exceptions: state/ files declare their owning module; include/
|
|
# files have no functions. We only check src/<module>/<function>.S.
|
|
if module_from_path and not module_from_path.startswith(("state", "include")):
|
|
if f["module"] != module_from_path:
|
|
errors.append(
|
|
f"{rel}: @module {f['module']!r} does not match directory "
|
|
f"{module_from_path!r}"
|
|
)
|
|
except ValueError:
|
|
# File outside src/. Skip the module-vs-path check.
|
|
pass
|
|
|
|
return errors
|
|
|
|
|
|
def _iter_src_files(repo_root: Path) -> list[Path]:
|
|
src = repo_root / "src"
|
|
if not src.is_dir():
|
|
return []
|
|
files: list[Path] = []
|
|
for path in sorted(src.rglob("*.S")):
|
|
# include/ holds pure equates; state/ holds .bss data declarations and
|
|
# rodata constants (no callable functions). Neither hosts a verifiable
|
|
# function, so spec blocks are not required for them.
|
|
if "include" in path.parts or "state" in path.parts:
|
|
continue
|
|
files.append(path)
|
|
return files
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("path", nargs="?", help="Single .S file to parse")
|
|
parser.add_argument("--all", action="store_true", help="Parse every .S in src/")
|
|
parser.add_argument("--json", action="store_true", help="Emit JSON to stdout")
|
|
parser.add_argument(
|
|
"--repo-root",
|
|
type=Path,
|
|
default=REPO_ROOT,
|
|
help="Repository root (default: parent of tools/)",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.all and args.path:
|
|
parser.error("--all and a positional path are mutually exclusive")
|
|
if not args.all and not args.path:
|
|
parser.error("provide a path or --all")
|
|
|
|
repo_root = args.repo_root.resolve()
|
|
accepted = _accepted_adrs(repo_root)
|
|
|
|
paths = (
|
|
_iter_src_files(repo_root) if args.all else [Path(args.path).resolve()]
|
|
)
|
|
|
|
results: list[dict] = []
|
|
all_errors: list[str] = []
|
|
for path in paths:
|
|
try:
|
|
spec = parse_spec(path)
|
|
except SpecError as exc:
|
|
all_errors.append(str(exc))
|
|
results.append({"path": str(path), "error": str(exc)})
|
|
continue
|
|
errors = validate_spec(spec, repo_root=repo_root, accepted_adrs=accepted)
|
|
all_errors.extend(errors)
|
|
entry = spec.to_dict()
|
|
entry["errors"] = errors
|
|
results.append(entry)
|
|
|
|
if args.json:
|
|
json.dump(results if args.all else results[0], sys.stdout, indent=2)
|
|
sys.stdout.write("\n")
|
|
|
|
for err in all_errors:
|
|
print(err, file=sys.stderr)
|
|
|
|
return 1 if all_errors else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|