Silent JS compile fallback: a syntax error becomes a stage that returns its own source as a string #19
Labels
No labels
agent
blocked
agent
new
agent
review
agent
working
complexity
high
complexity
low
priority
high
priority
low
priority
medium
risk
high
risk
low
risk
medium
type
bug
type
chore
type
feature
type
security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
tfks/logbus#19
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
When an operator-supplied JS config value fails to parse, js_sandbox::compile_fn does not report the error. It falls back to compiling a function that returns the source text as a string literal. The pipeline then starts, runs, and writes plausible-looking output built from that string. Nothing appears on ERRORS, nothing appears in the logs, and logbus --check reports the config as OK.
Reproduction
An agg stage whose view: is missing one closing brace:
run-rows:
module: agg
config:
filter: () => true
key: (e) => e.run_id
view: |
(events) => {
return { run_id: events[0].run_id, count: events.length };
# ← closing } omitted
Expected: config check fails, naming the stage and the syntax error.
Actual: logbus --check prints config ok. The pipeline runs to completion. The view returns its own source, which the downstream stage spreads character by character, so the output file contains:
[{ "0": "(", "1": "e", "2": "v", "3": "e", "4": "n", "5": "t", "6": "s", "7": ")", ... }]
Root cause
src/util/js_sandbox.rs:252:
pub fn compile_fn(ctx: &rquickjs::Ctx, name: &str, src: &str) -> anyhow::Result<()> {
let as_fn = format!("var {name} = ({src}); if (typeof {name} !== 'function') throw 0; ...");
if ctx.eval::<(), _>(as_fn.as_bytes()).is_ok() {
return Ok(());
}
// Any parse failure lands here, indistinguishable from "operator meant a literal"
let escaped = serde_json::to_string(src).unwrap();
let as_str = format!("var {name} = function() {{ return {escaped}; }};");
ctx.eval::<(), _>(as_str.as_bytes())
.map_err(|e| anyhow::anyhow!("JS compile error in '{name}': {e}"))
}
The fallback is deliberate and load-bearing-valued config fields stay strings (to:ops@example.com, a literal SQL query:). The defect is that it collapses two very different intents into one
silent path: "this was always meant to be meant to be a function and the operatormade a typo."
Blast radius
compile_fn backs ~20 config fields across 9 plugins:
Every field in the left column can currently be silently replaced by its own source text. js with path: is the sharpest case — a whole file whose onction, where the string fallback is never a legitimate reading.
Proposed fix
compile_expr already has the heuristic this needs, three lines away (js_sandbox.rs:278):
let is_fn = src.contains("=>") || src.trim_start().starts_with("function");
Apply the same test in compile_fn: if the source looks like a function but fails to parse, propagate the
syntax error instead of falling back. Lit(ops@example.com contains no =>), and every realistic typo in a function-valued field starts failing loudly. This fixes the whole class without
classifying fields one by one.
Optionally, and more precisely: give comp variant and use it for the left columnabove, so key: somestring in an agg is rejected too.
Acceptance
and the QuickJS error text.
Related paper cut, same failure surface
js with path: wraps the file's contents imicolon (})();) is a syntax error while})() is fine. Correct today, but it is an easy mistake that currently lands in exactly this silent path, and it is not documented near the path: optio