Coffey Labs is now Coffey Labs LLC, an Arizona limited liability company. Copyright lines and SPDX-FileCopyrightText headers naming Coffey Labs or John Coffey now name Coffey Labs LLC. Upstream copyright notices are unchanged.
145 lines
5.5 KiB
Python
Executable File
145 lines
5.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Tell INBUXA Admin what each expression field accepts.
|
|
|
|
Every expression field in the registry has a context: the constants it may
|
|
evaluate to (DKIM verification: relaxed, strict or disable) and the variables
|
|
its conditions may read (sender_domain, local_port...). The server enforces
|
|
both, but the schema it serves the admin describes every expression field as
|
|
only an `x:Expression` object, so the admin can offer nothing better than a
|
|
free-text box.
|
|
|
|
This reads those contexts from the generated registry code and writes them
|
|
into the served schema, on each expression field's type:
|
|
|
|
"type": {"type": "object", "objectName": "x:Expression",
|
|
"expression": {"constants": ["relaxed", "strict", "disable"],
|
|
"variables": ["sender", "sender_domain", ...]}}
|
|
|
|
The registry code is the source, so re-run this after anything that
|
|
regenerates it (an upstream import, a new expression field). `--check` exits 1
|
|
when the schema is out of date; CI runs it.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import gzip
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parents[2]
|
|
REGISTRY = root / 'crates' / 'registry' / 'src' / 'schema'
|
|
SCHEMA = root / 'resources' / 'schema' / 'schema.json.gz'
|
|
SCHEMA_HASH = root / 'resources' / 'schema' / 'schema.json.sha256'
|
|
|
|
|
|
def names(enum, text):
|
|
"""Variant → wire name, from the `Enum::Variant => "name"` arms."""
|
|
return dict(re.findall(rf'{enum}::(\w+) => "([^"]+)"', text))
|
|
|
|
|
|
def lists(text):
|
|
"""Every `pub static NAME: &[ExpressionConstant|Variable] = &[...]`."""
|
|
out = {}
|
|
for name, kind, body in re.findall(
|
|
r'pub static (\w+): &\[(ExpressionConstant|ExpressionVariable)\] = &\[(.*?)\];', text, re.S
|
|
):
|
|
out[name] = (kind, re.findall(rf'{kind}::(\w+)', body))
|
|
return out
|
|
|
|
|
|
def contexts(text):
|
|
"""(struct, Property variant, variables list name, constants list name) per context."""
|
|
out = []
|
|
for block in re.finditer(r'(?m)^impl (\w+) \{(.*?)^\}', text, re.S):
|
|
struct, body = block.group(1), block.group(2)
|
|
for ctx in re.finditer(r'ExpressionContext \{(.*?)\n\s*\}\n', body, re.S):
|
|
fields = ctx.group(1)
|
|
prop = re.search(r'property: Property::(\w+),', fields)
|
|
var = re.search(r'allowed_variables: (&\[\]|\w+),', fields)
|
|
const = re.search(r'allowed_constants: (&\[\]|\w+),', fields)
|
|
if prop and var and const:
|
|
out.append((struct, prop.group(1), var.group(1), const.group(1)))
|
|
return out
|
|
|
|
|
|
def build():
|
|
enums = (REGISTRY / 'enums.rs').read_text(encoding='utf-8')
|
|
enums_impl = (REGISTRY / 'enums_impl.rs').read_text(encoding='utf-8')
|
|
props = names('Property', (REGISTRY / 'properties_impl.rs').read_text(encoding='utf-8'))
|
|
const_names = names('ExpressionConstant', enums_impl)
|
|
var_names = names('ExpressionVariable', enums_impl)
|
|
known = lists(enums)
|
|
|
|
def resolve(ref, kind, wire):
|
|
if ref == '&[]':
|
|
return []
|
|
found = known.get(ref)
|
|
if not found or found[0] != kind:
|
|
raise SystemExit(f'expr-schema: no {kind} list named {ref}')
|
|
return [wire[v] for v in found[1]]
|
|
|
|
table = {}
|
|
for struct, prop, var, const in contexts((REGISTRY / 'structs_impl.rs').read_text(encoding='utf-8')):
|
|
table[(f'x:{struct}', props[prop])] = {
|
|
'constants': resolve(const, 'ExpressionConstant', const_names),
|
|
'variables': resolve(var, 'ExpressionVariable', var_names),
|
|
}
|
|
return table
|
|
|
|
|
|
def apply(schema, table):
|
|
"""Write the table into the schema; returns the (object, field) pairs it couldn't place."""
|
|
missing = []
|
|
for (obj, field), expr in sorted(table.items()):
|
|
target = schema['fields'].get(obj, {}).get('properties', {}).get(field)
|
|
if target is None or target['type'].get('objectName') != 'x:Expression':
|
|
missing.append(f'{obj}.{field}')
|
|
continue
|
|
target['type']['expression'] = expr
|
|
return missing
|
|
|
|
|
|
def encode(schema):
|
|
text = json.dumps(schema, ensure_ascii=False, separators=(',', ':'))
|
|
out = gzip.compress(text.encode('utf-8'), compresslevel=9, mtime=0)
|
|
digest = base64.urlsafe_b64encode(hashlib.sha256(out).digest()).decode().rstrip('=')
|
|
return out, digest
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument('--check', action='store_true', help='exit 1 if the schema is out of date')
|
|
args = parser.parse_args()
|
|
|
|
before = SCHEMA.read_bytes()
|
|
schema = json.loads(gzip.decompress(before))
|
|
table = build()
|
|
missing = apply(schema, table)
|
|
if missing:
|
|
print('expr-schema: expression contexts with no matching schema field:', file=sys.stderr)
|
|
for m in missing:
|
|
print(f' {m}', file=sys.stderr)
|
|
return 1
|
|
|
|
current = json.loads(gzip.decompress(before))
|
|
if current == schema:
|
|
print(f'expr-schema: {len(table)} expression fields, schema up to date')
|
|
return 0
|
|
if args.check:
|
|
print('expr-schema: schema is out of date; run tools/fork/expr-schema.py', file=sys.stderr)
|
|
return 1
|
|
out, digest = encode(schema)
|
|
SCHEMA.write_bytes(out)
|
|
SCHEMA_HASH.write_text(digest, encoding='utf-8')
|
|
print(f'expr-schema: wrote {len(table)} expression fields into {SCHEMA.relative_to(root)}')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|