add el_html_sanitize allowlist runtime primitive

Replaces the need for product-level denylist sanitizers. Small
state-machine parser; tag-and-attribute allowlist passed as JSON;
URL scheme validation on href/src attrs (http, https, mailto,
fragment, relative); whole-subtree drop for script/style/iframe/
object/embed/form (plus rarer media containers). No comment-
wrapping (was fragile to comment-injection bypass via a literal
--> inside an attacker-supplied attribute value).

Also picks up the codegen and parser changes for first-class
Instant/Duration types (postfix-literal time values, typed binop
dispatch) that were sitting in tree alongside this work.

Test corpus at tests/html_sanitizer/ covers the live attacker
probes (script, iframe, form, javascript:, about:, data:, img
onerror, onclick) plus structural attacks (comment-injection
bypass, tab-in-scheme bypass, encoded payloads, malformed input,
empty input, plain text). 29 cases, all green.

Self-host fixed point holds at 5720 lines via the canonical
el-compiler/src/compiler.el entry. Snapshot tagged at
dist/platform/elc.20260502-1249-self-host.

Backlog: bl-dc55ae07
This commit is contained in:
Will Anderson
2026-05-02 12:49:41 -05:00
parent 2e9d3247a6
commit af480f6266
7 changed files with 724 additions and 0 deletions
+438
View File
@@ -99,6 +99,29 @@ fn binop_to_c(op: String) -> String {
//
// cg_expr returns a C expression string (not a statement).
// duration_unit_nanos multiplier from a postfix-literal unit name to
// nanoseconds. Singular and plural forms collapse to the same multiplier;
// the parser already restricted `unit` to the set is_duration_unit accepts.
// Returns the multiplier as a decimal string suitable for splicing into
// the generated C as a literal int64 expression.
fn duration_unit_nanos(unit: String) -> String {
if str_eq(unit, "nano") { return "1LL" }
if str_eq(unit, "nanos") { return "1LL" }
if str_eq(unit, "milli") { return "1000000LL" }
if str_eq(unit, "millis") { return "1000000LL" }
if str_eq(unit, "millisecond") { return "1000000LL" }
if str_eq(unit, "milliseconds") { return "1000000LL" }
if str_eq(unit, "second") { return "1000000000LL" }
if str_eq(unit, "seconds") { return "1000000000LL" }
if str_eq(unit, "minute") { return "60000000000LL" }
if str_eq(unit, "minutes") { return "60000000000LL" }
if str_eq(unit, "hour") { return "3600000000000LL" }
if str_eq(unit, "hours") { return "3600000000000LL" }
if str_eq(unit, "day") { return "86400000000000LL" }
if str_eq(unit, "days") { return "86400000000000LL" }
"1LL"
}
fn cg_expr(expr: Map<String, Any>) -> String {
let kind: String = expr["expr"]
@@ -107,6 +130,17 @@ fn cg_expr(expr: Map<String, Any>) -> String {
return v
}
// DurationLit postfix-literal time value (e.g. 30.seconds, 1.hour).
// Lowered to a literal int64 nanosecond count, wrapped in the runtime
// entry point so the intent is explicit at the C level. The arithmetic
// is fully constant-folded by any optimising C compiler.
if kind == "DurationLit" {
let count: String = expr["count"]
let unit: String = expr["unit"]
let mult: String = duration_unit_nanos(unit)
return "el_duration_from_nanos((el_val_t)(" + count + "LL * " + mult + "))"
}
if kind == "Float" {
// Wrap Float literals in el_from_float() so the bit pattern is
// preserved through the el_val_t (int64) slot. Without this,
@@ -157,6 +191,153 @@ fn cg_expr(expr: Map<String, Any>) -> String {
let left_kind: String = left["expr"]
let right_kind: String = right["expr"]
// Temporal-type dispatch (Instant + Duration first-class)
// Run BEFORE the int / string / generic paths so typed temporal
// operands route through the runtime wrappers and invalid combos
// become #error directives rather than silently falling through to
// raw int arithmetic. The wrappers are no-op casts at the C level
// but make the intent explicit and centralise future changes (e.g.
// saturating arithmetic, overflow guards).
let left_is_inst: Bool = is_instant_expr(left)
let right_is_inst: Bool = is_instant_expr(right)
let left_is_dur: Bool = is_duration_expr(left)
let right_is_dur: Bool = is_duration_expr(right)
let any_temporal: Bool = false
if left_is_inst { let any_temporal = true }
if right_is_inst { let any_temporal = true }
if left_is_dur { let any_temporal = true }
if right_is_dur { let any_temporal = true }
if any_temporal {
if op == "Plus" {
if left_is_inst {
if right_is_dur {
return "el_instant_add_dur(" + left_c + ", " + right_c + ")"
}
if right_is_inst {
time_record_violation("instant_plus_instant", "Instant + Instant is not allowed")
return "0 /* TIME_TYPE_ERROR: Instant + Instant */"
}
}
if left_is_dur {
if right_is_inst {
return "el_instant_add_dur(" + right_c + ", " + left_c + ")"
}
if right_is_dur {
return "el_duration_add(" + left_c + ", " + right_c + ")"
}
if is_int_expr(right) {
time_record_violation("duration_plus_int", "Duration + Int is not allowed (use duration_seconds(n) or N.seconds)")
return "0 /* TIME_TYPE_ERROR: Duration + Int */"
}
}
if right_is_dur {
if is_int_expr(left) {
time_record_violation("duration_plus_int", "Int + Duration is not allowed")
return "0 /* TIME_TYPE_ERROR: Int + Duration */"
}
}
}
if op == "Minus" {
if left_is_inst {
if right_is_dur {
return "el_instant_sub_dur(" + left_c + ", " + right_c + ")"
}
if right_is_inst {
return "el_instant_diff(" + left_c + ", " + right_c + ")"
}
}
if left_is_dur {
if right_is_dur {
return "el_duration_sub(" + left_c + ", " + right_c + ")"
}
if is_int_expr(right) {
time_record_violation("duration_minus_int", "Duration - Int is not allowed")
return "0 /* TIME_TYPE_ERROR: Duration - Int */"
}
}
}
if op == "Star" {
if left_is_dur {
if is_int_expr(right) {
return "el_duration_scale(" + left_c + ", " + right_c + ")"
}
}
if right_is_dur {
if is_int_expr(left) {
return "el_duration_scale(" + right_c + ", " + left_c + ")"
}
}
}
if op == "Slash" {
if left_is_dur {
if is_int_expr(right) {
return "el_duration_div(" + left_c + ", " + right_c + ")"
}
}
}
// Comparisons. Cross-type comparisons are forbidden.
if op == "Lt" {
if left_is_inst {
if right_is_inst { return "el_instant_lt(" + left_c + ", " + right_c + ")" }
if right_is_dur {
time_record_violation("instant_cmp_duration", "Instant < Duration is not allowed")
return "0 /* TIME_TYPE_ERROR: Instant < Duration */"
}
}
if left_is_dur {
if right_is_dur { return "el_duration_lt(" + left_c + ", " + right_c + ")" }
if right_is_inst {
time_record_violation("duration_cmp_instant", "Duration < Instant is not allowed")
return "0 /* TIME_TYPE_ERROR: Duration < Instant */"
}
}
}
if op == "LtEq" {
if left_is_inst {
if right_is_inst { return "el_instant_le(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_le(" + left_c + ", " + right_c + ")" }
}
}
if op == "Gt" {
if left_is_inst {
if right_is_inst { return "el_instant_gt(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_gt(" + left_c + ", " + right_c + ")" }
}
}
if op == "GtEq" {
if left_is_inst {
if right_is_inst { return "el_instant_ge(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_ge(" + left_c + ", " + right_c + ")" }
}
}
if op == "EqEq" {
if left_is_inst {
if right_is_inst { return "el_instant_eq(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_eq(" + left_c + ", " + right_c + ")" }
}
}
if op == "NotEq" {
if left_is_inst {
if right_is_inst { return "el_instant_ne(" + left_c + ", " + right_c + ")" }
}
if left_is_dur {
if right_is_dur { return "el_duration_ne(" + left_c + ", " + right_c + ")" }
}
}
// Fall through let the existing path handle anything we
// didn't explicitly cover (typically string-concat with a
// typed temporal value, e.g. for debug prints, which works
// because both share the int64 slot).
}
if op == "Plus" {
// If either side is a string literal, always concat
if left_kind == "Str" {
@@ -365,6 +546,21 @@ fn cg_expr(expr: Map<String, Any>) -> String {
// handler)`). User-defined fns and variadic builtins pass
// through (builtin_arity returns -1).
arity_check_call(fn_name, arity)
// sleep(Duration) Phase 1 of the typed-time work. When the
// single arg is provably a Duration we lower to el_sleep_duration
// so the runtime sees nanos directly. Existing sleep() callers
// that pass an Int still emit `sleep(<int>)`, which falls through
// to the no-such-symbol path those call sites must migrate to
// a typed Duration. Acceptable: the spec marks them out for an
// audit pass during Phase 1.
if str_eq(fn_name, "sleep") {
if arity == 1 {
let only_arg = native_list_get(args, 0)
if is_duration_expr(only_arg) {
return "el_sleep_duration(" + args_c + ")"
}
}
}
return fn_name + "(" + args_c + ")"
}
@@ -672,6 +868,23 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
if str_eq(ltype, "Int") {
add_int_name(name)
}
// Temporal type annotations register the name with the matching
// typed-set so BinOp / comparison codegen routes through the
// typed wrappers and forbids cross-type ops.
if str_eq(ltype, "Instant") {
add_instant_name(name)
}
if str_eq(ltype, "Duration") {
add_duration_name(name)
}
// Inference from RHS duration literals and known-typed calls
// propagate even when the let is unannotated.
if is_instant_expr(val) {
add_instant_name(name)
}
if is_duration_expr(val) {
add_duration_name(name)
}
let vk: String = val["expr"]
if str_eq(vk, "Int") {
add_int_name(name)
@@ -928,6 +1141,23 @@ fn is_int_name(name: String) -> Bool {
return str_contains(csv, "," + name + ",")
}
// Same shape as is_int_name, for Instant- and Duration-typed bindings.
// Used by the BinOp/comparison codegen to dispatch arithmetic through the
// typed runtime wrappers (el_instant_add_dur, el_duration_lt, ) and to
// surface mismatches (Instant + Instant, Duration + Int) as #error
// directives at the top of the generated C.
fn is_instant_name(name: String) -> Bool {
let csv: String = state_get("__instant_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn is_duration_name(name: String) -> Bool {
let csv: String = state_get("__duration_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
// Known runtime builtins that return Int. Used to dispatch arithmetic vs
// string-concat on `+` when one side is a Call. New builtins must be added
// here when they return Int and may participate in arithmetic.
@@ -956,9 +1186,161 @@ fn is_int_call(call_expr: Map<String, Any>) -> Bool {
if str_eq(name, "el_max") { return true }
if str_eq(name, "el_min") { return true }
if str_eq(name, "float_to_int") { return true }
if str_eq(name, "unix_timestamp") { return true }
if str_eq(name, "instant_to_unix_seconds") { return true }
if str_eq(name, "instant_to_unix_millis") { return true }
if str_eq(name, "duration_to_seconds") { return true }
if str_eq(name, "duration_to_millis") { return true }
if str_eq(name, "duration_to_nanos") { return true }
return false
}
// Builtins that return an Instant. Used by is_instant_expr and the BinOp
// dispatch `now() + 5.seconds` types as Instant only because we can see
// that now() is an Instant-returning Call.
fn is_instant_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "now") { return true }
if str_eq(name, "el_now_instant") { return true }
if str_eq(name, "unix_seconds") { return true }
if str_eq(name, "unix_millis") { return true }
if str_eq(name, "instant_from_iso8601") { return true }
if str_eq(name, "el_instant_add_dur") { return true }
if str_eq(name, "el_instant_sub_dur") { return true }
return false
}
// Builtins that return a Duration. Same role as is_instant_call.
fn is_duration_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "el_duration_from_nanos") { return true }
if str_eq(name, "duration_seconds") { return true }
if str_eq(name, "duration_millis") { return true }
if str_eq(name, "duration_nanos") { return true }
if str_eq(name, "el_instant_diff") { return true }
if str_eq(name, "el_duration_add") { return true }
if str_eq(name, "el_duration_sub") { return true }
if str_eq(name, "el_duration_scale") { return true }
if str_eq(name, "el_duration_div") { return true }
if str_eq(name, "ttl_cache_age") { return true }
return false
}
// Recursive type predicates for Instant / Duration. Mirror is_int_expr.
// is_instant_expr / is_duration_expr return true only when the expression
// is provably of that type at codegen time. Anything ambiguous returns
// false the BinOp dispatcher then leaves the expression on the
// untyped-int path, which is the safest fallback because at the runtime
// level all three types share the int64 slot.
fn is_instant_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") {
let name: String = expr["name"]
return is_instant_name(name)
}
if str_eq(k, "Call") {
return is_instant_call(expr)
}
if str_eq(k, "BinOp") {
let op: String = expr["op"]
if str_eq(op, "Plus") {
// Instant + Duration Instant
// Duration + Instant Instant
if is_instant_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
if is_duration_expr(expr["left"]) {
if is_instant_expr(expr["right"]) { return true }
}
return false
}
if str_eq(op, "Minus") {
// Instant - Duration Instant
if is_instant_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
return false
}
return false
}
return false
}
fn is_duration_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "DurationLit") { return true }
if str_eq(k, "Ident") {
let name: String = expr["name"]
return is_duration_name(name)
}
if str_eq(k, "Call") {
return is_duration_call(expr)
}
if str_eq(k, "Neg") {
return is_duration_expr(expr["inner"])
}
if str_eq(k, "BinOp") {
let op: String = expr["op"]
if str_eq(op, "Plus") {
// Duration + Duration Duration
if is_duration_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
return false
}
if str_eq(op, "Minus") {
// Duration - Duration Duration
// Instant - Instant Duration (caught here, not in is_instant_expr)
if is_duration_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
if is_instant_expr(expr["left"]) {
if is_instant_expr(expr["right"]) { return true }
}
return false
}
if str_eq(op, "Star") {
// Duration * Int Duration
// Int * Duration Duration
if is_duration_expr(expr["left"]) {
if is_int_expr(expr["right"]) { return true }
}
if is_int_expr(expr["left"]) {
if is_duration_expr(expr["right"]) { return true }
}
return false
}
if str_eq(op, "Slash") {
// Duration / Int Duration
if is_duration_expr(expr["left"]) {
if is_int_expr(expr["right"]) { return true }
}
return false
}
return false
}
return false
}
// Record a temporal-type violation. Surfaced as `#error` directives at the
// top of the generated C, identical machinery to cap_record_violation.
// kinds: "instant_plus_instant", "duration_plus_int", etc.
fn time_record_violation(kind: String, detail: String) -> Bool {
let csv: String = state_get("__time_violations")
if str_eq(csv, "") { let csv = "," }
let entry: String = kind + ":" + detail
let key: String = "," + entry + ","
if str_contains(csv, key) { return true }
state_set("__time_violations", csv + entry + ",")
return true
}
// Recursive type-propagation: is `expr` known-Int at codegen time?
// This unifies the BinOp(+) dispatch so chained arithmetic over Int
// operands stays arithmetic. Without recursion, a wrapping `+` between
@@ -1138,6 +1520,30 @@ fn emit_cap_violations() -> Void {
}
}
// Surface temporal-type violations as #error directives. The cg_expr BinOp
// dispatcher records each violation (Instant + Instant, Duration + Int, )
// as a CSV entry "kind:detail" via time_record_violation. Each entry maps
// to a single #error so downstream cc fails the build with a clear El-
// source-level message before the bogus C even links.
fn emit_time_violations() -> Void {
let csv: String = state_get("__time_violations")
if str_eq(csv, "") { return }
if str_eq(csv, ",") { return }
let n: Int = str_len(csv)
let i: Int = 1
while i < n {
let next_comma: Int = str_index_of(str_slice(csv, i, n), ",")
if next_comma < 0 { return }
let entry: String = str_slice(csv, i, i + next_comma)
let colon: Int = str_index_of(entry, ":")
if colon > 0 {
let detail: String = str_slice(entry, colon + 1, str_len(entry))
emit_line("#error \"temporal type error: " + detail + "\"")
}
let i = i + next_comma + 1
}
}
// Builtin arity table
//
// El programs sometimes call runtime builtins with the wrong number of
@@ -1180,6 +1586,8 @@ fn builtin_arity(name: String) -> Int {
if str_eq(name, "str_format") { return 2 }
if str_eq(name, "str_lower") { return 1 }
if str_eq(name, "str_upper") { return 1 }
// HTML sanitizer
if str_eq(name, "el_html_sanitize") { return 2 }
// Math
if str_eq(name, "el_abs") { return 1 }
if str_eq(name, "el_max") { return 2 }
@@ -1384,8 +1792,28 @@ fn add_int_name(name: String) -> Bool {
return true
}
fn add_instant_name(name: String) -> Bool {
let csv: String = state_get("__instant_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__instant_names", csv + name + ",")
return true
}
fn add_duration_name(name: String) -> Bool {
let csv: String = state_get("__duration_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__duration_names", csv + name + ",")
return true
}
fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
state_set("__int_names", ",")
state_set("__instant_names", ",")
state_set("__duration_names", ",")
let np: Int = native_list_len(params)
let pi = 0
while pi < np {
@@ -1395,6 +1823,12 @@ fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
if str_eq(ptype, "Int") {
add_int_name(pname)
}
if str_eq(ptype, "Instant") {
add_instant_name(pname)
}
if str_eq(ptype, "Duration") {
add_duration_name(pname)
}
let pi = pi + 1
}
return true
@@ -1661,6 +2095,8 @@ fn codegen(stmts: [Map<String, Any>], source: String) -> String {
state_set("__cap_violations", "")
// Clear arity-violation accumulator from any prior compile.
state_set("__arity_violations", "")
// Clear temporal-type-violation accumulator from any prior compile.
state_set("__time_violations", "")
// Preamble
emit_line("#include <stdint.h>")
@@ -1831,6 +2267,8 @@ fn codegen(stmts: [Map<String, Any>], source: String) -> String {
// so a misuse of a known builtin (wrong arg count) fails the build
// with a clear message naming the builtin and its expected arity.
emit_arity_violations()
// Temporal-type violations (Instant + Instant, Duration + Int, ).
emit_time_violations()
// Return empty string output was streamed via println
""
+45
View File
@@ -474,10 +474,55 @@ fn parse_block(tokens: [Map<String, Any>], pos: Int) -> Map<String, Any> {
// Postfix expressions (calls, field access, index)
// is_duration_unit recognise the postfix unit suffix on a numeric literal.
// Used by parse_postfix to detect `30.seconds`-shape time literals before
// falling back to the generic `obj.field` field-access lowering. Singular
// and plural forms map to the same nanosecond multiplier; codegen does the
// arithmetic at compile time.
fn is_duration_unit(name: String) -> Bool {
if name == "nanos" { return true }
if name == "nano" { return true }
if name == "millis" { return true }
if name == "milli" { return true }
if name == "millisecond" { return true }
if name == "milliseconds" { return true }
if name == "second" { return true }
if name == "seconds" { return true }
if name == "minute" { return true }
if name == "minutes" { return true }
if name == "hour" { return true }
if name == "hours" { return true }
if name == "day" { return true }
if name == "days" { return true }
false
}
fn parse_postfix(tokens: [Map<String, Any>], pos: Int) -> Map<String, Any> {
let r = parse_primary(tokens, pos)
let node = r["node"]
let p = r["pos"]
// Postfix duration literal: `<Int>.<unit>` where <unit> is one of
// nanos | millis | seconds | minutes | hours | days (each with an
// optional plural). We recognise this before the generic Dot-as-field
// path so `30.seconds` lowers to a DurationLit AST node carrying the
// count and the unit, not a field access on an Int.
let primary_kind: String = node["expr"]
if primary_kind == "Int" {
let dot_kind = tok_kind(tokens, p)
if dot_kind == "Dot" {
let unit_kind = tok_kind(tokens, p + 1)
if unit_kind == "Ident" {
let unit_name = tok_value(tokens, p + 1)
if is_duration_unit(unit_name) {
let count_str: String = node["value"]
let node = { "expr": "DurationLit", "count": count_str, "unit": unit_name }
let p = p + 2
}
}
}
}
let running = true
while running {
let k = tok_kind(tokens, p)