land prohibition-as-query: the emitter records, it no longer adjudicates
This commit is contained in:
@@ -3271,6 +3271,18 @@ fn params_to_env_args(params: [Any]) -> String {
|
||||
|
||||
fn cg_fn(stmt: Map<String, Any>) -> Void {
|
||||
let fn_name: String = stmt["name"]
|
||||
state_set("__cg_current_fn", fn_name)
|
||||
// Emit which constructs this fn carries, so the prohibition check can be a
|
||||
// query over relations instead of a walk inside the emitter.
|
||||
let cdl = stmt["decorators"]
|
||||
let n_cdl: Int = native_list_len(cdl)
|
||||
let cdi = 0
|
||||
while cdi < n_cdl {
|
||||
let cd = native_list_get(cdl, cdi)
|
||||
let cdn: String = cd["name"]
|
||||
record_call(fn_name, "@" + cdn)
|
||||
let cdi = cdi + 1
|
||||
}
|
||||
// Skip El's `fn main()` - C provides its own main() for top-level stmts
|
||||
// and a duplicate `el_val_t main(void)` would collide with it.
|
||||
if fn_name == "main" { return }
|
||||
@@ -3284,25 +3296,11 @@ fn cg_fn(stmt: Map<String, Any>) -> Void {
|
||||
// decorator LIST so the role may be stacked with other decorators.
|
||||
// Check each DECLARED prohibition in turn. The owning construct is known by
|
||||
// construction, so the diagnostic names it rather than hardcoding one rule.
|
||||
let pcs: String = prohibiting_constructs()
|
||||
if !str_eq(pcs, "") {
|
||||
let pc_list = str_split(pcs, ",")
|
||||
let n_pc: Int = native_list_len(pc_list)
|
||||
let pci = 0
|
||||
while pci < n_pc {
|
||||
let pc: String = str_trim(native_list_get(pc_list, pci))
|
||||
if !str_eq(pc, "") {
|
||||
state_set("__prohibit_active", prohibition_names(pc))
|
||||
if vbd_has_restricted_call(body) {
|
||||
if !fn_has_decorator(stmt, pc) {
|
||||
emit_line("#error \"boundary violation: " + prohibition_names(pc) + " may only be called from an @" + pc + " fn, but '" + fn_name + "' is not one\"")
|
||||
}
|
||||
}
|
||||
state_set("__prohibit_active", "")
|
||||
}
|
||||
let pci = pci + 1
|
||||
}
|
||||
}
|
||||
// The walk records; it no longer decides. Whether a call is legal where it
|
||||
// appears is a query over the emitted relations — see
|
||||
// tools/check/prohibitions.sh. An emitter that also adjudicates has to
|
||||
// contain every rule anyone will ever want.
|
||||
vbd_has_restricted_call(body)
|
||||
// Seed the per-function int-name set so the `+` codegen can dispatch
|
||||
// arithmetic vs concat on type-annotated identifiers.
|
||||
build_int_names_for_params(params)
|
||||
@@ -3466,19 +3464,19 @@ fn emit_program_init(stmt: Map<String, Any>) -> Void {
|
||||
// Scan a function body for direct calls to DHARMA-restricted builtins
|
||||
// (dharma_emit, dharma_field). These may only appear inside @manager fns.
|
||||
|
||||
fn vbd_is_restricted_name(name: String) -> Bool {
|
||||
let active: String = state_get("__prohibit_active")
|
||||
if str_eq(active, "") { return false }
|
||||
let parts = str_split(active, ",")
|
||||
let n: Int = native_list_len(parts)
|
||||
let i = 0
|
||||
while i < n {
|
||||
if str_eq(str_trim(native_list_get(parts, i)), name) { return true }
|
||||
let i = i + 1
|
||||
}
|
||||
false
|
||||
// record_call — emit one <caller> calls <callee> relation. The traversal is
|
||||
// irreducible: you must walk the AST to find calls. What is NOT irreducible is
|
||||
// the rule (which names are restricted) or the decision (#error). Those move
|
||||
// out; the walk stays and changes purpose from deciding to recording.
|
||||
fn record_call(caller: String, callee: String) -> Void {
|
||||
let path: String = env("EL_RELATIONS_OUT")
|
||||
if str_eq(path, "") { return }
|
||||
let prev: String = ""
|
||||
if fs_exists(path) { let prev = fs_read(path) }
|
||||
fs_write(path, prev + caller + " calls " + callee + "\n")
|
||||
}
|
||||
|
||||
|
||||
fn vbd_expr_has_restricted_call(expr: Map<String, Any>) -> Bool {
|
||||
let kind: String = expr["expr"]
|
||||
if str_eq(kind, "Call") {
|
||||
@@ -3486,7 +3484,7 @@ fn vbd_expr_has_restricted_call(expr: Map<String, Any>) -> Bool {
|
||||
let fk: String = func["expr"]
|
||||
if str_eq(fk, "Ident") {
|
||||
let fname: String = func["name"]
|
||||
if vbd_is_restricted_name(fname) { return true }
|
||||
record_call(state_get("__cg_current_fn"), fname)
|
||||
}
|
||||
if vbd_expr_has_restricted_call(func) { return true }
|
||||
let args = expr["args"]
|
||||
@@ -4259,22 +4257,10 @@ fn program_has_routes(recs: [Map<String, Any>]) -> Bool {
|
||||
// the same mechanism codegen already uses for __match_counter and
|
||||
// __if_expr_counter.
|
||||
fn declare_prohibition(construct: String, names_csv: String) -> Void {
|
||||
let known: String = state_get("__prohibit_constructs")
|
||||
if str_eq(known, "") {
|
||||
state_set("__prohibit_constructs", construct)
|
||||
} else {
|
||||
state_set("__prohibit_constructs", known + "," + construct)
|
||||
}
|
||||
state_set("__prohibit_names_" + construct, names_csv)
|
||||
record_call(construct, "prohibits:" + names_csv)
|
||||
}
|
||||
|
||||
fn prohibition_names(construct: String) -> String {
|
||||
state_get("__prohibit_names_" + construct)
|
||||
}
|
||||
|
||||
fn prohibiting_constructs() -> String {
|
||||
state_get("__prohibit_constructs")
|
||||
}
|
||||
|
||||
// scan_declared_decorators — token-level pre-pass registering every construct
|
||||
// the program declares. Runs once per module alongside scan_routes, because
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Control for prohibition-as-query: the compiler records, the checker decides.
|
||||
set -uo pipefail
|
||||
ELC="${1:?usage: prohibition_query.sh <elc>}"
|
||||
LANG_DIR="${2:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
W=$(mktemp -d); trap 'rm -rf "$W"' EXIT; F=0
|
||||
chk(){ [ "$2" = "$3" ] && printf ' ok %s\n' "$1" || { printf ' FAIL %s\n expected %s got %s\n' "$1" "$2" "$3"; F=$((F+1)); }; }
|
||||
|
||||
cat > "$W/p.el" <<'EOF'
|
||||
@decorator("prohibits_outside", "raw_sql")
|
||||
fn repository() {}
|
||||
|
||||
fn sneaky() -> Int { raw_sql("DROP") return 1 }
|
||||
|
||||
@repository
|
||||
fn allowed() -> Int { raw_sql("SELECT") return 2 }
|
||||
|
||||
fn main() { println("ok") }
|
||||
EOF
|
||||
EL_RELATIONS_OUT="$W/rel.txt" "$ELC" "$W/p.el" >/dev/null 2>&1
|
||||
"$LANG_DIR/tools/check/prohibitions.sh" "$W/rel.txt" > "$W/out.txt" 2>&1; rc=$?
|
||||
chk "a violation outside the boundary is caught" "1" "$rc"
|
||||
chk "the offending fn is named" "1" "$(grep -c 'sneaky is not one' "$W/out.txt")"
|
||||
chk "a call inside the boundary is NOT flagged" "0" "$(grep -c 'allowed is not one' "$W/out.txt")"
|
||||
|
||||
cat > "$W/q.el" <<'EOF'
|
||||
@decorator("prohibits_outside", "raw_sql")
|
||||
fn repository() {}
|
||||
|
||||
@repository
|
||||
fn only_allowed() -> Int { raw_sql("SELECT") return 1 }
|
||||
|
||||
fn main() { println("ok") }
|
||||
EOF
|
||||
EL_RELATIONS_OUT="$W/rel2.txt" "$ELC" "$W/q.el" >/dev/null 2>&1
|
||||
"$LANG_DIR/tools/check/prohibitions.sh" "$W/rel2.txt" >/dev/null 2>&1
|
||||
chk "a clean program exits 0" "0" "$?"
|
||||
echo; echo " 4 assertions, $((4-F)) passed, $F failed"; exit $F
|
||||
@@ -806,23 +806,8 @@ test "undeclared-guard-emits-nothing" {
|
||||
// ── Declared constructs: wraps and prohibitions ──────────────────────────────
|
||||
|
||||
|
||||
test "declared-prohibition-fires-outside-the-boundary" {
|
||||
let src: String = "@decorator(\"prohibits_outside\", \"raw_sql\")\nfn repository() {}\nfn sneaky() -> Int { raw_sql(\"DROP\") return 1 }"
|
||||
let out: String = compile_capture(src)
|
||||
assert str_contains(out, "raw_sql may only be called from an @repository fn"), "a program-declared prohibition is enforced"
|
||||
}
|
||||
|
||||
test "declared-prohibition-permits-inside-the-boundary" {
|
||||
let src: String = "@decorator(\"prohibits_outside\", \"raw_sql\")\nfn repository() {}\n@repository\nfn allowed() -> Int { raw_sql(\"SELECT\") return 1 }"
|
||||
let out: String = compile_capture(src)
|
||||
assert !str_contains(out, "raw_sql may only be called"), "the owning construct permits the call"
|
||||
}
|
||||
|
||||
test "seeded-vbd-prohibition-still-enforced" {
|
||||
let src: String = "fn leaky() -> Int { dharma_emit(\"x\", \"y\") return 1 }"
|
||||
let out: String = compile_capture(src)
|
||||
assert str_contains(out, "may only be called from an @manager fn"), "the compiled-in core prohibition survives being declared rather than branched"
|
||||
}
|
||||
|
||||
// ── Runtime seam ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -925,3 +910,17 @@ test "zero-param-fn-emits-valid-c" {
|
||||
assert str_contains(out, "struct __env_noargs { char __e0; };"), "zero-param env has a field"
|
||||
assert !str_contains(out, "__env = { }"), "and no empty initialiser"
|
||||
}
|
||||
|
||||
|
||||
// ── Prohibition is a query, not an emission ─────────────────────────────────
|
||||
//
|
||||
// The compiler records what it saw -- who calls what, who carries what, who
|
||||
// prohibits what. Whether that is legal is decided by tools/check/prohibitions.sh
|
||||
// against the emitted relations, at build time. An emitter that also adjudicates
|
||||
// has to contain every rule anyone will ever want.
|
||||
|
||||
test "compiler-no-longer-emits-prohibition-errors" {
|
||||
let src: String = "@decorator(\"prohibits_outside\", \"raw_sql\")\nfn repository() {}\nfn sneaky() -> Int { raw_sql(\"DROP\") return 1 }"
|
||||
let out: String = compile_capture(src)
|
||||
assert !str_contains(out, "boundary violation"), "the emitter does not adjudicate"
|
||||
}
|
||||
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# prohibitions.sh — enforce boundary prohibitions as a QUERY over relations the
|
||||
# compiler emitted, rather than as a rule the compiler contains.
|
||||
#
|
||||
# A prohibition is a containment relation over the call graph: "these calls may
|
||||
# appear only inside a fn carrying construct C". The compiler's job is to say
|
||||
# what it saw — who calls what, who carries what, who prohibits what. Deciding
|
||||
# whether that is legal is a query, and a query does not belong in an emitter.
|
||||
#
|
||||
# Detection still happens at BUILD time. What moved is where the rule and the
|
||||
# checker live, which is what "a #error has no runtime" was hiding.
|
||||
#
|
||||
# usage: prohibitions.sh <relations-file>
|
||||
# exit 0 = clean; exit N = N violations
|
||||
set -uo pipefail
|
||||
REL="${1:?usage: prohibitions.sh <relations-file>}"
|
||||
[ -f "$REL" ] || { echo "no relations file: $REL" >&2; exit 0; }
|
||||
|
||||
V=0
|
||||
# construct -> prohibited names
|
||||
while read -r construct _ rest; do
|
||||
[ "${rest#prohibits:}" = "$rest" ] && continue
|
||||
names="${rest#prohibits:}"
|
||||
IFS=',' read -ra NAMES <<< "$names"
|
||||
for n in "${NAMES[@]}"; do
|
||||
# every fn that calls a prohibited name
|
||||
while read -r caller _ callee; do
|
||||
[ "$callee" = "$n" ] || continue
|
||||
# ...must carry the owning construct
|
||||
if ! grep -qx "$caller calls @$construct" "$REL"; then
|
||||
printf 'boundary violation: %s may only be called from an @%s fn, but %s is not one\n' \
|
||||
"$n" "$construct" "$caller"
|
||||
V=$((V+1))
|
||||
fi
|
||||
done < <(sort -u "$REL")
|
||||
done
|
||||
done < <(sort -u "$REL")
|
||||
|
||||
[ "$V" -eq 0 ] && echo "prohibitions: clean"
|
||||
exit "$V"
|
||||
Reference in New Issue
Block a user