Files
Ulf Wiger 53eaa3056a Add ASN.1-to-Java codegen and RLP codec for spend/signed tx subset.
Generate typed records from GajumaruChainObjects.asn, implement Asn1Rlp
and BasicEncoders matching Erlang static serialization, and add golden-vector
equivalence tests against gmser_chain_objects output.
2026-07-22 11:31:15 +02:00

471 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Generate Java record declarations from GajumaruChainObjects.asn
(or any module produced by gmser_schema_export).
This is intentionally a thin, schema-shaped codegen: it does NOT emit a
BER/DER codec. The abstract types are for typed construction; RLP remains
the wire format (see swiss.qpq.gajumaru.core.encoding.RLP).
Usage (from gajumaru-core/):
bin/asn1_to_java \\
--asn ../../gajumaru/asn1_generated/GajumaruChainObjects.asn \\
--types Id,SignedTxV1,SpendTxV1
bin/asn1_to_java --asn ... --all
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional, Tuple, Union
# ---------------------------------------------------------------------------
# ASN.1 model (minimal)
# ---------------------------------------------------------------------------
AsnType = Union[
"PrimitiveType",
"NamedType",
"SequenceType",
"SequenceOfType",
]
@dataclass
class PrimitiveType:
kind: str # integer, octet_string, boolean, fixed_integer
constraint: Optional[str] = None # e.g. "0..255" or "12"
@dataclass
class NamedType:
name: str # Id, BigInt, SpendTxV1, ...
@dataclass
class SequenceField:
name: str
type: AsnType
@dataclass
class SequenceType:
fields: List[SequenceField]
@dataclass
class SequenceOfType:
elem: AsnType
@dataclass
class TypeDef:
name: str
type: AsnType
comment: str = ""
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
COMMENT_RE = re.compile(r"--[^\n]*")
TYPE_DEF_RE = re.compile(
r"([A-Z][A-Za-z0-9]*)\s*::=\s*",
)
def strip_comments(text: str) -> str:
return COMMENT_RE.sub("", text)
def parse_module(text: str) -> dict[str, TypeDef]:
text = strip_comments(text)
# Drop module wrapper noise; keep type assignments only.
begin = text.find("BEGIN")
end = text.rfind("END")
if begin >= 0 and end > begin:
text = text[begin + 5 : end]
defs: dict[str, TypeDef] = {}
pos = 0
while True:
m = TYPE_DEF_RE.search(text, pos)
if not m:
break
name = m.group(1)
start = m.end()
# Find next top-level type def or EOF
nxt = TYPE_DEF_RE.search(text, start)
body = text[start : nxt.start() if nxt else len(text)].strip()
# Trim trailing junk
body = body.rstrip().rstrip(";").strip()
try:
asn_type = parse_type(body)
except Exception as e:
raise SystemExit(f"Failed to parse type {name}: {e}\nBody:\n{body[:200]}") from e
defs[name] = TypeDef(name=name, type=asn_type)
pos = nxt.start() if nxt else len(text)
return defs
def parse_type(s: str) -> AsnType:
s = s.strip()
if s.startswith("SEQUENCE OF"):
rest = s[len("SEQUENCE OF") :].strip()
return SequenceOfType(parse_type(rest))
if s.startswith("SEQUENCE"):
rest = s[len("SEQUENCE") :].strip()
if not rest.startswith("{"):
raise ValueError(f"expected SEQUENCE {{...}}, got: {s[:60]}")
inner = extract_braces(rest)
return SequenceType(parse_fields(inner))
if s.startswith("INTEGER"):
rest = s[len("INTEGER") :].strip()
if rest.startswith("(") and rest.endswith(")"):
c = rest[1:-1].strip()
if ".." in c or c == "MAX" or c.endswith("MAX"):
return PrimitiveType("integer", c)
# single value constraint: fixed integer
if re.fullmatch(r"\d+", c):
return PrimitiveType("fixed_integer", c)
return PrimitiveType("integer", c)
return PrimitiveType("integer")
if s.startswith("OCTET STRING"):
rest = s[len("OCTET STRING") :].strip()
c = None
if rest.startswith("(") and ")" in rest:
c = rest[rest.find("(") + 1 : rest.rfind(")")]
return PrimitiveType("octet_string", c)
if s == "BOOLEAN":
return PrimitiveType("boolean")
if re.fullmatch(r"[A-Z][A-Za-z0-9]*", s):
return NamedType(s)
raise ValueError(f"unsupported type expression: {s[:80]!r}")
def extract_braces(s: str) -> str:
"""s starts with '{'; return content inside matching braces."""
assert s[0] == "{"
depth = 0
for i, ch in enumerate(s):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return s[1:i]
raise ValueError("unbalanced braces")
def parse_fields(body: str) -> List[SequenceField]:
"""Parse 'name Type, name Type' with nested braces."""
fields: List[SequenceField] = []
parts = split_top_level(body, ",")
for part in parts:
part = part.strip()
if not part:
continue
# field name is first token (lowercase start in our export)
m = re.match(r"([A-Za-z][A-Za-z0-9]*)\s+(.+)$", part, re.DOTALL)
if not m:
raise ValueError(f"bad field: {part[:60]!r}")
fname, ftype = m.group(1), m.group(2).strip()
fields.append(SequenceField(fname, parse_type(ftype)))
return fields
def split_top_level(s: str, sep: str) -> List[str]:
parts: List[str] = []
depth = 0
start = 0
for i, ch in enumerate(s):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
elif ch == sep and depth == 0:
parts.append(s[start:i])
start = i + 1
parts.append(s[start:])
return parts
# ---------------------------------------------------------------------------
# Java emission
# ---------------------------------------------------------------------------
HEADER = """\
/*
* Generated by bin/asn1_to_java from GajumaruChainObjects.asn.
* Do not edit by hand — regenerate from the ASN.1 schema.
*
* These types are abstract syntax (headers). Wire encoding is RLP
* via swiss.qpq.gajumaru.core.encoding.RLP / Asn1Rlp, not BER/DER.
*/
"""
def java_type_name(name: str) -> str:
return name
def field_to_java(
f: SequenceField,
owner: str,
nested: List[Tuple[str, SequenceType]],
) -> Tuple[str, str]:
"""Return (javaType, fieldName). May append nested type defs."""
jtype = asn_to_java(f.type, owner, f.name, nested)
return jtype, f.name
def asn_to_java(
t: AsnType,
owner: str,
field_name: str,
nested: List[Tuple[str, SequenceType]],
) -> str:
if isinstance(t, NamedType):
# Built-in aliases
if t.name == "BigInt":
return "java.math.BigInteger"
if t.name in ("Uint8", "Uint16"):
return "int"
if t.name == "Uint32":
return "long"
if t.name in ("Uint64", "Uint128"):
return "java.math.BigInteger"
return t.name
if isinstance(t, PrimitiveType):
if t.kind == "boolean":
return "boolean"
if t.kind == "octet_string":
return "byte[]"
if t.kind == "fixed_integer":
return "int"
if t.kind == "integer":
# constrained small ranges map to int/long when obvious
if t.constraint in ("0..255", "0..65535"):
return "int"
if t.constraint == "0..4294967295":
return "long"
return "java.math.BigInteger"
raise ValueError(t)
if isinstance(t, SequenceOfType):
elem = asn_to_java(t.elem, owner, field_name + "Elem", nested)
return f"java.util.List<{box(elem)}>"
if isinstance(t, SequenceType):
nested_name = f"{owner}_{capitalize(field_name)}"
nested.append((nested_name, t))
# also need to resolve nested field types recursively for emission
return nested_name
raise ValueError(f"unknown type {t}")
def box(jtype: str) -> str:
return {
"int": "Integer",
"long": "Long",
"boolean": "Boolean",
"byte[]": "byte[]", # List<byte[]> is awkward but ok for now
}.get(jtype, jtype)
def capitalize(s: str) -> str:
return s[:1].upper() + s[1:] if s else s
def emit_sequence_record(
name: str,
seq: SequenceType,
package: str,
all_nested: List[Tuple[str, SequenceType]],
) -> str:
nested: List[Tuple[str, SequenceType]] = []
components = []
constants = []
for f in seq.fields:
jtype, jname = field_to_java(f, name, nested)
components.append(f" {jtype} {jname}")
if isinstance(f.type, PrimitiveType) and f.type.kind == "fixed_integer":
if f.name == "tag":
constants.append(f" public static final int TAG = {f.type.constraint};")
elif f.name == "vsn":
constants.append(f" public static final int VSN = {f.type.constraint};")
all_nested.extend(nested)
body = ",\n".join(components)
const_block = ("\n" + "\n".join(constants) + "\n") if constants else ""
return (
f"package {package};\n\n"
f"{HEADER}"
f"public record {name}(\n{body}\n) {{\n"
f"{const_block}"
f"}}\n"
)
def emit_alias(name: str, t: AsnType, package: str) -> Optional[str]:
"""Emit a tiny holder for INTEGER aliases we don't map away."""
# We map BigInt/Uint* at use sites; still emit Id as a record.
if name in ("BigInt", "Uint8", "Uint16", "Uint32", "Uint64", "Uint128"):
return None
if isinstance(t, SequenceType):
return None # handled elsewhere
if isinstance(t, PrimitiveType) and t.kind == "integer":
# skip pure aliases
return None
return None
def collect_dependencies(name: str, defs: dict[str, TypeDef], acc: set[str]) -> None:
if name in acc or name not in defs:
return
acc.add(name)
t = defs[name].type
walk_deps(t, defs, acc)
def walk_deps(t: AsnType, defs: dict[str, TypeDef], acc: set[str]) -> None:
if isinstance(t, NamedType):
if t.name in defs:
collect_dependencies(t.name, defs, acc)
elif isinstance(t, SequenceOfType):
walk_deps(t.elem, defs, acc)
elif isinstance(t, SequenceType):
for f in t.fields:
walk_deps(f.type, defs, acc)
def generate(
defs: dict[str, TypeDef],
wanted: List[str],
package: str,
out_dir: Path,
) -> List[Path]:
# Always include named deps of wanted types
selected: set[str] = set()
for w in wanted:
if w not in defs:
raise SystemExit(f"Unknown type {w}. Available: {', '.join(sorted(defs))}")
collect_dependencies(w, defs, selected)
out_dir.mkdir(parents=True, exist_ok=True)
written: List[Path] = []
# Emit in dependency-friendly order: common first
order = sorted(selected, key=lambda n: (0 if n == "Id" else 1, n))
for name in order:
tdef = defs[name]
t = tdef.type
if isinstance(t, SequenceType):
nested_acc: List[Tuple[str, SequenceType]] = []
src = emit_sequence_record(name, t, package, nested_acc)
path = out_dir / f"{name}.java"
path.write_text(src)
written.append(path)
# nested anonymous sequences as separate top-level records
for nname, nseq in nested_acc:
more: List[Tuple[str, SequenceType]] = []
nsrc = emit_sequence_record(nname, nseq, package, more)
npath = out_dir / f"{nname}.java"
npath.write_text(nsrc)
written.append(npath)
# flatten one level of nesting iteratively
queue = list(more)
while queue:
qn, qs = queue.pop(0)
more2: List[Tuple[str, SequenceType]] = []
qsrc = emit_sequence_record(qn, qs, package, more2)
qpath = out_dir / f"{qn}.java"
qpath.write_text(qsrc)
written.append(qpath)
queue.extend(more2)
else:
alias = emit_alias(name, t, package)
if alias:
path = out_dir / f"{name}.java"
path.write_text(alias)
written.append(path)
return written
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--asn",
type=Path,
required=True,
help="Path to GajumaruChainObjects.asn",
)
ap.add_argument(
"--package",
default="swiss.qpq.gajumaru.core.asn1",
help="Java package for generated sources",
)
ap.add_argument(
"--out",
type=Path,
default=None,
help="Output directory for .java files "
"(default: src/main/java/<package path> under gajumaru-core)",
)
ap.add_argument(
"--types",
default="Id,SignedTxV1,SpendTxV1",
help="Comma-separated type names to generate (plus dependencies)",
)
ap.add_argument(
"--all",
action="store_true",
help="Generate all SEQUENCE types in the module",
)
args = ap.parse_args(argv)
asn_path = args.asn.resolve()
if not asn_path.is_file():
print(f"ASN.1 file not found: {asn_path}", file=sys.stderr)
return 1
text = asn_path.read_text()
defs = parse_module(text)
if not defs:
print("No type definitions parsed", file=sys.stderr)
return 1
if args.all:
wanted = [n for n, d in defs.items() if isinstance(d.type, SequenceType)]
else:
wanted = [t.strip() for t in args.types.split(",") if t.strip()]
if args.out is None:
script_dir = Path(__file__).resolve().parent
project_dir = script_dir.parent
pkg_path = args.package.replace(".", "/")
out_dir = project_dir / "src" / "main" / "java" / pkg_path
else:
out_dir = args.out
written = generate(defs, wanted, args.package, out_dir)
print(f"Parsed {len(defs)} ASN.1 types from {asn_path}")
print(f"Wrote {len(written)} Java file(s) to {out_dir}:")
for p in written:
print(f" {p.name}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))