Skip to main content

netget/scripting/
event_handler.rs

1//! Event handler configuration system
2//!
3//! This module defines how events are handled - either by LLM, script, or static responses.
4//!
5//! # Static handler event interpolation
6//!
7//! A [`EventHandlerType::Static`] handler emits its configured actions without calling
8//! the LLM. To let those actions echo values from the event that triggered them —
9//! correlation ids such as a DNS `query_id`, a DHCP/BOOTP `xid`, an SNMP `request-id`,
10//! a STUN transaction id — action JSON may contain references of the form:
11//!
12//! ```text
13//! {{event.<field>}}          e.g. {{event.query_id}}
14//! {{event.<a>.<b>}}          e.g. {{event.headers.host}}
15//! {{event.<list>.<index>}}   e.g. {{event.questions.0.name}}
16//! {{event}}                  the whole event payload
17//! ```
18//!
19//! Substitution is performed by [`interpolate_actions`] immediately before the actions
20//! are executed. Three rules define it:
21//!
22//! 1. **Whole-string reference preserves type.** A JSON string whose *entire* value is
23//!    one reference is replaced by the referenced JSON value itself, so
24//!    `"query_id": "{{event.query_id}}"` yields a *number*, not `"4660"`. Objects,
25//!    arrays, booleans and null survive equally.
26//! 2. **Embedded reference interpolates text.** `"reply to {{event.domain}}"` produces a
27//!    string; non-string values are rendered in their JSON form (`null`, `true`, `42`,
28//!    `{"a":1}`).
29//! 3. **Everything else is byte-identical.** Only `{{` … `}}` groups whose contents are
30//!    `event` or begin with `event.` are touched. Any other braces — Handlebars snippets
31//!    in a served template, `{{ msg }}` in a Vue page, `{` in a JSON body or a regex —
32//!    pass through unchanged, so handlers written before this feature keep working.
33//!
34//! An unresolvable reference is a hard error naming the reference and listing the fields
35//! the event actually carries; it is never silently rendered as `null` or the empty
36//! string, because a static handler with a typo'd field name must not appear to work.
37//!
38//! ## Why not Handlebars
39//!
40//! Handlebars is already a dependency (`src/llm/template_engine.rs`) and lends the
41//! familiar `{{…}}` spelling, but it is the wrong engine for this path: it renders to a
42//! `String`, so rule 1 would require re-parsing the output and would turn the string
43//! `"007"` into the number `7`; it HTML-escapes `{{…}}` by default, corrupting JSON and
44//! URL payloads unless every reference uses the triple-stash; and it claims the whole
45//! `{{…}}` namespace, so a handler that serves a Handlebars/Vue template, or that
46//! contains `{{#if}}`/`{{!--`/`{{>` text, would be rewritten or rejected. The resolver
47//! below is ~150 lines, borrows only the spelling, and leaves every non-`event`
48//! reference alone.
49
50use serde::{Deserialize, Serialize};
51use serde_json::Value;
52
53/// Pattern for matching events
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(untagged)]
56pub enum EventPattern {
57    /// Match a specific event type ID
58    Specific(String),
59    /// Match all events
60    Wildcard,
61}
62
63impl EventPattern {
64    /// Check if this pattern matches the given event type ID
65    pub fn matches(&self, event_type_id: &str) -> bool {
66        match self {
67            EventPattern::Specific(pattern) => pattern == event_type_id,
68            EventPattern::Wildcard => true,
69        }
70    }
71
72    /// Create a wildcard pattern
73    pub fn wildcard() -> Self {
74        EventPattern::Wildcard
75    }
76
77    /// Create a specific pattern
78    pub fn specific(event_type_id: impl Into<String>) -> Self {
79        EventPattern::Specific(event_type_id.into())
80    }
81}
82
83impl From<String> for EventPattern {
84    fn from(s: String) -> Self {
85        if s == "*" || s == "all" {
86            EventPattern::Wildcard
87        } else {
88            EventPattern::Specific(s)
89        }
90    }
91}
92
93impl From<&str> for EventPattern {
94    fn from(s: &str) -> Self {
95        if s == "*" || s == "all" {
96            EventPattern::Wildcard
97        } else {
98            EventPattern::Specific(s.to_string())
99        }
100    }
101}
102
103/// Handler type for an event
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
106pub enum EventHandlerType {
107    /// Handle with LLM (default behavior)
108    Llm {
109        /// Instruction for how the LLM should handle this event
110        instruction: String,
111    },
112
113    /// Handle with inline script
114    Script {
115        /// Scripting language (python, javascript, go, perl)
116        language: String,
117        /// Inline script code
118        code: String,
119        /// Run the script as a **resident** process: spawned once per scope and
120        /// driven with one event per stdin line, keeping in-process state
121        /// between events. Opt-in; defaults to `false` (the stateless per-event
122        /// path). A resident script defines `handle(event_type, event, message)`
123        /// instead of reading stdin itself. See `src/scripting/resident.rs`.
124        #[serde(default)]
125        resident: bool,
126        /// Resident scope: `"server"` (default — one process shared by all the
127        /// server's connections) or `"connection"` (one process per
128        /// connection). Ignored unless `resident` is `true`.
129        #[serde(default, skip_serializing_if = "Option::is_none")]
130        scope: Option<String>,
131    },
132
133    /// Handle with static response (actions array)
134    ///
135    /// Action JSON may reference the triggering event with `{{event.field}}`; see the
136    /// [module docs](self#static-handler-event-interpolation) for the substitution rules.
137    Static {
138        /// Actions to execute (actual JSON values, not strings)
139        actions: Vec<serde_json::Value>,
140    },
141
142    /// Handled by a human at the dashboard.
143    ///
144    /// The event parks as a pending question (`AppState::park_intercept`) and the
145    /// connection waits, exactly as it would for a slow model. Whatever actions the
146    /// operator composes are executed as the answer; `{{event.field}}` references work
147    /// the same as in a static handler. If nobody answers within `timeout_secs`, the
148    /// handler **fails closed** — the dispatch errors and the protocol's LLM-failure
149    /// branch answers the peer with a category, never with an invented success.
150    Manual {
151        /// Seconds to wait for the operator before failing closed.
152        #[serde(default = "default_manual_timeout_secs")]
153        timeout_secs: u64,
154    },
155}
156
157/// How long a manual handler waits for the operator by default.
158///
159/// Generous on purpose: the whole point is that a human reads the event and composes an
160/// answer, and most protocol peers apply their own (shorter) timeout anyway. It exists so
161/// an unattended dashboard eventually fails closed instead of parking connections forever.
162pub const DEFAULT_MANUAL_TIMEOUT_SECS: u64 = 300;
163
164fn default_manual_timeout_secs() -> u64 {
165    DEFAULT_MANUAL_TIMEOUT_SECS
166}
167
168impl EventHandlerType {
169    /// Create a per-event (stateless) script handler.
170    pub fn script(language: impl Into<String>, code: impl Into<String>) -> Self {
171        EventHandlerType::Script {
172            language: language.into(),
173            code: code.into(),
174            resident: false,
175            scope: None,
176        }
177    }
178
179    /// Create a resident (persistent) script handler with the given scope
180    /// (`"server"` or `"connection"`; `None` defaults to server scope).
181    pub fn script_resident(
182        language: impl Into<String>,
183        code: impl Into<String>,
184        scope: Option<String>,
185    ) -> Self {
186        EventHandlerType::Script {
187            language: language.into(),
188            code: code.into(),
189            resident: true,
190            scope,
191        }
192    }
193
194    /// Create a static handler
195    pub fn static_response(actions: Vec<serde_json::Value>) -> Self {
196        EventHandlerType::Static { actions }
197    }
198
199    /// Create an LLM handler
200    pub fn llm(instruction: impl Into<String>) -> Self {
201        EventHandlerType::Llm {
202            instruction: instruction.into(),
203        }
204    }
205
206    /// Create a manual (human-answered) handler.
207    pub fn manual(timeout_secs: u64) -> Self {
208        EventHandlerType::Manual { timeout_secs }
209    }
210
211    /// Validate the handler's `{{event.…}}` references *without* an event.
212    ///
213    /// This is the parse-time half of the check: it catches malformed references
214    /// (`{{event.}}`, `{{event..x}}`, an opening `{{event.` that is never closed), which
215    /// are wrong regardless of which event arrives. Whether a *well-formed* reference
216    /// resolves depends on the event payload and can only be decided at dispatch time by
217    /// [`interpolate_actions`].
218    ///
219    /// Callers that parse handler configuration (e.g. `EventHandler::parse_event_handlers`
220    /// in `src/events/handler.rs`) should call this so a typo is reported to the MCP
221    /// caller at `start_server` time rather than silently at the first packet.
222    pub fn validate(&self) -> Result<(), InterpolationError> {
223        if let EventHandlerType::Script { code, .. } = self {
224            if code.len() > super::types::ScriptSource::MAX_CODE_BYTES {
225                return Err(budget_error());
226            }
227        }
228        match self {
229            EventHandlerType::Static { actions } => {
230                for action in actions {
231                    validate_event_references(action)?;
232                }
233                Ok(())
234            }
235            // A script's contract depends on `resident`, and getting it wrong fails
236            // silently: a resident script defines `handle(event_type, event, message)`
237            // while a non-resident one reads stdin and prints its own output. Define
238            // `handle` without `resident: true` and the script produces nothing, the
239            // handler yields no actions, and the server fails closed -- which the peer
240            // sees as a generic protocol error with no hint of the real cause.
241            EventHandlerType::Script {
242                language,
243                code,
244                resident,
245                ..
246            } if !*resident && defines_handle(language, code) => {
247                Err(InterpolationError::script_contract(language.clone()))
248            }
249            _ => Ok(()),
250        }
251    }
252}
253
254/// Event handler configuration - maps event patterns to handlers
255///
256/// `deny_unknown_fields` is load-bearing. There are exactly two keys here, and there is no
257/// data-based matching: a rule matches on the event id alone. An `event_data_contains` key
258/// -- an entirely reasonable thing to assume exists, and something an LLM writing handlers
259/// will invent -- used to be accepted and silently ignored, so every rule registered
260/// against one event id matched every occurrence and first-match-wins picked the first.
261/// The config looked like it discriminated between requests and simply did not.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct EventHandler {
265    /// Pattern to match events
266    pub event_pattern: EventPattern,
267
268    /// Handler to use for matched events
269    pub handler: EventHandlerType,
270}
271
272impl EventHandler {
273    /// Create a new event handler
274    pub fn new(event_pattern: EventPattern, handler: EventHandlerType) -> Self {
275        Self {
276            event_pattern,
277            handler,
278        }
279    }
280
281    /// Check if this handler matches the given event type ID
282    pub fn matches(&self, event_type_id: &str) -> bool {
283        self.event_pattern.matches(event_type_id)
284    }
285}
286
287/// Configuration for all event handlers
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct EventHandlerConfig {
290    /// List of event handlers (processed in order, first match wins)
291    pub handlers: Vec<EventHandler>,
292}
293
294impl EventHandlerConfig {
295    /// Create a new empty configuration
296    pub fn new() -> Self {
297        Self {
298            handlers: Vec::new(),
299        }
300    }
301
302    /// Add a handler to the configuration
303    pub fn add_handler(&mut self, handler: EventHandler) {
304        self.handlers.push(handler);
305    }
306
307    /// Find the first handler that matches the given event type ID
308    pub fn find_handler(&self, event_type_id: &str) -> Option<&EventHandlerType> {
309        self.handlers
310            .iter()
311            .find(|h| h.matches(event_type_id))
312            .map(|h| &h.handler)
313    }
314
315    /// Check if any handlers are configured
316    pub fn is_empty(&self) -> bool {
317        self.handlers.is_empty()
318    }
319
320    /// Get the number of handlers
321    pub fn len(&self) -> usize {
322        self.handlers.len()
323    }
324}
325
326// ---------------------------------------------------------------------------
327// Static handler event interpolation
328// ---------------------------------------------------------------------------
329
330/// Opening delimiter of a reference.
331const REF_OPEN: &str = "{{";
332/// Closing delimiter of a reference.
333const REF_CLOSE: &str = "}}";
334/// The only root identifier that is substituted. `{{anything.else}}` is left alone.
335const REF_ROOT: &str = "event";
336/// `REF_ROOT` followed by the path separator.
337const REF_ROOT_DOT: &str = "event.";
338
339/// A `{{event.…}}` reference in a static handler action could not be resolved.
340///
341/// Carries the offending reference verbatim plus a human-readable reason that names the
342/// missing field and lists what the event actually offers.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct InterpolationError {
345    /// The reference exactly as it appeared, e.g. `{{event.headers.hsot}}`, or the
346    /// script's language when `kind` is [`ErrorKind::ScriptContract`].
347    pub reference: String,
348    /// Why it could not be resolved, including the available alternatives
349    pub detail: String,
350    /// Which validation failed, so the message reads correctly for each.
351    pub kind: ErrorKind,
352}
353
354/// What `EventHandlerType::validate` rejected.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum ErrorKind {
357    /// A `{{event.…}}` reference in a static handler could not be resolved.
358    Interpolation,
359    /// A script's entry point contradicts its `resident` flag.
360    ScriptContract,
361}
362
363impl InterpolationError {
364    fn new(reference: impl Into<String>, detail: impl Into<String>) -> Self {
365        Self {
366            reference: reference.into(),
367            detail: detail.into(),
368            kind: ErrorKind::Interpolation,
369        }
370    }
371
372    /// A script defines `handle(...)` but is not marked `resident` (or vice versa).
373    fn script_contract(language: impl Into<String>) -> Self {
374        Self {
375            reference: language.into(),
376            detail: String::new(),
377            kind: ErrorKind::ScriptContract,
378        }
379    }
380}
381
382impl std::fmt::Display for InterpolationError {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        match self.kind {
385            ErrorKind::Interpolation => write!(
386                f,
387                "static handler reference `{}` could not be resolved: {}",
388                self.reference, self.detail
389            ),
390            ErrorKind::ScriptContract => write!(
391                f,
392                "this {} script defines `handle(...)`, which is the RESIDENT entry point, \
393                 but the handler is not marked `resident: true`. As written the script \
394                 reads nothing from stdin and prints nothing, so the handler produces no \
395                 actions and the server fails closed -- with no hint of why. Add \
396                 `\"resident\": true`, or rewrite the script to read the event from stdin \
397                 and print its actions.",
398                self.reference
399            ),
400        }
401    }
402}
403
404impl std::error::Error for InterpolationError {}
405
406/// A located reference inside a string.
407struct FoundRef<'a> {
408    /// Byte offset of the opening `{{`
409    start: usize,
410    /// Byte offset just past the closing `}}`
411    end: usize,
412    /// The reference including delimiters, for error messages
413    raw: &'a str,
414    /// The trimmed contents between the delimiters (`event` or `event.…`)
415    inner: &'a str,
416}
417
418/// Find the next `{{event…}}` reference at or after byte offset `from`.
419///
420/// `{{` groups whose contents are not rooted at `event` are skipped, not consumed, so a
421/// Handlebars/Vue template embedded in a handler is left untouched. The scan advances one
422/// byte at a time on a miss, so `{{{event.x}}}` still finds the inner reference.
423fn find_reference(s: &str, from: usize) -> Option<FoundRef<'_>> {
424    let mut cursor = from;
425    while cursor < s.len() {
426        let open = cursor + s[cursor..].find(REF_OPEN)?;
427        let after_open = open + REF_OPEN.len();
428        let Some(rel_close) = s[after_open..].find(REF_CLOSE) else {
429            // No closing delimiter anywhere after this point: nothing left to find.
430            return None;
431        };
432        let close = after_open + rel_close;
433        let inner = s[after_open..close].trim();
434        if inner == REF_ROOT || inner.starts_with(REF_ROOT_DOT) {
435            return Some(FoundRef {
436                start: open,
437                end: close + REF_CLOSE.len(),
438                raw: &s[open..close + REF_CLOSE.len()],
439                inner,
440            });
441        }
442        cursor = open + 1;
443    }
444    None
445}
446
447/// Split a reference's contents into path segments. `{{event}}` yields an empty path.
448fn parse_path<'a>(found: &FoundRef<'a>) -> Result<Vec<&'a str>, InterpolationError> {
449    if found.inner == REF_ROOT {
450        return Ok(Vec::new());
451    }
452    let rest = &found.inner[REF_ROOT_DOT.len()..];
453    if rest.is_empty() {
454        return Err(InterpolationError::new(
455            found.raw,
456            format!(
457                "the path after `{}.` is empty; write `{{{{event.field}}}}` or `{{{{event}}}}` for the whole payload",
458                REF_ROOT
459            ),
460        ));
461    }
462    let mut segments = Vec::new();
463    for segment in rest.split('.') {
464        let segment = segment.trim();
465        if segment.is_empty() {
466            return Err(InterpolationError::new(
467                found.raw,
468                "it contains an empty path segment (a doubled or trailing `.`)",
469            ));
470        }
471        segments.push(segment);
472    }
473    Ok(segments)
474}
475
476/// Describe what can be reached from `value`, for the "available:" half of an error.
477fn describe_available(value: &Value) -> String {
478    match value {
479        Value::Object(map) if map.is_empty() => "this object is empty".to_string(),
480        Value::Object(map) => format!(
481            "available fields: {}",
482            map.keys()
483                .take(16)
484                .map(|key| key.chars().take(64).collect::<String>())
485                .collect::<Vec<_>>()
486                .join(", ")
487        ),
488        Value::Array(items) if items.is_empty() => "this array is empty".to_string(),
489        Value::Array(items) => format!("available indices: 0..{}", items.len() - 1),
490        other => format!("it is a {} and has no fields", json_type_name(other)),
491    }
492}
493
494fn json_type_name(value: &Value) -> &'static str {
495    match value {
496        Value::Null => "null",
497        Value::Bool(_) => "boolean",
498        Value::Number(_) => "number",
499        Value::String(_) => "string",
500        Value::Array(_) => "array",
501        Value::Object(_) => "object",
502    }
503}
504
505/// Walk `segments` through the event payload.
506fn resolve<'a>(
507    event: &'a Value,
508    segments: &[&str],
509    raw: &str,
510) -> Result<&'a Value, InterpolationError> {
511    let mut current = event;
512    let mut walked = REF_ROOT.to_string();
513    for segment in segments {
514        let next = match current {
515            Value::Object(map) => map.get(*segment),
516            Value::Array(items) => segment.parse::<usize>().ok().and_then(|i| items.get(i)),
517            _ => None,
518        };
519        current = next.ok_or_else(|| {
520            InterpolationError::new(
521                raw,
522                format!(
523                    "`{}` has no `{}` ({})",
524                    walked,
525                    segment,
526                    describe_available(current)
527                ),
528            )
529        })?;
530        walked.push('.');
531        walked.push_str(segment);
532    }
533    Ok(current)
534}
535
536/// Maximum resources for one static action expansion (including all actions).
537pub const MAX_INTERPOLATION_BYTES: usize = 8 * 1024 * 1024;
538pub const MAX_INTERPOLATION_NODES: usize = 65_536;
539pub const MAX_INTERPOLATION_DEPTH: usize = 64;
540
541fn budget_error() -> InterpolationError {
542    InterpolationError::new(
543        "event",
544        "static handler exceeds the 8 MiB, 65536-node or 64-level interpolation budget",
545    )
546}
547
548fn check_tree(value: &Value) -> Result<(), InterpolationError> {
549    if crate::utils::json_budget::within_budget(
550        value,
551        MAX_INTERPOLATION_BYTES,
552        MAX_INTERPOLATION_NODES,
553        MAX_INTERPOLATION_DEPTH,
554    ) {
555        Ok(())
556    } else {
557        Err(budget_error())
558    }
559}
560
561/// A shared serialization budget bounds expansion before any large intermediate
562/// String is allocated. It also catches JSON escaping expansion.
563struct ExpansionBudget {
564    bytes: usize,
565    nodes: usize,
566}
567impl ExpansionBudget {
568    fn append(&mut self, out: &mut String, text: &str) -> Result<(), InterpolationError> {
569        if text.len() > MAX_INTERPOLATION_BYTES.saturating_sub(self.bytes) {
570            return Err(budget_error());
571        }
572        self.bytes += text.len();
573        out.push_str(text);
574        Ok(())
575    }
576    fn visit(&mut self) -> Result<(), InterpolationError> {
577        self.nodes += 1;
578        if self.nodes > MAX_INTERPOLATION_NODES {
579            return Err(budget_error());
580        }
581        Ok(())
582    }
583}
584
585fn bounded_display(
586    value: &Value,
587    budget: &mut ExpansionBudget,
588) -> Result<String, InterpolationError> {
589    check_tree(value)?;
590    if let Value::String(text) = value {
591        let mut result = String::new();
592        budget.append(&mut result, text)?;
593        return Ok(result);
594    }
595    struct Writer<'a> {
596        data: Vec<u8>,
597        budget: &'a mut ExpansionBudget,
598    }
599    impl std::io::Write for Writer<'_> {
600        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
601            if bytes.len() > MAX_INTERPOLATION_BYTES.saturating_sub(self.budget.bytes) {
602                return Err(std::io::Error::other("interpolation byte cap"));
603            }
604            self.budget.bytes += bytes.len();
605            self.data.extend_from_slice(bytes);
606            Ok(bytes.len())
607        }
608        fn flush(&mut self) -> std::io::Result<()> {
609            Ok(())
610        }
611    }
612    let mut writer = Writer {
613        data: Vec::new(),
614        budget,
615    };
616    serde_json::to_writer(&mut writer, value).map_err(|_| budget_error())?;
617    String::from_utf8(writer.data).map_err(|_| budget_error())
618}
619
620fn clone_bounded(value: &Value, budget: &mut ExpansionBudget) -> Result<Value, InterpolationError> {
621    check_tree(value)?;
622    fn copy(value: &Value, budget: &mut ExpansionBudget) -> Result<Value, InterpolationError> {
623        budget.visit()?;
624        match value {
625            Value::String(text) => {
626                let mut out = String::new();
627                budget.append(&mut out, text)?;
628                Ok(Value::String(out))
629            }
630            Value::Array(items) => items
631                .iter()
632                .map(|v| copy(v, budget))
633                .collect::<Result<Vec<_>, _>>()
634                .map(Value::Array),
635            Value::Object(map) => {
636                let mut out = serde_json::Map::new();
637                for (key, value) in map {
638                    let mut owned = String::new();
639                    budget.append(&mut owned, key)?;
640                    out.insert(owned, copy(value, budget)?);
641                }
642                Ok(Value::Object(out))
643            }
644            other => Ok(other.clone()),
645        }
646    }
647    copy(value, budget)
648}
649
650/// The event payload was absent, but a reference needed it.
651fn missing_event_error(raw: &str) -> InterpolationError {
652    InterpolationError::new(
653        raw,
654        "this event carries no structured data, so nothing can be substituted",
655    )
656}
657
658/// Interpolate one string, returning a `Value` so a whole-string reference keeps its type.
659fn interpolate_string(
660    s: &str,
661    event: Option<&Value>,
662    budget: &mut ExpansionBudget,
663) -> Result<Value, InterpolationError> {
664    let Some(first) = find_reference(s, 0) else {
665        // No reference at all: byte-identical pass-through.
666        let mut out = String::new();
667        budget.append(&mut out, s)?;
668        return Ok(Value::String(out));
669    };
670
671    // Rule 1: the string is exactly one reference -> substitute the JSON value itself.
672    if first.start == 0 && first.end == s.len() {
673        let segments = parse_path(&first)?;
674        let event = event.ok_or_else(|| missing_event_error(first.raw))?;
675        return clone_bounded(resolve(event, &segments, first.raw)?, budget);
676    }
677
678    // Rule 2: one or more references embedded in surrounding text -> string splice.
679    let mut out = String::new();
680    let mut cursor = 0usize;
681    let mut found = Some(first);
682    while let Some(f) = found {
683        budget.append(&mut out, &s[cursor..f.start])?;
684        let segments = parse_path(&f)?;
685        let event = event.ok_or_else(|| missing_event_error(f.raw))?;
686        out.push_str(&bounded_display(resolve(event, &segments, f.raw)?, budget)?);
687        cursor = f.end;
688        found = find_reference(s, cursor);
689    }
690    budget.append(&mut out, &s[cursor..])?;
691    Ok(Value::String(out))
692}
693
694/// Substitute every `{{event.…}}` reference in a JSON value tree.
695///
696/// Object keys are interpolated too, always as text (a JSON key must be a string).
697/// Values with no references are returned unchanged.
698pub fn interpolate_value(
699    value: &Value,
700    event_data: Option<&Value>,
701) -> Result<Value, InterpolationError> {
702    check_tree(value)?;
703    let result = interpolate_inner(
704        value,
705        event_data,
706        &mut ExpansionBudget { bytes: 0, nodes: 0 },
707    )?;
708    if check_tree(&result).is_err() {
709        crate::utils::json_budget::drop_iteratively(result);
710        return Err(budget_error());
711    }
712    Ok(result)
713}
714
715fn interpolate_inner(
716    value: &Value,
717    event_data: Option<&Value>,
718    budget: &mut ExpansionBudget,
719) -> Result<Value, InterpolationError> {
720    budget.visit()?;
721    match value {
722        Value::String(s) => interpolate_string(s, event_data, budget),
723        Value::Array(items) => items
724            .iter()
725            .map(|item| interpolate_inner(item, event_data, budget))
726            .collect::<Result<Vec<_>, _>>()
727            .map(Value::Array),
728        Value::Object(map) => {
729            let mut out = serde_json::Map::new();
730            for (key, val) in map {
731                let key = match interpolate_string(key, event_data, budget)? {
732                    Value::String(s) => s,
733                    other => bounded_display(&other, budget)?,
734                };
735                if out.contains_key(&key) {
736                    return Err(InterpolationError::new(key, "interpolated keys collide"));
737                }
738                out.insert(key, interpolate_inner(val, event_data, budget)?);
739            }
740            Ok(Value::Object(out))
741        }
742        other => Ok(other.clone()),
743    }
744}
745
746/// Expand all actions under one shared byte/node budget.
747pub fn interpolate_actions(
748    actions: &[Value],
749    event_data: Option<&Value>,
750) -> Result<Vec<Value>, InterpolationError> {
751    if actions.len() > MAX_INTERPOLATION_NODES {
752        return Err(budget_error());
753    }
754    let mut budget = ExpansionBudget { bytes: 0, nodes: 0 };
755    let expanded = actions
756        .iter()
757        .map(|action| {
758            check_tree(action)?;
759            interpolate_inner(action, event_data, &mut budget)
760        })
761        .collect::<Result<Vec<_>, _>>()?;
762    let combined = Value::Array(expanded);
763    if check_tree(&combined).is_err() {
764        crate::utils::json_budget::drop_iteratively(combined);
765        return Err(budget_error());
766    }
767    let Value::Array(expanded) = combined else {
768        unreachable!()
769    };
770    Ok(expanded)
771}
772
773/// Iterative inspection is also safe for programmatically constructed JSON.
774pub fn contains_event_reference(value: &Value) -> bool {
775    if check_tree(value).is_err() {
776        return false;
777    }
778    let mut pending = vec![value];
779    while let Some(value) = pending.pop() {
780        match value {
781            Value::String(s) if find_reference(s, 0).is_some() => return true,
782            Value::Array(items) => pending.extend(items),
783            Value::Object(map) => {
784                if map.keys().any(|key| find_reference(key, 0).is_some()) {
785                    return true;
786                }
787                pending.extend(map.values());
788            }
789            _ => {}
790        }
791    }
792    false
793}
794
795/// Whether a script defines the resident entry point `handle(...)`.
796///
797/// Deliberately syntactic and conservative: it looks for a definition, not a call, so a
798/// script that merely mentions the word is not flagged.
799fn defines_handle(language: &str, code: &str) -> bool {
800    let language = language.to_ascii_lowercase();
801    let python = matches!(language.as_str(), "python" | "python3");
802    let perl = language == "perl";
803    let javascript = matches!(language.as_str(), "javascript" | "js" | "node");
804    if !python && !perl && !javascript && language != "go" {
805        return false;
806    }
807    // Ignore comments and string literals using each language's delimiters.
808    // Tokenization makes whitespace/newlines irrelevant without executing code.
809    let bytes = code.as_bytes();
810    let mut tokens: Vec<&str> = Vec::new();
811    let mut i = 0;
812    while i < bytes.len() {
813        if bytes[i].is_ascii_whitespace() {
814            i += 1;
815            continue;
816        }
817        if ((python || perl) && bytes[i] == b'#')
818            || (!python && !perl && bytes[i..].starts_with(b"//"))
819        {
820            while i < bytes.len() && bytes[i] != b'\n' {
821                i += 1;
822            }
823            continue;
824        }
825        if !python && !perl && bytes[i..].starts_with(b"/*") {
826            i += 2;
827            while i < bytes.len() && !bytes[i..].starts_with(b"*/") {
828                i += 1;
829            }
830            i = (i + 2).min(bytes.len());
831            continue;
832        }
833        // Perl's q/qq/qw/qx/qr operators are strings/regexes too, not code.
834        if perl && bytes[i] == b'q' {
835            let mut delimiter = i + 1;
836            if bytes
837                .get(delimiter)
838                .is_some_and(|b| matches!(*b, b'q' | b'w' | b'x' | b'r'))
839            {
840                delimiter += 1;
841            }
842            while bytes.get(delimiter).is_some_and(u8::is_ascii_whitespace) {
843                delimiter += 1;
844            }
845            if let Some(&open) = bytes
846                .get(delimiter)
847                .filter(|b| !b.is_ascii_alphanumeric() && **b != b'_')
848            {
849                let close = match open {
850                    b'{' => b'}',
851                    b'[' => b']',
852                    b'(' => b')',
853                    b'<' => b'>',
854                    other => other,
855                };
856                let mut depth = 1;
857                i = delimiter + 1;
858                while i < bytes.len() && depth > 0 {
859                    if bytes[i] == b'\\' {
860                        i = (i + 2).min(bytes.len());
861                        continue;
862                    }
863                    if bytes[i] == close {
864                        depth -= 1;
865                    } else if open != close && bytes[i] == open {
866                        depth += 1;
867                    }
868                    i += 1;
869                }
870                tokens.push("<literal>");
871                continue;
872            }
873        }
874        if matches!(bytes[i], b'\'' | b'"' | b'`') {
875            let quote = bytes[i];
876            let triple = python && bytes[i..].starts_with(&[quote; 3]);
877            let size = if triple { 3 } else { 1 };
878            i += size;
879            while i < bytes.len() {
880                if bytes[i] == b'\\' {
881                    i = (i + 2).min(bytes.len());
882                    continue;
883                }
884                if bytes[i] == quote && (!triple || bytes[i..].starts_with(&[quote; 3])) {
885                    i += size;
886                    break;
887                }
888                i += 1;
889            }
890            // Keep a boundary so tokens on either side cannot form a declaration.
891            tokens.push("<literal>");
892            continue;
893        }
894        // Regex literals can contain text resembling a function declaration.
895        if javascript
896            && bytes[i] == b'/'
897            && tokens
898                .last()
899                .is_none_or(|t| matches!(*t, "=" | "(" | "," | ":" | "return"))
900        {
901            i += 1;
902            let mut class = false;
903            while i < bytes.len() {
904                match bytes[i] {
905                    b'\\' => {
906                        i = (i + 2).min(bytes.len());
907                        continue;
908                    }
909                    b'[' => class = true,
910                    b']' => class = false,
911                    b'/' if !class => {
912                        i += 1;
913                        break;
914                    }
915                    b'\n' => break,
916                    _ => {}
917                }
918                i += 1;
919            }
920            tokens.push("<literal>");
921            continue;
922        }
923        let start = i;
924        if bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'$' {
925            i += 1;
926            while i < bytes.len()
927                && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'_' | b'$'))
928            {
929                i += 1;
930            }
931        } else {
932            // Advance by a complete UTF-8 character before slicing.
933            i += code[i..].chars().next().unwrap().len_utf8();
934        }
935        tokens.push(&code[start..i]);
936    }
937    for (index, token) in tokens.iter().enumerate() {
938        if *token != "handle" {
939            continue;
940        }
941        let before = index.checked_sub(1).and_then(|n| tokens.get(n)).copied();
942        let after = tokens.get(index + 1).copied();
943        if python && before == Some("def") && after == Some("(") {
944            return true;
945        }
946        if perl && before == Some("sub") && matches!(after, Some("{" | "(" | ":")) {
947            return true;
948        }
949        if language == "go" && before == Some("func") && after == Some("(") {
950            return true;
951        }
952        if javascript {
953            if before == Some("function") && after == Some("(") {
954                return true;
955            }
956            if after != Some("=") {
957                continue;
958            }
959            let mut next = index + 2;
960            if tokens.get(next) == Some(&"async") {
961                next += 1;
962            }
963            if tokens.get(next) == Some(&"function") {
964                return true;
965            }
966            if tokens.get(next) == Some(&"(") {
967                let mut depth = 0;
968                while let Some(token) = tokens.get(next) {
969                    match *token {
970                        "(" => depth += 1,
971                        ")" => depth -= 1,
972                        _ => {}
973                    }
974                    next += 1;
975                    if depth == 0 {
976                        break;
977                    }
978                }
979            } else {
980                next += 1;
981            } // one unparenthesized arrow parameter
982            if tokens.get(next..next.saturating_add(2)) == Some(&["=", ">"][..]) {
983                return true;
984            }
985        }
986    }
987    false
988}
989
990/// Check every reference in a value tree for syntactic validity, without an event.
991///
992/// Catches malformed paths (`{{event.}}`, `{{event..x}}`) that are wrong for any event.
993/// Field existence is deliberately *not* checked here: it depends on the payload.
994pub fn validate_event_references(value: &Value) -> Result<(), InterpolationError> {
995    check_tree(value)?;
996    fn check_string(s: &str) -> Result<(), InterpolationError> {
997        let mut cursor = 0;
998        while let Some(found) = find_reference(s, cursor) {
999            parse_path(&found)?;
1000            cursor = found.end;
1001        }
1002        Ok(())
1003    }
1004    let mut pending = vec![value];
1005    while let Some(value) = pending.pop() {
1006        match value {
1007            Value::String(s) => check_string(s)?,
1008            Value::Array(items) => pending.extend(items),
1009            Value::Object(map) => {
1010                for key in map.keys() {
1011                    check_string(key)?;
1012                }
1013                pending.extend(map.values());
1014            }
1015            _ => {}
1016        }
1017    }
1018    Ok(())
1019}