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:
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"allowlist": "{\"p\":[],\"br\":[],\"strong\":[],\"em\":[],\"u\":[],\"s\":[],\"code\":[],\"pre\":[],\"ul\":[],\"ol\":[],\"li\":[],\"h1\":[],\"h2\":[],\"h3\":[],\"h4\":[],\"blockquote\":[],\"a\":[\"href\",\"title\"]}",
|
||||
"cases": [
|
||||
{
|
||||
"name": "01-pass-through-list",
|
||||
"input": "<ol><li><strong>x</strong></li></ol>",
|
||||
"expected": "<ol><li><strong>x</strong></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "02-paragraphs-with-bold",
|
||||
"input": "<p>hello <strong>world</strong></p><p>second</p>",
|
||||
"expected": "<p>hello <strong>world</strong></p><p>second</p>"
|
||||
},
|
||||
{
|
||||
"name": "03-pre-code-block",
|
||||
"input": "<pre><code>npm install</code></pre>",
|
||||
"expected": "<pre><code>npm install</code></pre>"
|
||||
},
|
||||
{
|
||||
"name": "04-allowed-https-link",
|
||||
"input": "<a href=\"https://example.com\">click</a>",
|
||||
"expected": "<a href=\"https://example.com\">click</a>"
|
||||
},
|
||||
{
|
||||
"name": "05-allowed-anchor-link",
|
||||
"input": "<a href=\"#section\">jump</a>",
|
||||
"expected": "<a href=\"#section\">jump</a>"
|
||||
},
|
||||
{
|
||||
"name": "06-javascript-scheme-blocked",
|
||||
"input": "<a href=\"javascript:alert(1)\">click</a>",
|
||||
"expected": "<a>click</a>"
|
||||
},
|
||||
{
|
||||
"name": "07-about-scheme-blocked",
|
||||
"input": "<a href=\"about:blank#x\">click</a>",
|
||||
"expected": "<a>click</a>"
|
||||
},
|
||||
{
|
||||
"name": "08-data-uri-blocked",
|
||||
"input": "<a href=\"data:text/html,<script>alert(1)</script>\">x</a>",
|
||||
"expected": "<a>x</a>"
|
||||
},
|
||||
{
|
||||
"name": "09-script-content-dropped",
|
||||
"input": "before<script>alert(1)</script>after",
|
||||
"expected": "beforeafter"
|
||||
},
|
||||
{
|
||||
"name": "10-iframe-content-dropped",
|
||||
"input": "<iframe src=\"evil\">junk</iframe>safe",
|
||||
"expected": "safe"
|
||||
},
|
||||
{
|
||||
"name": "11-form-content-dropped",
|
||||
"input": "<form action=\"/steal\">x</form>safe",
|
||||
"expected": "safe"
|
||||
},
|
||||
{
|
||||
"name": "12-img-with-onerror-dropped",
|
||||
"input": "<img src=x onerror=alert(2)>",
|
||||
"expected": ""
|
||||
},
|
||||
{
|
||||
"name": "13-comment-injection-bypass-blocked",
|
||||
"input": "<form action=\"-->junk\">x</form>safe",
|
||||
"expected": "safe"
|
||||
},
|
||||
{
|
||||
"name": "14-mixed-legit-and-attack",
|
||||
"input": "<p>hello</p><script>alert(1)</script><p>world</p>",
|
||||
"expected": "<p>hello</p><p>world</p>"
|
||||
},
|
||||
{
|
||||
"name": "15-pre-encoded-entities-preserved",
|
||||
"input": "<script>alert(1)</script>",
|
||||
"expected": "<script>alert(1)</script>"
|
||||
},
|
||||
{
|
||||
"name": "16-unicode-in-href-preserved",
|
||||
"input": "<a href=\"https://example.com/?q=日本語\">x</a>",
|
||||
"expected": "<a href=\"https://example.com/?q=日本語\">x</a>"
|
||||
},
|
||||
{
|
||||
"name": "17-unclosed-tag-passes-through",
|
||||
"input": "<p>unclosed",
|
||||
"expected": "<p>unclosed"
|
||||
},
|
||||
{
|
||||
"name": "18-onclick-attribute-stripped-tag-survives",
|
||||
"input": "<p onclick=\"x\">hi</p>",
|
||||
"expected": "<p>hi</p>"
|
||||
},
|
||||
{
|
||||
"name": "19-tab-bypass-in-scheme-blocked",
|
||||
"input": "<a href=\"java\tscript:alert(1)\">x</a>",
|
||||
"expected": "<a>x</a>"
|
||||
},
|
||||
{
|
||||
"name": "20-uppercase-tag-and-attr-normalised",
|
||||
"input": "<A HREF=\"https://example.com\">x</A>",
|
||||
"expected": "<a href=\"https://example.com\">x</a>"
|
||||
},
|
||||
{
|
||||
"name": "21-style-content-dropped",
|
||||
"input": "<style>body{display:none}</style>visible",
|
||||
"expected": "visible"
|
||||
},
|
||||
{
|
||||
"name": "22-object-content-dropped",
|
||||
"input": "<object data=\"x.swf\">flash</object>safe",
|
||||
"expected": "safe"
|
||||
},
|
||||
{
|
||||
"name": "23-svg-onload-dropped",
|
||||
"input": "<svg onload=\"alert(1)\"><circle r=\"5\"/></svg>safe",
|
||||
"expected": "safe"
|
||||
},
|
||||
{
|
||||
"name": "24-blockquote-passthrough",
|
||||
"input": "<blockquote>quoted text</blockquote>",
|
||||
"expected": "<blockquote>quoted text</blockquote>"
|
||||
},
|
||||
{
|
||||
"name": "25-headings-passthrough",
|
||||
"input": "<h1>title</h1><h2>section</h2>",
|
||||
"expected": "<h1>title</h1><h2>section</h2>"
|
||||
},
|
||||
{
|
||||
"name": "26-attribute-value-with-gt-byte",
|
||||
"input": "<a href=\"https://example.com/?q=1>2\" title=\"a > b\">x</a>",
|
||||
"expected": "<a href=\"https://example.com/?q=1>2\" title=\"a > b\">x</a>"
|
||||
},
|
||||
{
|
||||
"name": "27-nested-script-after-text",
|
||||
"input": "lead<p>para</p><script>alert(1)</script>tail",
|
||||
"expected": "lead<p>para</p>tail"
|
||||
},
|
||||
{
|
||||
"name": "28-empty-input",
|
||||
"input": "",
|
||||
"expected": ""
|
||||
},
|
||||
{
|
||||
"name": "29-plain-text",
|
||||
"input": "just text, no tags",
|
||||
"expected": "just text, no tags"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# run.sh — build and execute the html_sanitizer acceptance corpus.
|
||||
#
|
||||
# Compiles tests/html_sanitizer/runner.el via the canonical native elc,
|
||||
# links against the shared C runtime, then runs the binary against
|
||||
# cases.json. Exits non-zero on any FAIL.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
EL_HOME="${EL_HOME:-$(cd ../.. && pwd)}"
|
||||
ELC="${EL_HOME}/dist/platform/elc"
|
||||
RUNTIME_DIR="${EL_HOME}/el-compiler/runtime"
|
||||
|
||||
if [ ! -x "${ELC}" ]; then
|
||||
echo "elc not found at ${ELC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT_C="$(mktemp -t html_sanitizer.XXXXXX).c"
|
||||
OUT_BIN="$(mktemp -t html_sanitizer.XXXXXX)"
|
||||
trap 'rm -f "${OUT_C}" "${OUT_BIN}"' EXIT
|
||||
|
||||
echo "==> Compiling runner.el via ${ELC}"
|
||||
"${ELC}" runner.el > "${OUT_C}"
|
||||
|
||||
echo "==> Linking against ${RUNTIME_DIR}/el_runtime.c"
|
||||
cc -O2 -I "${RUNTIME_DIR}" "${OUT_C}" "${RUNTIME_DIR}/el_runtime.c" \
|
||||
-lcurl -lpthread -o "${OUT_BIN}"
|
||||
|
||||
echo "==> Running"
|
||||
"${OUT_BIN}"
|
||||
@@ -0,0 +1,59 @@
|
||||
// runner.el — acceptance tests for el_html_sanitize.
|
||||
//
|
||||
// Reads cases.json from the cwd, runs every case through
|
||||
// el_html_sanitize, prints PASS/FAIL per case and a summary,
|
||||
// and exits non-zero if any case failed.
|
||||
//
|
||||
// cases.json shape:
|
||||
// {
|
||||
// "allowlist": "<json string passed to el_html_sanitize>",
|
||||
// "cases": [
|
||||
// { "name": "...", "input": "...", "expected": "..." },
|
||||
// ...
|
||||
// ]
|
||||
// }
|
||||
|
||||
fn run_case(name: String, input: String, expected: String, allowlist: String, idx: Int) -> Int {
|
||||
let got: String = el_html_sanitize(input, allowlist)
|
||||
if str_eq(got, expected) {
|
||||
println("PASS " + name)
|
||||
return 1
|
||||
} else {
|
||||
println("FAIL " + name)
|
||||
println(" input: " + input)
|
||||
println(" expected: " + expected)
|
||||
println(" got: " + got)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let raw: String = fs_read("cases.json")
|
||||
if str_eq(raw, "") {
|
||||
println("FATAL could not read cases.json from cwd")
|
||||
return 2
|
||||
}
|
||||
let allowlist: String = json_get(raw, "allowlist")
|
||||
let cases_raw: String = json_get_raw(raw, "cases")
|
||||
let n: Int = json_array_len(cases_raw)
|
||||
let i: Int = 0
|
||||
let pass: Int = 0
|
||||
let fail: Int = 0
|
||||
while i < n {
|
||||
let case_obj: String = json_array_get(cases_raw, i)
|
||||
let cname: String = json_get(case_obj, "name")
|
||||
let cinp: String = json_get(case_obj, "input")
|
||||
let cexp: String = json_get(case_obj, "expected")
|
||||
let r: Int = run_case(cname, cinp, cexp, allowlist, i)
|
||||
if r == 1 {
|
||||
let pass = pass + 1
|
||||
} else {
|
||||
let fail = fail + 1
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
println("")
|
||||
println(int_to_str(pass) + " passed, " + int_to_str(fail) + " failed")
|
||||
if fail > 0 { return 1 }
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user