diff --git a/crates/splash-capabilities/src/lib.rs b/crates/splash-capabilities/src/lib.rs index 1589656..12cc721 100644 --- a/crates/splash-capabilities/src/lib.rs +++ b/crates/splash-capabilities/src/lib.rs @@ -7632,7 +7632,7 @@ mod tests { ) .unwrap(); - assert!(report.completed(), "{:?}", report.diagnostics); + assert!(!report.completed(), "{:?}", report.diagnostics); assert!(report .diagnostics .iter() @@ -7641,6 +7641,30 @@ mod tests { assert!(runtime.audit().is_empty()); } + #[test] + fn guards_and_logical_precedence_skip_granted_effects() { + for (source, completed) in [ + ( + "use mod.std;use mod.tool;std.assert(false);tool.call(\"text.echo\",\"secret\")", + false, + ), + ( + "use mod.tool;true || false && tool.call(\"text.echo\",\"secret\")", + true, + ), + ] { + let mut runtime = CapabilityRuntime::default(); + runtime + .register_tool(ToolPolicy::new("text.echo"), |_| { + panic!("a skipped effect must never execute") + }) + .unwrap(); + let result = runtime.eval(source).unwrap(); + assert_eq!(result.completed(), completed, "{:?}", result.diagnostics); + assert!(runtime.audit().is_empty()); + } + } + #[test] fn try_catch_cannot_widen_a_lease_and_can_use_its_granted_fallback() { let shell_calls = std::rc::Rc::new(std::cell::Cell::new(0)); diff --git a/crates/splash-capabilities/tests/canonical_profile.rs b/crates/splash-capabilities/tests/canonical_profile.rs index 315f9f5..128e860 100644 --- a/crates/splash-capabilities/tests/canonical_profile.rs +++ b/crates/splash-capabilities/tests/canonical_profile.rs @@ -47,7 +47,7 @@ fn executes_shipped_canonical_fixtures_through_real_capability_bindings() { "../../splash-core/tests/fixtures/canonical_constructs.splash" )) .unwrap(); - assert!(initial.suspended); + assert!(initial.suspended, "{:?}", initial.diagnostics); let pump = runtime.pump().unwrap(); assert_eq!(pump.completed, 1); diff --git a/crates/splash-cli/tests/cli_contract.rs b/crates/splash-cli/tests/cli_contract.rs index 2913170..4757bd8 100644 --- a/crates/splash-cli/tests/cli_contract.rs +++ b/crates/splash-cli/tests/cli_contract.rs @@ -28,6 +28,27 @@ fn stderr(output: &Output) -> String { String::from_utf8(output.stderr.clone()).expect("the Splash CLI should emit UTF-8 stderr") } +#[test] +fn evaluation_stops_before_a_granted_effect_after_an_uncaught_error() { + let output = run_splash(vec![ + "eval".into(), + "--allow-echo".into(), + "use mod.std;use mod.tool;std.assert(false);tool.call(\"text.echo\",\"must not run\")" + .into(), + ]); + assert!(!output.status.success()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("must not run")); + assert!(stderr(&output).contains("assertion failed")); +} + +#[test] +fn direct_debug_logging_cannot_write_to_cli_stdout() { + let output = run_splash(vec!["eval".into(), "~\"private marker\"".into()]); + assert!(!output.status.success()); + assert!(output.stdout.is_empty(), "{:?}", output.stdout); + assert!(!stderr(&output).contains("private marker")); +} + #[test] fn profile_contract_discloses_no_ambient_authority() { let output = run_splash(vec!["profile".to_owned()]); diff --git a/crates/splash-core/src/lib.rs b/crates/splash-core/src/lib.rs index c8fedf5..459674e 100644 --- a/crates/splash-core/src/lib.rs +++ b/crates/splash-core/src/lib.rs @@ -92,6 +92,9 @@ pub const DEFAULT_BUDGET_SAMPLE_INTERVAL: u32 = 1_024; pub const DEFAULT_MAX_JSON_DATA_BYTES: usize = 64 * 1024; /// Default maximum JSON container nesting accepted at a host-data boundary. pub const DEFAULT_MAX_JSON_DATA_DEPTH: usize = 64; +/// JSON integers crossing the VM boundary must fit the contiguous exact +/// binary64 integer range. Larger identifiers should be encoded as strings. +pub const MAX_SAFE_JSON_INTEGER: u64 = 9_007_199_254_740_991; /// Maximum source array items processed by one transforming standard /// collection helper. /// @@ -395,6 +398,7 @@ pub enum RuntimeJsonError { TooDeep { maximum: usize }, InvalidEncoding, NonFiniteNumber, + UnsafeInteger, UnsupportedScriptValue, NonStringObjectKey, UnknownObjectKey, @@ -427,6 +431,8 @@ impl Display for RuntimeJsonError { Self::NonFiniteNumber => { formatter.write_str("a Splash non-finite number cannot cross a JSON boundary") } + Self::UnsafeInteger => formatter + .write_str("JSON integers outside +/-9007199254740991 must be encoded as strings"), Self::UnsupportedScriptValue => { formatter.write_str("a Splash value cannot be represented as JSON") } @@ -779,7 +785,7 @@ pub struct ImportedModuleCallHintReport { impl Evaluation { pub fn succeeded(&self) -> bool { - !self.value.is_err() + !self.value.is_err() && self.diagnostics.is_empty() } pub fn completed(&self) -> bool { @@ -819,6 +825,7 @@ impl Runtime { json_method_limits, }; runtime.with_vm(|vm| { + vm.bx.allow_debug_output = false; vm.bx .heap .set_max_string_bytes(Some(limits.max_string_bytes)); @@ -924,16 +931,15 @@ impl Runtime { if !is_valid_json_global_name(name) { return Err(RuntimeError::JsonData(RuntimeJsonError::InvalidGlobalName)); } - let encoded = - serialize_bounded_json(value, max_bytes, max_depth).map_err(RuntimeError::JsonData)?; + serialize_bounded_json(value, max_bytes, max_depth).map_err(RuntimeError::JsonData)?; + validate_script_json_numbers(value).map_err(RuntimeError::JsonData)?; let max_string_bytes = self.limits.max_string_bytes; let max_heap_bytes = self.limits.max_heap_bytes; self.with_vm(|vm| { if has_paused_thread(vm) { return Err(RuntimeError::EvaluationInProgress); } - let mut parser = vm::json::JsonParserThread::default(); - let value = parser.read_json(&encoded, &mut vm.bx.heap); + let value = materialize_script_json(&mut vm.bx.heap, value); vm.bx.heap.reconcile_heap_bytes(); let actual = vm.bx.heap.accounted_heap_bytes(); let heap_limit_exceeded = vm.bx.heap.take_heap_limit_exceeded(); @@ -2017,8 +2023,8 @@ pub fn encode_bounded_script_json( /// Decodes bounded JSON into a Splash value for a trusted native binding. /// -/// The JSON is parsed and re-encoded through Splash's bounded host-data -/// boundary before the vendored VM materializes it. This prevents a native +/// The JSON is validated through Splash's bounded host-data boundary before +/// its decoded values are copied into the VM. This prevents a native /// binding from bypassing the runtime's JSON byte or nesting limits. Decoding /// data does not load a module or grant a capability. pub fn decode_bounded_script_json( @@ -2028,9 +2034,72 @@ pub fn decode_bounded_script_json( max_depth: usize, ) -> Result { let value = parse_bounded_json(document, max_bytes, max_depth)?; - let encoded = serialize_bounded_json(&value, max_bytes, max_depth)?; - let mut parser = vm::json::JsonParserThread::default(); - Ok(parser.read_json(&encoded, &mut vm.bx.heap)) + validate_script_json_numbers(&value)?; + serialize_bounded_json(&value, max_bytes, max_depth)?; + Ok(materialize_script_json(&mut vm.bx.heap, &value)) +} + +// Inputs have already passed byte, depth, and numeric checks. Copy decoded +// values directly: the compatibility script tokenizer is not a JSON decoder +// and loses signs on negative JSON numbers. +fn materialize_script_json(heap: &mut vm::heap::ScriptHeap, value: &JsonValue) -> vm::ScriptValue { + match value { + JsonValue::Null => vm::ScriptValue::NIL, + JsonValue::Bool(value) => (*value).into(), + JsonValue::Number(value) => value.as_f64().expect("validated JSON number").into(), + JsonValue::String(value) => heap.new_string_from_str(value), + JsonValue::Array(values) => { + let array = heap.new_array(); + for value in values { + let value = materialize_script_json(heap, value); + heap.array_push_unchecked(array, value); + } + array.into() + } + JsonValue::Object(values) => { + let object = heap.new_object(); + heap.set_string_keys(object); + for (key, value) in values { + let key = heap.new_string_from_str(key); + let value = materialize_script_json(heap, value); + heap.set_value_def(object, key, value); + } + object.into() + } + } +} + +// Call only after the ordinary JSON depth/byte checks. Host-only JSON and +// schema/telemetry serialization retain their exact serde integer domain. +fn validate_script_json_numbers(value: &JsonValue) -> Result<(), RuntimeJsonError> { + match value { + JsonValue::Number(number) => { + let safe = if let Some(integer) = number.as_u64() { + integer <= MAX_SAFE_JSON_INTEGER + } else if let Some(integer) = number.as_i64() { + integer.unsigned_abs() <= MAX_SAFE_JSON_INTEGER + } else { + number.as_f64().is_some_and(|n| { + n.is_finite() && (n.fract() != 0.0 || n.abs() <= MAX_SAFE_JSON_INTEGER as f64) + }) + }; + if !safe { + return Err(RuntimeJsonError::UnsafeInteger); + } + } + JsonValue::Array(values) => { + for value in values { + validate_script_json_numbers(value)?; + } + } + JsonValue::Object(values) => { + for value in values.values() { + validate_script_json_numbers(value)?; + } + } + _ => {} + } + Ok(()) } fn validate_json_depth( @@ -2142,6 +2211,9 @@ fn write_script_json( return Err(RuntimeJsonError::NonFiniteNumber); } if value.fract() == 0.0 { + if value.abs() > MAX_SAFE_JSON_INTEGER as f64 { + return Err(RuntimeJsonError::UnsafeInteger); + } return writer.append(&value.to_string()); } let Some(number) = serde_json::Number::from_f64(value) else { @@ -5377,8 +5449,8 @@ mod tests { let evaluation = runtime.eval(source).unwrap(); assert!( - evaluation.completed(), - "{name} did not complete: {:?}", + !evaluation.completed(), + "{name} must stop at the uncaught assertion: {:?}", evaluation.diagnostics ); assert!( @@ -5389,17 +5461,6 @@ mod tests { "{name}: {:?}", evaluation.diagnostics ); - assert_eq!( - runtime - .script_value_as_json( - evaluation.value, - DEFAULT_MAX_JSON_DATA_BYTES, - DEFAULT_MAX_JSON_DATA_DEPTH, - ) - .unwrap(), - serde_json::json!(0), - "{name} re-entered an abandoned fallback" - ); } } @@ -8446,7 +8507,7 @@ compute(outer, 2) array.push(squares, index * index)\n\ }\n\ assert(squares == [0, 1, 4])\n\ - assert(array.range(9007199254740991, 9007199254740992) == [9007199254740991])\n\ + assert(array.range(9007199254740990, 9007199254740991) == [9007199254740990])\n\ assert(array.concat([1, 2], [3, 4]) == [1, 2, 3, 4])\n\ let optional_nested = {answer: 1}\n\ let optional = [nil, false, 0, \"\", optional_nested, nil]\n\ @@ -8609,7 +8670,7 @@ compute(outer, 2) ); let invalid_range_precision = runtime - .eval("use mod.std.array\ntry array.range(0, 9007199254740994) catch \"invalid\"") + .eval("use mod.std.array\ntry array.range(0, 9007199254740991+3) catch \"invalid\"") .unwrap(); assert!( invalid_range_precision.completed(), @@ -8701,7 +8762,7 @@ compute(outer, 2) let oversized_len = Runtime::default() .eval(&format!( - "use mod.std.array\n\ + "use mod.std.assert\nuse mod.std.array\n\ let values = []\n\ values[{MAX_STANDARD_ARRAY_ITEMS}] = 0\n\ assert(array.len(values) == {})\n\ diff --git a/crates/splash-core/src/profile.rs b/crates/splash-core/src/profile.rs index ab6058f..0f1cfb1 100644 --- a/crates/splash-core/src/profile.rs +++ b/crates/splash-core/src/profile.rs @@ -1341,6 +1341,15 @@ impl<'source> ProfileLexer<'source> { ); } + let spelling: String = self.chars[start..self.index].iter().collect(); + if let Ok(value) = spelling.parse::() { + if !value.is_finite() + || (value.fract() == 0.0 && value.abs() > crate::MAX_SAFE_JSON_INTEGER as f64) + { + self.report(line, column, + "numeric literals must be finite; integers outside +/-9007199254740991 must be strings"); + } + } self.emit(TokenKind::Number, line, column, start, self.index); } @@ -1566,7 +1575,13 @@ impl<'source> ProfileLexer<'source> { self.advance(); } let operator: String = self.chars[start..self.index].iter().collect(); - if is_canonical_operator(&operator) { + if operator == "~" { + self.report( + line, + column, + "direct logging (`~`) is not part of the canonical Splash profile", + ); + } else if is_canonical_operator(&operator) { self.emit( TokenKind::Operator(operator), line, @@ -2103,6 +2118,7 @@ struct CanonicalParser { imports: Option, static_record_shapes: Option, vm_statement_separator_offsets: Option>, + vm_block_tail_edits: Vec<(usize, usize, &'static str)>, } impl CanonicalParser { @@ -2119,6 +2135,7 @@ impl CanonicalParser { imports: None, static_record_shapes: None, vm_statement_separator_offsets: None, + vm_block_tail_edits: Vec::new(), } } @@ -2150,16 +2167,27 @@ impl CanonicalParser { .vm_statement_separator_offsets .take() .expect("VM statement-separator collection was enabled"); - let inserted_statement_separators = offsets.len(); - let mut lowered = String::with_capacity(source.len() + inserted_statement_separators); + // Also budget synthesized nil tails. Removed source semicolons only + // reduce the VM token count and retain their source width as spaces. + let inserted_statement_separators = offsets.len() + + self + .vm_block_tail_edits + .iter() + .filter(|(_, _, text)| *text == " nil ") + .count(); + let mut edits: Vec<_> = offsets + .into_iter() + .map(|offset| (offset, offset, ";")) + .collect(); + edits.extend(self.vm_block_tail_edits); + edits.sort_by_key(|(start, _, _)| *start); + let mut lowered = String::with_capacity(source.len() + inserted_statement_separators * 5); let mut source_offset = 0_usize; - - for offset in offsets { - debug_assert!(source_offset <= offset && offset <= source.len()); - debug_assert!(source.is_char_boundary(offset)); - lowered.push_str(&source[source_offset..offset]); - lowered.push(';'); - source_offset = offset; + for (start, end, replacement) in edits { + debug_assert!(source_offset <= start && end <= source.len()); + lowered.push_str(&source[source_offset..start]); + lowered.push_str(replacement); + source_offset = end; } lowered.push_str(&source[source_offset..]); @@ -2217,6 +2245,10 @@ impl CanonicalParser { } fn parse_block(&mut self) { + self.parse_block_with_value(false); + } + + fn parse_block_with_value(&mut self, produces_value: bool) { if !self.enter_nesting() { return; } @@ -2226,14 +2258,36 @@ impl CanonicalParser { return; } self.consume_statement_ends(); + let mut tail_is_value = false; + let mut tail_boundary = self.index; while !self.at_end() && !self.at_kind(&TokenKind::CloseCurly) { let start = self.index; + tail_is_value = !self.starts_non_value_try_tail(); self.parse_statement(); + tail_boundary = self.index; self.require_statement_end(); if self.index == start { self.advance(); } } + if produces_value && self.at_kind(&TokenKind::CloseCurly) { + let close = self.current().start_byte; + if let Some(offsets) = self.vm_statement_separator_offsets.as_mut() { + if tail_is_value { + if offsets.last() == Some(&close) { + offsets.pop(); + } + for token in &self.tokens[tail_boundary..self.index] { + if matches!(token.kind, TokenKind::Semicolon) { + self.vm_block_tail_edits + .push((token.start_byte, token.end_byte, " ")); + } + } + } else { + self.vm_block_tail_edits.push((close, close, " nil ")); + } + } + } if !self.take_kind(&TokenKind::CloseCurly) { self.report_current("expected `}` to close a block"); } @@ -2524,7 +2578,7 @@ impl CanonicalParser { fn parse_conditional_branch(&mut self) { if self.at_kind(&TokenKind::OpenCurly) { - self.parse_block(); + self.parse_block_with_value(true); } else if self.starts_lambda_expression() { self.report_current("a lambda used as a conditional branch must be written in a block"); } else if self.at_expression_boundary() { @@ -2640,7 +2694,7 @@ impl CanonicalParser { } fn parse_unary(&mut self) { - self.take_any_operator(&["!", "-", "+", "~"]); + self.take_any_operator(&["!", "-", "+"]); self.parse_postfix(); } diff --git a/crates/splash-core/tests/fixtures/canonical_constructs.splash b/crates/splash-core/tests/fixtures/canonical_constructs.splash index 21264c7..8993ecd 100644 --- a/crates/splash-core/tests/fixtures/canonical_constructs.splash +++ b/crates/splash-core/tests/fixtures/canonical_constructs.splash @@ -33,7 +33,7 @@ for index, value in flags { break } -for first, second, third in flags { +for first, second, third in record { break } diff --git a/crates/splash-core/tests/language_regressions.rs b/crates/splash-core/tests/language_regressions.rs new file mode 100644 index 0000000..0536f02 --- /dev/null +++ b/crates/splash-core/tests/language_regressions.rs @@ -0,0 +1,256 @@ +//! Reproductions from the September 2026 language review (#9–#18). +use serde_json::{json, Value}; +use splash_core::{ExecutionLimits, Runtime, RuntimeError, RuntimeJsonError}; + +fn evaluate(source: &str) -> Value { + let mut runtime = Runtime::default(); + let result = runtime + .eval(source) + .unwrap_or_else(|e| panic!("{source}: {e:?}")); + assert!(result.completed(), "{source}: {:?}", result.diagnostics); + runtime + .script_value_as_json(result.value, 65536, 64) + .unwrap() +} + +#[test] +fn cyclic_and_shared_graph_equality_terminates() { + // Run the crash reproduction in a child so a future stack overflow or + // runaway native comparison produces a bounded, ordinary test failure. + const CHILD: &str = "SPLASH_EQUALITY_REGRESSION_CHILD"; + if std::env::var_os(CHILD).is_some() { + for source in [ + "let a=[nil];a[0]=a;let b=[nil];b[0]=b;a==b", + "let a={next:nil};a.next=a;let b={next:nil};b.next=b;a==b", + "let a=[0];let b=[0];let i=0;while i<29 {a=[a,a];b=[b,b];i+=1;};a==b", + "let a=[0];let b=[0];let i=0;while i<1000 {a=[a];b=[b];i+=1;};a==b", + ] { + assert_eq!(evaluate(source), json!(true)); + } + assert_eq!( + evaluate("let a={next:nil,tag:1};a.next=a;let b={next:nil,tag:2};b.next=b;a!=b"), + json!(true) + ); + return; + } + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "cyclic_and_shared_graph_equality_terminates", + "--nocapture", + ]) + .env(CHILD, "1") + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success()); + break; + } + if std::time::Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("equality exceeded five seconds"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +#[test] +fn native_equality_consumes_instruction_fuel_and_cannot_be_caught() { + let mut runtime = Runtime::with_limits( + (), + (), + ExecutionLimits { + instruction_limit: 512, + budget_sample_interval: 1, + ..Default::default() + }, + ) + .unwrap(); + let result = runtime.eval("use mod.std.array; let a=array.range(0,1000);let b=array.range(0,1000);try {a==b;} catch {true;}").unwrap(); + assert!(!result.completed()); + assert!( + result.diagnostics.iter().any(|d| d.contains("equality")), + "{:?}", + result.diagnostics + ); +} + +#[test] +fn equality_has_an_independent_native_work_ceiling() { + let mut runtime = Runtime::with_limits( + (), + (), + ExecutionLimits { + instruction_limit: 1_000_000, + soft_timeout: std::time::Duration::from_secs(5), + hard_timeout: std::time::Duration::from_secs(5), + ..Default::default() + }, + ) + .unwrap(); + let values = json!((0..40_000).collect::>()); + runtime.set_json_global("a", &values, 512_000, 64).unwrap(); + runtime.set_json_global("b", &values, 512_000, 64).unwrap(); + let result = runtime.eval("try {a==b;} catch {true;}").unwrap(); + assert!(!result.completed()); + assert!(result.diagnostics.iter().any(|d| d.contains("equality"))); +} + +#[test] +fn uncaught_errors_stop_and_caught_errors_recover() { + for source in [ + "use mod.std;std.assert(false);42", + "missing;42", + "nil();42", + "use mod.std;fn f(){std.assert(false);return 7;};f();42", + ] { + let mut runtime = Runtime::default(); + let result = runtime.eval(source).unwrap(); + assert!(!result.completed(), "{source}"); + assert!(!result.diagnostics.is_empty()); + let next = runtime.eval("42").unwrap(); + assert!(next.completed(), "{:?}", next.diagnostics); + } + assert_eq!( + evaluate("use mod.std;fn f(){std.assert(false);return 7;};try {f();} catch {42;}"), + json!(42) + ); +} + +#[test] +fn logical_precedence_matches_boolean_algebra_and_skips_effects() { + for a in [false, true] { + for b in [false, true] { + for c in [false, true] { + assert_eq!(evaluate(&format!("{a} || {b} && {c}")), json!(a || b && c)); + assert_eq!(evaluate(&format!("{a} && {b} || {c}")), json!(a && b || c)); + } + } + } + assert_eq!(evaluate("let n=0;true||false&&(n=1);n"), json!(0)); + assert_eq!( + evaluate("[true == 2 > 1, false != 2 > 1, 1 < 2 == true]"), + json!([true, true, true]) + ); +} + +#[test] +fn logical_not_does_not_depend_on_number_storage() { + assert_eq!( + evaluate("[!1,!(1+0),!0,!(0+0),!(-1),!true,!false,!nil]"), + json!([false, false, true, true, false, false, true, true]) + ); +} + +#[test] +fn conditional_block_values_survive_statement_separators() { + for source in [ + "let x=if true {1;} else {2;};x", + "let x=if false {1;} else {2;};x-1", + "let x=if true {\n1\n} else {\n2\n}\nx", + "let x=if true {\r\n1\r\n} else {\r\n2\r\n}\r\nx", + "let x=if true {1; /* tail */ ;} else {2;};x", + "let x=if true {if false {0;} else {1;};} else {2;};x", + "let x=if true {try {1;} catch {2;};} else {3;};x", + ] { + assert_eq!(evaluate(source), json!(1), "{source}"); + } + for source in [ + "if true {} else {1;}", + "if true {let a=1;} else {2;}", + "if false {1;} else {}", + ] { + assert_eq!(evaluate(source), Value::Null, "{source}"); + } +} + +#[test] +fn invalid_array_indices_never_read_or_mutate_element_zero() { + for index in ["-1", "0.9", "nil", "true", "\"x\"", "\"0\"", "0/0"] { + assert_eq!( + evaluate(&format!( + "try {{[10,20][{index}];}} catch {{\"bad index\";}}" + )), + json!("bad index") + ); + for operation in ["=99", "+=1", "-=1", "*=2", "/=2"] { + assert_eq!( + evaluate(&format!( + "let a=[10,20];try {{a[{index}]{operation};nil;}} catch {{nil;}};a" + )), + json!([10, 20]), + "{index}{operation}" + ); + } + } + assert_eq!( + evaluate("let a=[10,20];a[1.0]=30;[a[0],a[1+0]]"), + json!([10, 30]) + ); +} + +#[test] +fn numeric_string_conversion_is_uniform_and_invalid_text_is_nan() { + assert_eq!( + evaluate("[\"2\"-1,\"00000002\"-1,\"12345678\"-1]"), + json!([1, 1, 12345677]) + ); + for text in ["x", "abcdefgh"] { + assert_eq!(evaluate(&format!("let n=\"{text}\"-1;n!=n")), json!(true)); + } +} + +#[test] +fn json_integer_boundaries_reject_loss_instead_of_rounding() { + assert_eq!( + evaluate("use mod.std.json;json.parse(\"[9007199254740991,-9007199254740991]\")"), + json!([9007199254740991_i64, -9007199254740991_i64]) + ); + for value in [ + "9007199254740992", + "9007199254740993", + "-9007199254740993", + "18446744073709551615", + "1e20", + ] { + assert!(Runtime::default().eval(value).is_err(), "literal {value}"); + assert_eq!(evaluate(&format!("use mod.std.json;try {{json.parse(\"{{\\\"id\\\":{value}}}\");}} catch {{\"unsafe\";}}")), json!("unsafe")); + } + let mut runtime = Runtime::default(); + runtime + .set_json_global("input", &json!({"id":"9007199254740993"}), 65536, 64) + .unwrap(); + assert!(runtime + .set_json_global("input", &json!({"id":9007199254740993_u64}), 65536, 64) + .is_err()); + let unchanged = runtime.eval("input.id").unwrap(); + assert_eq!( + runtime + .script_value_as_json(unchanged.value, 65536, 64) + .unwrap(), + json!("9007199254740993") + ); + let result = runtime.eval("9007199254740991+2").unwrap(); + assert_eq!( + runtime.script_value_as_json(result.value, 65536, 64), + Err(RuntimeError::JsonData(RuntimeJsonError::UnsafeInteger)) + ); + assert!(splash_core::serialize_bounded_json(&json!(u64::MAX), 65536, 64).is_ok()); +} + +#[test] +fn direct_vm_logging_is_denied_even_in_compatibility_mode() { + let mut runtime = Runtime::default(); + assert!(runtime.eval("~\"private marker\"").is_err()); + let result = runtime + .eval_vm_compatibility("~\"private marker\"") + .unwrap(); + assert!(!result.completed()); + assert!(result + .diagnostics + .iter() + .any(|d| d.contains("logging is disabled"))); +} diff --git a/crates/splash-linux-seccomp/src/linux.rs b/crates/splash-linux-seccomp/src/linux.rs index 71786d3..3f3e88e 100644 --- a/crates/splash-linux-seccomp/src/linux.rs +++ b/crates/splash-linux-seccomp/src/linux.rs @@ -57,7 +57,7 @@ pub fn validate_filter(bytes: &[u8]) -> Result().0 { instructions.push(ptrace::sock_filter { code: u16::from_ne_bytes([instruction[0], instruction[1]]), jt: instruction[2], diff --git a/crates/splash-sandbox/src/bin/splash-landlock-runner.rs b/crates/splash-sandbox/src/bin/splash-landlock-runner.rs index 5ec0ce2..72435d2 100644 --- a/crates/splash-sandbox/src/bin/splash-landlock-runner.rs +++ b/crates/splash-sandbox/src/bin/splash-landlock-runner.rs @@ -145,7 +145,7 @@ mod linux { } let mut bytes = Vec::with_capacity(value.len() / 2); - for pair in value.as_bytes().chunks_exact(2) { + for pair in value.as_bytes().as_chunks::<2>().0 { let Some(high) = decode_lower_hex(pair[0]) else { return Err(RunnerError::InvalidStagedSeccompFilterEncoding); }; diff --git a/crates/splash-storage/src/lib.rs b/crates/splash-storage/src/lib.rs index eca6447..c31d66a 100644 --- a/crates/splash-storage/src/lib.rs +++ b/crates/splash-storage/src/lib.rs @@ -1390,7 +1390,7 @@ fn decode_tag(value: &str) -> Option<[u8; AUTHENTICATED_RECORD_TAG_BYTES]> { return None; } let mut decoded = [0; AUTHENTICATED_RECORD_TAG_BYTES]; - for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() { decoded[index] = (hex_value(pair[0])? << 4) | hex_value(pair[1])?; } Some(decoded) diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 187aaaa..c2605d2 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -1516,15 +1516,13 @@ impl<'a> Parser<'a> { // Refusing is what keeps the two apart; dropping // the operator quietly is the one outcome neither // reading supports. - if self - .tokens - .get(at) - .is_some_and(|t| t.kind == Kind::Punct + if self.tokens.get(at).is_some_and(|t| { + t.kind == Kind::Punct && matches!( t.text.as_str(), "+" | "-" | "*" | "/" | "%" - )) - { + ) + }) { let t = self.tokens[at].clone(); self.sink.at( &t, @@ -2226,9 +2224,7 @@ impl<'a> Parser<'a> { None => self.sink.push( 0, 0, - format!( - "expected \":\" after argument name `{name}`, found end of source" - ), + format!("expected \":\" after argument name `{name}`, found end of source"), ), } // Recover to the closing paren so one mistake yields one @@ -2500,14 +2496,14 @@ fn expr_paths(operand: &Operand, out: &mut Vec) { } fn data_path(data: &serde_json::Value, path: &str) -> Option { - let mut current = data.clone(); + let mut current = data; for segment in path.split('.') { current = match segment.parse::() { - Ok(i) => current.get(i)?.clone(), - Err(_) => current.get(segment)?.clone(), + Ok(i) => current.get(i)?, + Err(_) => current.get(segment)?, }; } - Some(current) + Some(current.clone()) } fn scope_value(scope: &ValueScope, operand: &Operand) -> Option { @@ -3097,7 +3093,10 @@ pub mod catalog { // quotes, so a card asks for the fields it wants to show and never // learns that a store exists. ("sys.watchlist", &["ticker", "fields"]), - ("sys.indicator", &["countries", "indicator", "years", "fields"]), + ( + "sys.indicator", + &["countries", "indicator", "years", "fields"], + ), ("sys.video", &["query", "count", "fields"]), ("sys.prefs", &["fields"]), ("sys.reading", &["fields"]), @@ -3181,7 +3180,10 @@ pub mod catalog { // for "Stanford" renders five rows all reading "Stanford", which is what // Photon actually returns and is useless to choose between. The backend has // answered this field all along; the catalog simply never admitted it. - ("sys.search", &["id", "name", "label", "query", "lat", "lon", "distance"]), + ( + "sys.search", + &["id", "name", "label", "query", "lat", "lon", "distance"], + ), ("sys.route", &["duration", "distance", "steps"]), ("sys.step", &["instruction", "remaining", "progress", "eta"]), ( @@ -3230,20 +3232,27 @@ pub mod catalog { // chart's to fetch; these are the numbers a card puts beside it. ( "sys.indicator", - &["name", "latest", "first", "change", "min", "max", "year", "title"], + &[ + "name", "latest", "first", "change", "min", "max", "year", "title", + ], ), // A YouTube search result. `embed` is a player url ready to hand to // sys.link — a card cannot build one, because L0 has no string // concatenation, and that is the point. ( "sys.video", - &["id", "title", "channel", "length", "views", "age", "thumb", "embed"], + &[ + "id", "title", "channel", "length", "views", "age", "thumb", "embed", + ], ), ("sys.prefs", &["units", "range", "home", "work", "mode"]), // The reading list: SAVED story ids, joined to the story each id names. // The id is the identity Algolia serves forever, so a bookmarked story // outlives the front page it was found on. - ("sys.reading", &["id", "title", "author", "points", "comments", "url"]), + ( + "sys.reading", + &["id", "title", "author", "points", "comments", "url"], + ), // Followed TOPICS: the store holds a topic word ("ai", "nba"); the // top story beside it is searched fresh on every read, so a followed // topic never pins the story that was hot when it was followed. @@ -5146,6 +5155,8 @@ pub struct RealizeLimits { pub max_nodes: usize, pub max_depth: usize, pub max_collection: usize, + /// Aggregate element visits and loop iterations, including false guards. + pub max_work: usize, } impl Default for RealizeLimits { @@ -5154,6 +5165,7 @@ impl Default for RealizeLimits { max_nodes: 8_192, max_depth: 64, max_collection: 512, + max_work: 65_536, } } } @@ -5251,6 +5263,7 @@ fn realize_inner( let mut ctx = Realizer { depth: 0, + work: 0, card: &card, limits, nodes: 0, @@ -5396,29 +5409,30 @@ impl ValueScope<'_> { } let mut current = match self.frames.iter().rev().find(|(n, _, _)| n == root) { - Some((_, v, _)) => v.clone(), - None => self.data.get(root)?.clone(), + Some((_, v, _)) => v, + None => self.data.get(root)?, }; for segment in segments { // `[inner]` — resolve the inner path, then index by its value. if let Some(inner) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { let key = self.lookup(inner)?; current = match &key { - serde_json::Value::String(k) => current.get(k.as_str())?.clone(), - v => current.get(v.as_u64()? as usize)?.clone(), + serde_json::Value::String(k) => current.get(k.as_str())?, + v => current.get(v.as_u64()? as usize)?, }; continue; } current = match segment.parse::() { - Ok(index) => current.get(index)?.clone(), - Err(_) => current.get(segment)?.clone(), + Ok(index) => current.get(index)?, + Err(_) => current.get(segment)?, }; } - Some(current) + Some(current.clone()) } } struct Realizer<'a> { + work: usize, card: &'a Card, limits: RealizeLimits, nodes: usize, @@ -5439,6 +5453,15 @@ struct Realizer<'a> { } impl Realizer<'_> { + fn charge_work(&mut self) -> bool { + if self.work >= self.limits.max_work || self.nodes >= self.limits.max_nodes { + self.truncated = true; + return false; + } + self.work += 1; + true + } + fn element( &mut self, element: &Element, @@ -5446,7 +5469,7 @@ impl Realizer<'_> { key: &str, out: &mut Vec, ) { - if self.nodes >= self.limits.max_nodes || self.depth >= self.limits.max_depth { + if !self.charge_work() || self.depth >= self.limits.max_depth { self.truncated = true; return; } @@ -5712,6 +5735,9 @@ impl Realizer<'_> { let mut seen_keys: Vec = Vec::new(); for (index, item) in items.iter().enumerate() { + if !self.charge_work() { + break; + } if index >= self.limits.max_collection { self.truncated = true; break; @@ -5724,11 +5750,11 @@ impl Realizer<'_> { .and_then(|k| { let mut segments = k.split('.'); segments.next()?; - let mut current = item.clone(); + let mut current = item; for segment in segments { - current = current.get(segment)?.clone(); + current = current.get(segment)?; } - Some(json_to_key(¤t)) + Some(json_to_key(current)) }) .unwrap_or_else(|| index.to_string()); @@ -6392,7 +6418,8 @@ pub mod makepad { pub(super) fn map_vias(node: &UiNode) -> Option { let mut pairs: Vec = Vec::new(); for slot in ["via", "via2"] { - let (Some(lat), Some(lon)) = (map_coord(node, slot, "lat"), map_coord(node, slot, "lon")) + let (Some(lat), Some(lon)) = + (map_coord(node, slot, "lat"), map_coord(node, slot, "lon")) else { continue; }; @@ -7014,7 +7041,7 @@ pub mod makepad { index.parse::().ok()?; let key = match field { f @ ("id" | "title" | "channel" | "length" | "views" | "age" | "thumb" - | "embed") => f, + | "embed") => f, _ => return None, }; let query = arg("query")?; @@ -7034,7 +7061,7 @@ pub mod makepad { index.parse::().ok()?; let key = match field { f @ ("name" | "latest" | "first" | "change" | "min" | "max" | "year" - | "title") => f, + | "title") => f, _ => return None, }; let countries = arg("countries")?; @@ -8563,7 +8590,9 @@ fn dispatch_writes( // carried it falls through instead of consuming the swipe. return (Vec::new(), Vec::new()); } - let here = current.as_str().and_then(|c| rows.iter().position(|r| r == c)); + let here = current + .as_str() + .and_then(|c| rows.iter().position(|r| r == c)); let at = match (here, forward) { (Some(i), true) => (i + 1) % rows.len(), (Some(i), false) => (i + rows.len() - 1) % rows.len(), @@ -9527,9 +9556,7 @@ pub mod kit { /// is the theme's answer, and deciding here would put styling in the /// lowering. fn width_wrap(node: &UiNode) -> Option<(&'static str, String)> { - let Some(t) = token_arg(node, "width") else { - return None; - }; + let t = token_arg(node, "width")?; match t { "fill" => Some(("l0_wide(", ")".into())), // NOT a no-op, though it is the default for a text role: `l0_row` @@ -9620,8 +9647,7 @@ pub mod kit { // // A text role that ASKED to fill is not intrinsic, so it keeps the // filling wrapper. - let asked_to_fill = - token_arg(node, "width") == Some("fill"); + let asked_to_fill = token_arg(node, "width") == Some("fill"); let intrinsic = // A `.danger` chip is the exception: it SPANS the sheet, so a // fit-width hit target is the one thing that can hide it. It emitted @@ -9816,7 +9842,7 @@ pub mod kit { // zoom pill has, so a chip the card put on the map reads over pale // tiles instead of vanishing into them — and a card that docks // nothing there must not get an empty box for it. - let has_side = node.children.iter().any(|c| docked(&c, "right")); + let has_side = node.children.iter().any(|c| docked(c, "right")); let _ = write!(out, ", {}", u8::from(has_side)); out.push(')'); } @@ -9906,13 +9932,15 @@ pub mod kit { // Both are red — what destructive looks like stays the // theme's call. if matches!(arg(node, "width"), Some(NodeValue::Token(t)) if t == "fit") { - let _ = - write!(out, "l0_chip_danger_row({})", makepad::expr_of(node, "text")); + let _ = write!( + out, + "l0_chip_danger_row({})", + makepad::expr_of(node, "text") + ); } else { let _ = write!(out, "l0_chip_danger({})", makepad::expr_of(node, "text")); } - } else if matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "primary") - { + } else if matches!(arg(node, "tone"), Some(NodeValue::Token(t)) if t == "primary") { // The same reasoning as `.danger`: a primary action is bigger // type AND a rounder target AND a lit fill, and threading three // presentation decisions through a flag reads worse than naming diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index cbb2a96..144b3b0 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -3497,11 +3497,10 @@ fn syntax_the_model_gets_wrong_is_refused_with_a_teaching_diagnostic() { let report = check_ui_l0_named("malformed", source); assert!(!report.valid, "{what} must be rejected"); assert!( - report - .diagnostics - .iter() - .any(|d| d.message.contains("cannot appear inside an argument list") - && d.message.contains("wrap elements")), + report.diagnostics.iter().any(|d| d + .message + .contains("cannot appear inside an argument list") + && d.message.contains("wrap elements")), "{what} should produce the teaching diagnostic: {:#?}", report.diagnostics ); @@ -3522,11 +3521,10 @@ fn syntax_the_model_gets_wrong_is_refused_with_a_teaching_diagnostic() { let report = check_ui_l0_named("malformed", "view root TextRow(text, value)"); assert!(!report.valid, "a bare-value argument must be rejected"); assert!( - report - .diagnostics - .iter() - .any(|d| d.message.contains("expected \":\" after argument name `text`") - && d.message.contains("`name: value`")), + report.diagnostics.iter().any(|d| d + .message + .contains("expected \":\" after argument name `text`") + && d.message.contains("`name: value`")), "comma-for-colon should produce the teaching diagnostic: {:#?}", report.diagnostics ); @@ -4065,6 +4063,7 @@ fn realization_depth_is_bounded_independently_of_node_count() { max_nodes: 100_000, // far above what this tree needs max_depth: 64, max_collection: 512, + ..RealizeLimits::default() }; let report = realize(&source, &serde_json::json!({}), limits); assert!( @@ -4079,6 +4078,36 @@ fn realization_depth_is_bounded_independently_of_node_count() { ); } +#[test] +fn empty_nested_expansion_consumes_aggregate_work() { + let source = "source movers sys.movers()\nstate show {shape: bool, initial: false}\nview root Panel {for a in movers key a.id {for b in movers key b.id {for c in movers key c.id {for d in movers key d.id {when show {Rule()}}}}}}"; + assert!(check_ui_l0_named("bounded", source).valid); + let rows: Vec<_> = (0..128).map(|id| serde_json::json!({"id":id})).collect(); + let report = realize( + source, + &serde_json::json!({"movers":rows}), + RealizeLimits { + max_work: 100, + ..Default::default() + }, + ); + assert!( + report.truncated, + "empty expansion must exhaust work even without emitted nodes" + ); + assert_eq!(report.nodes, 1); + let small = realize( + source, + &serde_json::json!({"movers":[{"id":0}]}), + RealizeLimits { + max_work: 100, + ..Default::default() + }, + ); + assert!(!small.truncated); + assert_eq!(small.nodes, 1); +} + /// §1.1 as a check rather than a paragraph: a card must have no way to state a /// colour, a size in pixels, or a font. The principle survives the revert of the /// lowering that was meant to implement it — a card naming roles rather than @@ -5172,7 +5201,11 @@ fn previewing_stores_nothing_and_adding_stores_once() { // Type: the query moves, and the search source parameterised by it goes // stale — that is what makes results-as-you-type refetch. let out = dispatch(&mut store, "typing", "berk"); - assert_eq!(out.changed, vec!["query"], "typing writes the query: {out:?}"); + assert_eq!( + out.changed, + vec!["query"], + "typing writes the query: {out:?}" + ); assert!( out.stale.contains(&"found".to_string()), "the search must refetch: {:?}", @@ -5213,7 +5246,12 @@ fn previewing_stores_nothing_and_adding_stores_once() { assert_eq!(out.writes.len(), 1, "exactly one durable write: {out:?}"); let w = &out.writes[0]; assert_eq!( - (w.source.as_str(), w.helper.as_str(), w.op.as_str(), w.value.as_str()), + ( + w.source.as_str(), + w.helper.as_str(), + w.op.as_str(), + w.value.as_str() + ), ("cities", "sys.cities", "append", city), "the host is told the bound name, the capability, the op and the value" ); @@ -7066,7 +7104,10 @@ fn the_nav_card_routes_between_two_editable_places() { } // And opening one turns THAT row into a field, so a keyboard-capable renderer // still gets one — and picking a result works without one either way. - for (event, target) in [("edit_origin", "choose_origin"), ("edit_dest", "choose_dest")] { + for (event, target) in [ + ("edit_origin", "choose_origin"), + ("edit_dest", "choose_dest"), + ] { let mut store = splash_ui_l0::InstanceStore::default(); splash_ui_l0::dispatch_reporting(NAV, &mut store, "root", event, None, &data); let open = splash_ui_l0::kit::lower( @@ -7814,11 +7855,17 @@ fn a_docked_panel_does_not_draw_a_second_panel_inside_the_dock() { .expect("the sheet is the rounded box at the bottom"); // The line after the sheet opens is its content, not another box. assert!( - !mp.lines().nth(sheet + 1).unwrap_or("").contains("RoundedView{"), + !mp.lines() + .nth(sheet + 1) + .unwrap_or("") + .contains("RoundedView{"), "a second box inside the sheet:\n{mp}" ); assert!( - mp.lines().nth(sheet).unwrap_or("").contains("align: Align{x: 0.5}"), + mp.lines() + .nth(sheet) + .unwrap_or("") + .contains("align: Align{x: 0.5}"), "the sheet centres what is in it:\n{mp}" ); } @@ -7863,7 +7910,11 @@ fn a_cycle_advances_from_the_state_the_card_is_showing() { .expect("realizes"), ); // The drive branch is the one with the tap on it. - let now = if dsl.contains("l0_chip") { "drive" } else { "plan" }; + let now = if dsl.contains("l0_chip") { + "drive" + } else { + "plan" + }; assert_eq!( now, after_one_tap, "seeded on {seed:?}, one tap must reach {after_one_tap:?}, not {now:?}:\n{dsl}" @@ -8120,7 +8171,10 @@ fn a_card_names_a_map_control_and_never_calls_the_widget() { ); // Asked for: the theme is told, and told which. let zoom = lower(&card(", controls: .zoom")); - assert!(zoom.contains("\"zoom\""), "a zoom pill was asked for:\n{zoom}"); + assert!( + zoom.contains("\"zoom\""), + "a zoom pill was asked for:\n{zoom}" + ); let all = lower(&card(", controls: .all")); assert!(all.contains("\"all\""), "recenter too:\n{all}"); @@ -8324,11 +8378,17 @@ fn a_chase_map_carries_no_pins() { }; // The overview pins both ends — that is the requirement. let plan = lower("plan"); - assert!(plan.contains("\",0\"") && plan.contains("\",2\""), "plan pins:\n{plan}"); + assert!( + plan.contains("\",0\"") && plan.contains("\",2\""), + "plan pins:\n{plan}" + ); // The chase map passes an EMPTY marker string, which the widget reads as // "no pins" — not a missing argument, which would be an arity error. let drive = lower("drive"); - assert!(drive.contains("follow3d"), "drive is the chase camera:\n{drive}"); + assert!( + drive.contains("follow3d"), + "drive is the chase camera:\n{drive}" + ); assert!( !drive.contains("\",0\"") && !drive.contains("\",2\""), "a chase map must carry no pins:\n{drive}" @@ -8548,7 +8608,8 @@ fn a_field_can_answer_a_keystroke_and_a_commit_differently() { let joined = fields.join("\n"); // The first field carries BOTH, and they are different events. assert!( - joined.contains("\\\"e\\\":\\\"typing\\\"") && joined.contains("\\\"e\\\":\\\"set_dest\\\""), + joined.contains("\\\"e\\\":\\\"typing\\\"") + && joined.contains("\\\"e\\\":\\\"set_dest\\\""), "a field may answer both moments:\n{joined}" ); // The second asked for no keystroke event and must not have acquired one: a @@ -8611,7 +8672,9 @@ fn a_map_routes_through_both_of_its_stops() { // The route call ALONE. A fixed-width window swept in the pin string, which // legitimately carries three separators — origin, two stops, destination — and // made a correct lowering look like four waypoints. - let start = dsl.find("sys.navroute(").expect("a map with a trip lowers a route"); + let start = dsl + .find("sys.navroute(") + .expect("a map with a trip lowers a route"); let mut depth = 0usize; let mut end = start; for (i, c) in dsl[start..].char_indices() { @@ -8701,7 +8764,11 @@ fn an_initial_taken_from_a_source_is_reported_as_captured() { // And once the store HOLDS it, the capture does not repeat — that is what makes // it a capture rather than a re-read. let mut store = splash_ui_l0::InstanceStore::default(); - store.set_cell(splash_ui_l0::CARD_STATE_KEY, "from_lat", serde_json::json!(1.5)); + store.set_cell( + splash_ui_l0::CARD_STATE_KEY, + "from_lat", + serde_json::json!(1.5), + ); let held = splash_ui_l0::realize_with_state(CARD, &data, &store, RealizeLimits::default()); assert!( held.captured.is_empty(), diff --git a/crates/splash-workflow/src/lib.rs b/crates/splash-workflow/src/lib.rs index 30ee1ce..0f584b3 100644 --- a/crates/splash-workflow/src/lib.rs +++ b/crates/splash-workflow/src/lib.rs @@ -10165,6 +10165,30 @@ mod tests { assert_eq!(checkpoint.completed_step_ids(), ["first"]); } + #[test] + fn an_uncaught_guard_error_prevents_later_granted_steps() { + let mut runtime = CapabilityRuntime::default(); + runtime + .register_tool(ToolPolicy::new("text.echo"), |_| { + panic!("the effect following a failed guard must never execute") + }) + .unwrap(); + let mut engine = WorkflowEngine::new(runtime); + let plan = engine + .plan(vec![ + WorkflowStep::new("guard", "use mod.std;std.assert(false);42"), + WorkflowStep::new("effect", "use mod.tool;tool.call(\"text.echo\",\"secret\")"), + ]) + .unwrap(); + let approval = engine.approve(&plan).unwrap(); + assert!( + matches!(engine.execute(&plan, approval), Err(WorkflowError::StepFailed { + step_id, completed_steps: 0, .. + }) if step_id == "guard") + ); + assert!(engine.runtime().audit().is_empty()); + } + #[test] fn rejects_noncanonical_source_with_step_context_before_a_workflow_tool_runs() { let mut runtime = CapabilityRuntime::default(); diff --git a/docs/grammar.md b/docs/grammar.md index 4b42539..34fce7b 100644 --- a/docs/grammar.md +++ b/docs/grammar.md @@ -114,7 +114,7 @@ equality = comparison, { ( "==" | "!=" ), comparison } ; comparison = additive, { ( "<" | "<=" | ">" | ">=" ), additive } ; additive = multiplicative, { ( "+" | "-" ), multiplicative } ; multiplicative = unary, { ( "*" | "/" | "%" ), unary } ; -unary = [ "!" | "-" | "+" | "~" ], postfix ; +unary = [ "!" | "-" | "+" ], postfix ; postfix = primary, { call | field-access | index-access | ".await()" } ; call = "(", [ expression, { ",", expression } ], ")" ; @@ -147,6 +147,37 @@ a record literal as the whole branch, parenthesize it, for example a value-producing expression. `for`, `loop`, and `while` do not produce a value; place an explicit `nil` or another result expression after them. +An `if`/`elif`/`else` block produces its final expression value, including +when that expression ends in a newline or semicolon. An empty branch or a +branch ending in a declaration or loop produces `nil`. Function blocks still +use explicit `return`. Statement separators do not discard conditional results. + +`!` always produces a Boolean by negating truth conversion. Comparisons bind +more tightly than equality, `&&` more tightly than `||`; logical operators +short-circuit their entire right-hand expression. `~` is an inherited logging +operator, not a canonical unary operator, and the standalone runtime denies +its output even through its trusted compatibility entry point. + +Array indexing accepts only finite, non-negative integral numbers. A negative, +fractional, string, Boolean, or `nil` index raises a catchable error before any +read or write. Numeric string conversion is independent of inline versus heap +storage: valid numeric text converts to a number; invalid text produces NaN. +NaN is unequal to itself and cannot cross the JSON boundary. + +Canonical numeric literals must be finite. Integer literals and VM JSON input +and output are restricted to -9007199254740991 through 9007199254740991. +Larger integer identifiers must be strings. An unsafe literal is a syntax +error; unsafe JSON conversion is a catchable error. Arithmetic uses binary64 +and may produce values outside that range, but integer results outside it +cannot be serialized as JSON. Fractional numbers retain binary64 rounding. + +Structural equality visits each distinct container pair once, including +cycles and shared subgraphs. Comparison consumes VM instruction fuel and +checks the hard deadline, with an independent ceiling of 65,536 work units +per comparison. Exhaustion terminates evaluation and cannot be caught. +An uncaught ordinary script error also terminates evaluation before any later +instruction or host effect; `try/catch` is the explicit recovery mechanism. + The grammar makes the portable operator precedence explicit rather than making every inherited VM operator part of the language contract. Use parentheses when a generated expression mixes control expressions and operators, including diff --git a/docs/language-profile.md b/docs/language-profile.md index c1d57b1..bb9a6dc 100644 --- a/docs/language-profile.md +++ b/docs/language-profile.md @@ -9,16 +9,12 @@ regression. Parser features outside the grammar and fixtures remain inherited compatibility features until separately specified. **Scope.** This is the *workflow* profile: it is shaped for bounded dataflow and -tool calls, and it deliberately rejects UI constructs. A second profile for -generated UI is proposed but unimplemented — see -[UI Profile Level 0](ui-profile-l0.md). The two are siblings rather than nested: -a card is not a workflow with widgets bolted on, and neither profile is a subset -of the other. Where they agree is the property that matters — a producer must be -unable to write anything that reaches a capability it was not granted. - -Until L0 exists, `splash profile` reports only this profile, and a host with -generated UI source has no canonical entry point (see -[Generated UI source](grammar.md#generated-ui-source)). +tool calls, and it deliberately rejects UI constructs. Generated UI uses the +implemented sibling [UI Profile](ui-profile-l0.md), including its separately +specified L1 expressions. Hosts check cards through +`splash_core::ui_l0::check_ui_l0` and realize them through the UI API. +`splash profile` describes this workflow profile; see +[Generated UI source](grammar.md#generated-ui-source) for the card entry point. ## Source contract @@ -628,12 +624,16 @@ used at the Rust data boundary. The frozen `mod.std.json` module exposes that same boundary through `json.parse(document)` and `json.stringify(value)`. `string.to_bytes()` creates the bounded UTF-8 byte-array form accepted by `parse_json()` and `json.parse()`. Those parsing APIs accept only strict JSON -from a string or UTF-8 byte array, then reconstruct a Splash value from bounded -canonical JSON; they do not accept Makepad's nonstandard JSON extensions. +from a string or UTF-8 byte array, then copy validated decoded values into the +VM; they do not use the compatibility script tokenizer as a JSON reader. `to_json()` and `json.stringify()` return a JSON string only for JSON-safe values. Cycles, unsupported values, non-finite numbers, and duplicate object keys are rejected on serialization; malformed or non-UTF-8 input is rejected on parsing. Either +direction rejects integer values outside +/-9007199254740991 before they can +be silently rounded; represent larger identifiers as strings. This also applies +to host-injected VM globals and canonical integer literals. Host-only JSON +used for schemas and telemetry retains its full integer range. Either direction rejects excessive container depth and excessive input or output as ordinary native errors that canonical `try/catch` can recover from. With default execution limits, direct conversion is capped at 64 KiB and 64 diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index de526fb..3d64594 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -8,6 +8,14 @@ the workflow language. `check_syntax` rejects UI source and `eval_vm_compatibili receive generated source, so neither canonical entry point admits a generated card. This profile is the third path, and `check_ui_l0` is its entry point. +Realization has four independent host-selected bounds in `RealizeLimits`: +8,192 emitted nodes, depth 64, 512 items per collection, and 65,536 aggregate +work units by default. Visiting an element and entering a loop iteration each +consume work, even when a false guard or empty component emits no nodes. +Exhaustion stops expansion and sets `RealizeReport::truncated`; hosts must +treat that report as partial. These bounds apply to a single realization, +including nested loops and component expansion. + --- ## 1. What Level 0 is for diff --git a/vendor/makepad/PATCHES.md b/vendor/makepad/PATCHES.md index 6b912e7..2039bff 100644 --- a/vendor/makepad/PATCHES.md +++ b/vendor/makepad/PATCHES.md @@ -1,5 +1,42 @@ # Local Vendor Patches +## `platform/script`: language review fixes (#9–#18) + +The September 2026 review fixes implement the contracts published in +`docs/grammar.md`. `equality.rs` replaces recursive structural comparison with +a cycle-aware worklist, meters edges, pairs, typed-array elements and string +chunks, and bounds temporary storage. Equality opcodes charge instruction fuel, +check the hard deadline, and bail on native-work exhaustion. NaN remains unequal +to itself. Raw host comparison is iterative but does not consume VM fuel. + +Uncaught VM errors now drain diagnostics and bail before another instruction +can run. Active `try` handlers still recover normally. A host-controlled +`allow_debug_output` flag preserves raw Makepad debugging by default; Splash's +standalone runtime disables it, including compatibility evaluation. LOG is +rejected and incidental VM error prints are removed. + +The parser retains the logical operator in streaming checkpoint state, closes +short-circuit jumps according to precedence, and gives comparisons higher +precedence than equality. `!` consistently negates truth conversion. Numeric +string conversion handles inline and heap strings alike and produces NaN for +invalid text. Array opcode reads and writes validate index type, sign, +integrality and representable range before touching storage. Existing limits +still govern sparse growth. Updating an untracked object field now preserves +its original insertion order, matching the tracked-object path; the stricter +uncaught-error behavior exposed this existing contract violation. + +Conditional block-tail lowering, strict JSON materialization, numeric boundary +checks, and aggregate UI realization work limits live in Splash-owned crates. +Regressions cover cycles and shared DAGs in a timed child process, fuel, +precedence and effects, streaming appends, error recovery, invalid writes, +numeric storage variants, JSON boundaries, and empty nested UI expansion. + +**Upstream sync:** reapply or retire the VM changes as a unit when importing +Makepad. Preserve the published Splash contracts and run both Splash's regression +suite and the explicit `makepad-script` tests, especially streaming precedence +and `try/catch` recovery. Keep the standalone logging opt-out host-controlled; +do not add capabilities to compensate for removed output paths. + ## `regex/src/utf8.rs`: Clippy-compatible iterator entry The upstream iterator wrapped an inner non-terminating loop in `while let`. diff --git a/vendor/makepad/platform/script/src/equality.rs b/vendor/makepad/platform/script/src/equality.rs new file mode 100644 index 0000000..6c4dab9 --- /dev/null +++ b/vendor/makepad/platform/script/src/equality.rs @@ -0,0 +1,158 @@ +//! Iterative structural equality. Container pairs are visited once, including +//! cyclic graphs and shared DAGs. Language opcodes supply fuel/deadline checks. + +use crate::makepad_live_id::LiveId; +use crate::{array::ScriptArrayStorage, heap::ScriptHeap, value::ScriptValue}; +use std::collections::HashSet; + +/// Independent ceiling on native comparison work and its temporary storage. +pub const MAX_EQUALITY_WORK: usize = 65_536; + +impl ScriptHeap { + /// Trusted host comparison without VM fuel. Still iterative and cycle-safe. + pub fn deep_eq(&self, a: ScriptValue, b: ScriptValue) -> bool { + self.deep_eq_bounded(a, b, usize::MAX, || true) + .unwrap_or(false) + } + + /// `None` is resource exhaustion, never a comparison result. Every queued + /// edge and processed pair consumes work before allocation or traversal. + pub fn deep_eq_bounded( + &self, + a: ScriptValue, + b: ScriptValue, + maximum_work: usize, + mut charge_host: impl FnMut() -> bool, + ) -> Option { + let mut remaining = maximum_work; + let mut charge = || { + remaining = remaining.checked_sub(1)?; + charge_host().then_some(()) + }; + let mut pending = vec![(a, b)]; + let mut visited = HashSet::new(); + while let Some((a, b)) = pending.pop() { + charge()?; + if a.is_nan() || b.is_nan() { + return Some(false); + } + if a == b { + continue; + } + if let Some(a) = a.as_number() { + if b.as_number() != Some(a) { + return Some(false); + } + continue; + } + if a.is_string_like() || b.is_string_like() { + let equal = self + .string_with(a, |heap, a| { + heap.string_with(b, |_, b| { + if a.len() != b.len() { + return Some(false); + } + for (a, b) in a.as_bytes().chunks(256).zip(b.as_bytes().chunks(256)) { + charge()?; + if a != b { + return Some(false); + } + } + Some(true) + }) + .unwrap_or(Some(false)) + }) + .unwrap_or(Some(false))?; + if !equal { + return Some(false); + } + continue; + } + if let (Some(pa), Some(pb)) = (a.as_object(), b.as_object()) { + if !visited.insert((a, b)) { + continue; + } + let oa = &self.objects[pa]; + let ob = &self.objects[pb]; + if oa.vec.len() != ob.vec.len() || oa.map_len() != ob.map_len() { + return Some(false); + } + charge()?; + pending.push((oa.proto, ob.proto)); + for (a, b) in oa.vec.iter().zip(&ob.vec) { + charge()?; + pending.push((a.key, b.key)); + charge()?; + pending.push((a.value, b.value)); + } + if let Some(result) = oa.map_iter_ret(|key, value| { + if charge().is_none() { + return Some(None); + } + let other = ob.map_get(&key).or_else(|| { + // JSON string keys and source identifier keys name the + // same fields without changing either object. + if let Some(id) = key.as_id().filter(|_| ob.tag.is_string_keys()) { + id.as_string(|s| { + s.and_then(|s| self.check_intern_string(s)) + .and_then(|key| ob.map_get(&key)) + }) + } else if key.is_string_like() && !ob.tag.is_string_keys() { + self.string_with(key, |_, s| ob.map_get(&LiveId::from_str(s).into())) + .flatten() + } else { + None + } + }); + let Some(other) = other else { + return Some(Some(false)); + }; + pending.push((value, other)); + None + }) { + return result; + } + continue; + } + if let (Some(pa), Some(pb)) = (a.as_array(), b.as_array()) { + if !visited.insert((a, b)) { + continue; + } + let a = &self.arrays[pa].storage; + let b = &self.arrays[pb].storage; + macro_rules! scalar_array { + ($a:expr, $b:expr) => {{ + if $a.len() != $b.len() { + return Some(false); + } + for (a, b) in $a.iter().zip($b) { + charge()?; + if a != b { + return Some(false); + } + } + }}; + } + match (a, b) { + (ScriptArrayStorage::ScriptValue(a), ScriptArrayStorage::ScriptValue(b)) => { + if a.len() != b.len() { + return Some(false); + } + for (&a, &b) in a.iter().zip(b) { + charge()?; + pending.push((a, b)); + } + } + (ScriptArrayStorage::F32(a), ScriptArrayStorage::F32(b)) => scalar_array!(a, b), + (ScriptArrayStorage::U32(a), ScriptArrayStorage::U32(b)) => scalar_array!(a, b), + (ScriptArrayStorage::U16(a), ScriptArrayStorage::U16(b)) => scalar_array!(a, b), + (ScriptArrayStorage::U8(a), ScriptArrayStorage::U8(b)) => scalar_array!(a, b), + _ => return Some(false), + } + continue; + } + return Some(false); + } + Some(true) + } +} diff --git a/vendor/makepad/platform/script/src/heap.rs b/vendor/makepad/platform/script/src/heap.rs index ac9c9a2..afd760a 100644 --- a/vendor/makepad/platform/script/src/heap.rs +++ b/vendor/makepad/platform/script/src/heap.rs @@ -510,13 +510,12 @@ impl ScriptHeap { if let Some(v) = v.as_u40() { return v as _; } - if let Some(v) = v.as_string() { - let str = self.string(v); - if let Ok(v) = str.parse::() { - return v; - } else { - return 0.0; - } + if let Some(number) = self.string_with(v, |_, text| text.parse::()) { + return number.unwrap_or_else(|_| { + ScriptValue::from_f64_traced_nan(f64::NAN, ip) + .as_f64() + .unwrap() + }); } if let Some(v) = v.as_bool() { return if v { 1.0 } else { 0.0 }; @@ -592,134 +591,6 @@ impl ScriptHeap { // Debug and utility - pub fn deep_eq(&self, a: ScriptValue, b: ScriptValue) -> bool { - if a == b { - return true; - } - if let Some(a) = a.as_number() { - if let Some(b) = b.as_number() { - return a == b; - } - return false; - } - if a.is_object() { - let mut aw = a; - let mut bw = b; - loop { - if let Some(pa) = aw.as_object() { - if let Some(pb) = bw.as_object() { - let oa = &self.objects[pa]; - let ob = &self.objects[pb]; - if oa.vec.len() != ob.vec.len() { - return false; - } - for (a, b) in oa.vec.iter().zip(ob.vec.iter()) { - if !self.deep_eq(a.key, b.key) || !self.deep_eq(a.value, b.value) { - return false; - } - } - if oa.map_len() != ob.map_len() { - return false; - } - if let Some(ret) = oa.map_iter_ret(|k, v1| { - if let Some(v2) = ob.map_get(&k) { - if !self.deep_eq(v1, v2) { - return Some(false); - } - return None; - } - // lets do the string keys shenanigans to make json ok - else if k.is_id() && ob.tag.is_string_keys() { - let id = k.as_id().unwrap(); - if let Some(v2) = id.as_string(|s| { - if let Some(s) = s { - if let Some(idx) = self.check_intern_string(s) { - ob.map_get(&idx) - } else { - None - } - } else { - None - } - }) { - if !self.deep_eq(v1, v2) { - return Some(false); - } - return None; - } - } else if k.is_string_like() && !ob.tag.is_string_keys() { - let id = if let Some(s) = k.as_string() { - if let Some(s) = &self.strings[s] { - LiveId::from_str(&s.string.0) - } else { - LiveId(0) - } - } else { - k.as_inline_string(|s| LiveId::from_str(s)).unwrap() - }; - if let Some(v2) = ob.map_get(&id.into()) { - if !self.deep_eq(v1, v2) { - return Some(false); - } - return None; - } - } - Some(false) - }) { - return ret; - } - aw = oa.proto; - bw = ob.proto; - if aw == bw { - return true; - } - } else { - return false; - } - } else { - return false; - } - } - } else if let Some(arr1) = a.as_array() { - if let Some(arr2) = b.as_array() { - match &self.arrays[arr1].storage { - ScriptArrayStorage::ScriptValue(arr1) => match &self.arrays[arr2].storage { - ScriptArrayStorage::ScriptValue(arr2) => { - if arr1.len() != arr2.len() { - return false; - } - for (a, b) in arr1.iter().zip(arr2.iter()) { - if !self.deep_eq(*a, *b) { - return false; - } - } - return true; - } - _ => return false, - }, - ScriptArrayStorage::F32(arr1) => match &self.arrays[arr2].storage { - ScriptArrayStorage::F32(arr2) => return arr1 == arr2, - _ => return false, - }, - ScriptArrayStorage::U32(arr1) => match &self.arrays[arr2].storage { - ScriptArrayStorage::U32(arr2) => return arr1 == arr2, - _ => return false, - }, - ScriptArrayStorage::U16(arr1) => match &self.arrays[arr2].storage { - ScriptArrayStorage::U16(arr2) => return arr1 == arr2, - _ => return false, - }, - ScriptArrayStorage::U8(arr1) => match &self.arrays[arr2].storage { - ScriptArrayStorage::U8(arr2) => return arr1 == arr2, - _ => return false, - }, - } - } - return false; - } - false - } - pub fn println(&self, value: ScriptValue) { let mut out = String::new(); let mut recur = Vec::new(); diff --git a/vendor/makepad/platform/script/src/lib.rs b/vendor/makepad/platform/script/src/lib.rs index 75c78a8..34ac6e7 100644 --- a/vendor/makepad/platform/script/src/lib.rs +++ b/vendor/makepad/platform/script/src/lib.rs @@ -14,6 +14,7 @@ macro_rules! script_eval { } pub mod colorhex; +pub mod equality; pub mod gen_index; pub mod heap; pub mod mod_gc; diff --git a/vendor/makepad/platform/script/src/object.rs b/vendor/makepad/platform/script/src/object.rs index 2598657..2be30a4 100644 --- a/vendor/makepad/platform/script/src/object.rs +++ b/vendor/makepad/platform/script/src/object.rs @@ -650,7 +650,11 @@ impl ScriptObjectData { self.map .capacity() .saturating_mul(Self::map_entry_bytes()) - .saturating_add(self.vec.capacity().saturating_mul(size_of::())) + .saturating_add( + self.vec + .capacity() + .saturating_mul(size_of::()), + ) } /// Conservative retained capacity needed before inserting a new map key. @@ -675,7 +679,10 @@ impl ScriptObjectData { .max(Self::MAP_GROWTH_CAPACITY_FLOOR) }; let map_bytes = map_capacity.checked_mul(Self::map_entry_bytes())?; - let vec_bytes = self.vec.capacity().checked_mul(size_of::())?; + let vec_bytes = self + .vec + .capacity() + .checked_mul(size_of::())?; map_bytes.checked_add(vec_bytes) } @@ -1009,6 +1016,10 @@ impl ScriptObjectData { } } } else { + if let Some(existing) = self.map.get_mut(&key) { + existing.value = value; + return; + } let order = self.map.len() as u32; self.map.insert( key, diff --git a/vendor/makepad/platform/script/src/opcodes.rs b/vendor/makepad/platform/script/src/opcodes.rs index 5eabd5a..a295409 100644 --- a/vendor/makepad/platform/script/src/opcodes.rs +++ b/vendor/makepad/platform/script/src/opcodes.rs @@ -235,29 +235,7 @@ impl<'a> ScriptVm<'a> { Opcode::LET_DESTRUCT_ARRAY_EL => self.handle_let_destruct_array_el(opargs), Opcode::LET_DESTRUCT_OBJECT_EL => self.handle_let_destruct_object_el(), - opcode => { - let ip = self.bx.threads.cur_ref().trap.ip; - let loc = self.bx.code.ip_to_loc(ip); - if let Some(loc) = loc { - eprintln!( - "UNDEFINED OPCODE {} (raw={}) at {} (ip body={}, index={})", - opcode, - opcode.raw(), - loc, - ip.body, - ip.index - ); - } else { - eprintln!( - "UNDEFINED OPCODE {} (raw={}) at ip body={}, index={}", - opcode, - opcode.raw(), - ip.body, - ip.index - ); - } - self.bx.threads.cur().trap.goto_next(); - } + _ => self.bail("undefined VM opcode"), } if self.bx.threads.cur_ref().has_execution_limit_exceeded() { return; diff --git a/vendor/makepad/platform/script/src/opcodes_assign.rs b/vendor/makepad/platform/script/src/opcodes_assign.rs index fcb6301..d023ce3 100644 --- a/vendor/makepad/platform/script/src/opcodes_assign.rs +++ b/vendor/makepad/platform/script/src/opcodes_assign.rs @@ -223,12 +223,12 @@ impl<'a> ScriptVm<'a> { .set_value(obj, index, value, self.bx.threads.cur().trap.pass()); self.bx.threads.cur().push_stack_unchecked(value); } else if let Some(arr) = object.as_array() { - self.bx.heap.set_array_index( - arr, - index.as_index(), - value, - self.bx.threads.cur().trap.pass(), - ); + let Some(index) = self.checked_array_index(index) else { + return; + }; + self.bx + .heap + .set_array_index(arr, index, value, self.bx.threads.cur().trap.pass()); self.bx.threads.cur().push_stack_unchecked(value); } else { let value = script_err_wrong_value!( @@ -276,7 +276,9 @@ impl<'a> ScriptVm<'a> { self.bx.threads.cur().push_stack_unchecked(value); } } else if let Some(arr) = object.as_array() { - let index = index.as_index(); + let Some(index) = self.checked_array_index(index) else { + return; + }; let old_value = self .bx .heap @@ -336,7 +338,9 @@ impl<'a> ScriptVm<'a> { self.bx.threads.cur().push_stack_unchecked(NIL); } } else if let Some(arr) = object.as_array() { - let index = index.as_index(); + let Some(index) = self.checked_array_index(index) else { + return; + }; let old_value = self .bx .heap @@ -690,7 +694,9 @@ impl<'a> ScriptVm<'a> { ); self.bx.threads.cur().push_stack_unchecked(value); } else if let Some(arr) = object.as_array() { - let index = index.as_index(); + let Some(index) = self.checked_array_index(index) else { + return; + }; let old_value = self .bx .heap @@ -739,7 +745,9 @@ impl<'a> ScriptVm<'a> { ); self.bx.threads.cur().push_stack_unchecked(value); } else if let Some(arr) = object.as_array() { - let index = index.as_index(); + let Some(index) = self.checked_array_index(index) else { + return; + }; let old_value = self .bx .heap diff --git a/vendor/makepad/platform/script/src/opcodes_loops.rs b/vendor/makepad/platform/script/src/opcodes_loops.rs index 06ccba5..bc8d258 100644 --- a/vendor/makepad/platform/script/src/opcodes_loops.rs +++ b/vendor/makepad/platform/script/src/opcodes_loops.rs @@ -379,8 +379,7 @@ impl<'a> ScriptVm<'a> { self.bx.threads.cur().trap.goto(start_ip); return; } - println!("For end unknown state"); - self.bx.threads.cur().trap.goto_next(); + self.bail("unknown loop state"); } pub fn break_for_loop(&mut self) { diff --git a/vendor/makepad/platform/script/src/opcodes_ops.rs b/vendor/makepad/platform/script/src/opcodes_ops.rs index c53c63b..bafb523 100644 --- a/vendor/makepad/platform/script/src/opcodes_ops.rs +++ b/vendor/makepad/platform/script/src/opcodes_ops.rs @@ -7,26 +7,19 @@ use crate::numeric::NumericValue; use crate::opcode::*; use crate::value::*; use crate::vm::ScriptVm; +use crate::*; impl<'a> ScriptVm<'a> { // ARITHMETIC handlers pub(crate) fn handle_not(&mut self) { let value = self.bx.threads.cur().pop_stack_resolved(&self.bx.heap); - if let Some(v) = value.as_f64() { - self.bx - .threads - .cur() - .push_stack_unchecked(ScriptValue::from_f64(!(v as u64) as f64)); - self.bx.threads.cur().trap.goto_next(); - } else { - let v = self.bx.heap.cast_to_bool(value); - self.bx - .threads - .cur() - .push_stack_unchecked(ScriptValue::from_bool(!v)); - self.bx.threads.cur().trap.goto_next(); - } + let v = self.bx.heap.cast_to_bool(value); + self.bx + .threads + .cur() + .push_stack_unchecked(ScriptValue::from_bool(!v)); + self.bx.threads.cur().trap.goto_next(); } pub(crate) fn handle_neg(&mut self) { @@ -104,23 +97,53 @@ impl<'a> ScriptVm<'a> { // EQUALITY handlers pub(crate) fn handle_eq(&mut self) { - let b = self.bx.threads.cur().pop_stack_resolved(&self.bx.heap); - let a = self.bx.threads.cur().pop_stack_resolved(&self.bx.heap); - self.bx - .threads - .cur() - .push_stack_unchecked(self.bx.heap.deep_eq(a, b).into()); - self.bx.threads.cur().trap.goto_next(); + self.handle_structural_equality(false); } pub(crate) fn handle_neq(&mut self) { + self.handle_structural_equality(true); + } + + fn handle_structural_equality(&mut self, negate: bool) { let b = self.bx.threads.cur().pop_stack_resolved(&self.bx.heap); let a = self.bx.threads.cur().pop_stack_resolved(&self.bx.heap); - self.bx - .threads - .cur() - .push_stack_unchecked((!self.bx.heap.deep_eq(a, b)).into()); - self.bx.threads.cur().trap.goto_next(); + let deadline = self.bx.run_budget.as_ref().map(|b| b.hard_deadline); + let thread = self.bx.threads.cur(); + let result = self + .bx + .heap + .deep_eq_bounded(a, b, crate::equality::MAX_EQUALITY_WORK, || { + if let Some(remaining) = thread.instruction_limit_remaining.as_mut() { + if *remaining == 0 { + return false; + } + *remaining -= 1; + } + deadline.is_none_or(|deadline| std::time::Instant::now() < deadline) + }); + match result { + Some(equal) => { + self.bx + .threads + .cur() + .push_stack_unchecked((equal != negate).into()); + self.bx.threads.cur().trap.goto_next(); + } + None => { + let error = script_err_limit!( + self.bx.threads.cur_ref().trap, + "script equality work, instruction, or time limit exceeded" + ); + // Native work exhaustion is uncatchable just like VM fuel. + self.drain_errors(); + self.bx + .threads + .cur() + .trap + .on + .set(Some(crate::trap::ScriptTrapOn::Bail(error))); + } + } } pub(crate) fn handle_shallow_eq(&mut self) { diff --git a/vendor/makepad/platform/script/src/opcodes_vars.rs b/vendor/makepad/platform/script/src/opcodes_vars.rs index c46dd75..f7073bb 100644 --- a/vendor/makepad/platform/script/src/opcodes_vars.rs +++ b/vendor/makepad/platform/script/src/opcodes_vars.rs @@ -178,7 +178,9 @@ impl<'a> ScriptVm<'a> { value } } else if let Some(arr) = object.as_array() { - let idx = index.as_index(); + let Some(idx) = self.checked_array_index(index) else { + return; + }; let value = self .bx .heap @@ -205,7 +207,9 @@ impl<'a> ScriptVm<'a> { .heap .set_value(obj, index, built_object, self.bx.threads.cur().trap.pass()); } else if let Some(arr) = object.as_array() { - let idx = index.as_index(); + let Some(idx) = self.checked_array_index(index) else { + return; + }; self.bx .heap .set_array_index(arr, idx, built_object, self.bx.threads.cur().trap.pass()); @@ -519,6 +523,20 @@ impl<'a> ScriptVm<'a> { self.bx.threads.cur().trap.goto_next(); } + /// Validate before touching storage, so rejected writes are atomic. + pub(crate) fn checked_array_index(&mut self, index: ScriptValue) -> Option { + if let Some(index) = index.checked_index() { + return Some(index); + } + let error = script_err_invalid_args!( + self.bx.threads.cur_ref().trap, + "array index must be a finite nonnegative integer" + ); + self.bx.threads.cur().push_stack_unchecked(error); + self.bx.threads.cur().trap.goto_next(); + None + } + // Array index handler pub(crate) fn handle_array_index(&mut self) { @@ -543,14 +561,18 @@ impl<'a> ScriptVm<'a> { }; self.bx.threads.cur().push_stack_unchecked(value) } else if let Some(arr) = object.as_array() { - let index = index.as_index(); + let Some(index) = self.checked_array_index(index) else { + return; + }; let value = self .bx .heap .array_index(arr, index, self.bx.threads.cur().trap.pass()); self.bx.threads.cur().push_stack_unchecked(value) } else if let Some(pod) = object.as_pod() { - let index = index.as_index(); + let Some(index) = self.checked_array_index(index) else { + return; + }; let value = self.bx.heap.pod_array_index( pod, index, @@ -653,6 +675,15 @@ impl<'a> ScriptVm<'a> { // Log handler pub(crate) fn handle_log(&mut self) { + if !self.bx.allow_debug_output { + let error = script_err_not_allowed!( + self.bx.threads.cur_ref().trap, + "direct script logging is disabled by this host" + ); + self.bx.threads.cur().push_stack_unchecked(error); + self.bx.threads.cur().trap.goto_next(); + return; + } let value = self.bx.threads.cur().peek_stack_resolved(&self.bx.heap); self.log(value); self.bx.threads.cur().trap.goto_next(); @@ -691,6 +722,9 @@ impl<'a> ScriptVm<'a> { // Log implementation pub fn log(&self, value: ScriptValue) { + if !self.bx.allow_debug_output { + return; + } if let Some(loc) = self.bx.code.ip_to_loc(self.bx.threads.cur_ref().trap.ip) { if value != NIL { if let Some(err_ptr) = value.as_err() { @@ -785,7 +819,9 @@ impl<'a> ScriptVm<'a> { let source = self.bx.threads.cur().pop_stack_resolved(&self.bx.heap); let value = if let Some(arr) = source.as_array() { - let idx = index.as_index(); + let Some(idx) = self.checked_array_index(index) else { + return; + }; // Try to get, return NIL if out of bounds or error let result = self .bx diff --git a/vendor/makepad/platform/script/src/parser.rs b/vendor/makepad/platform/script/src/parser.rs index bb77f73..f491d57 100644 --- a/vendor/makepad/platform/script/src/parser.rs +++ b/vendor/makepad/platform/script/src/parser.rs @@ -466,6 +466,7 @@ enum State { // Short-circuit evaluation - patches TEST opcode jump after second operand ShortCircuitEnd { test_slot: u32, + what_op: LiveId, }, // Short-circuit ?= - emits ASSIGN after RHS, then patches jump ShortCircuitAssignEnd { @@ -527,7 +528,8 @@ impl State { id!(|) => 13, id!(-:) => 14, id!(++) => 14, - id!(===) | id!(!==) | id!(==) | id!(!=) | id!(<) | id!(>) | id!(<=) | id!(>=) => 15, + id!(<) | id!(>) | id!(<=) | id!(>=) => 14, + id!(===) | id!(!==) | id!(==) | id!(!=) => 15, id!(is) => 15, id!(&&) => 16, id!(||) | id!(|?) => 17, @@ -777,6 +779,8 @@ pub struct ParserCheckpoint { /// The last opcode before the checkpoint, saved because auto-close's /// set_pop_to_me() mutates it in place. Must be restored on continuation. last_opcode: Option, + /// Auto-close patches pending logical jumps in place, not just the tail. + short_circuit_opcodes: Vec<(u32, ScriptValue)>, } impl ScriptParser { @@ -2711,7 +2715,7 @@ impl ScriptParser { self.push_code(Opcode::ME_SPLAT.into(), index); return 0; } - State::ShortCircuitEnd { test_slot } => { + State::ShortCircuitEnd { test_slot, .. } => { // Patch the TEST opcode's jump to skip to current position (after second operand) self.set_opcode_args(test_slot, OpcodeArgs::from_u32(self.code_len() - test_slot)); return 0; @@ -3631,8 +3635,13 @@ impl ScriptParser { } else { break; } - } else if let State::ShortCircuitEnd { test_slot } = last { - // Patch any higher-precedence short-circuit ops + } else if let State::ShortCircuitEnd { test_slot, what_op } = last { + // Keep a lower-precedence left operator open until + // its entire right-hand expression has been emitted. + if State::operator_order(*what_op) > op_order { + break; + } + // Patch higher or equal precedence short-circuit ops let test_slot = *test_slot; self.state.pop(); self.set_opcode_args( @@ -3649,7 +3658,10 @@ impl ScriptParser { self.push_code(State::short_circuit_opcode(op).into(), self.index); // Push state to patch the jump after second operand is parsed - self.state.push(State::ShortCircuitEnd { test_slot }); + self.state.push(State::ShortCircuitEnd { + test_slot, + what_op: op, + }); self.state.push(State::BeginExpr { required: true }); return 1; } @@ -3767,8 +3779,7 @@ impl ScriptParser { let Some(proto_field_index) = chain_start.checked_add(2) else { error!( self, - tokenizer, - "Malformed prototype field assignment" + tokenizer, "Malformed prototype field assignment" ); self.state.push(State::BeginExpr { required: true }); return 1; @@ -3782,8 +3793,7 @@ impl ScriptParser { if !chain_starts_with_id || !proto_field_slot_is_valid { error!( self, - tokenizer, - "Malformed prototype field assignment" + tokenizer, "Malformed prototype field assignment" ); self.state.push(State::BeginExpr { required: true }); return 1; @@ -4259,6 +4269,17 @@ impl ScriptParser { destruct_defaults_len: self.destruct_defaults.len(), nested_patterns_len: self.nested_patterns.len(), last_opcode: self.opcodes.last().copied(), + short_circuit_opcodes: self + .state + .iter() + .filter_map(|state| { + if let State::ShortCircuitEnd { test_slot, .. } = state { + Some((*test_slot, self.opcodes[*test_slot as usize])) + } else { + None + } + }) + .collect(), } } @@ -4273,6 +4294,9 @@ impl ScriptParser { *last = saved; } } + for (slot, opcode) in cp.short_circuit_opcodes { + self.opcodes[slot as usize] = opcode; + } self.index = cp.token_index; self.state = cp.state; self.destruct_defaults.truncate(cp.destruct_defaults_len); @@ -4406,6 +4430,12 @@ impl ScriptParser { State::EmitUnary { what_op, index } => { self.push_code(State::operator_to_unary(what_op), index); } + State::ShortCircuitEnd { test_slot, .. } => { + self.set_opcode_args( + test_slot, + OpcodeArgs::from_u32(self.code_len() - test_slot), + ); + } _ => {} } } diff --git a/vendor/makepad/platform/script/src/value.rs b/vendor/makepad/platform/script/src/value.rs index d08f577..f302107 100644 --- a/vendor/makepad/platform/script/src/value.rs +++ b/vendor/makepad/platform/script/src/value.rs @@ -1108,6 +1108,14 @@ impl ScriptValue { self.0 < Self::TYPE_NAN } + /// Checked index for language reads and writes; unlike `as_index`, this + /// cannot truncate, saturate, or turn a non-number into item zero. + pub fn checked_index(&self) -> Option { + let value = self.as_number()?; + (value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value < usize::MAX as f64) + .then_some(value as usize) + } + pub const fn as_index(&self) -> usize { if let Some(f) = self.as_f64() { return f as usize; diff --git a/vendor/makepad/platform/script/src/vm.rs b/vendor/makepad/platform/script/src/vm.rs index 8968e64..1232cb4 100644 --- a/vendor/makepad/platform/script/src/vm.rs +++ b/vendor/makepad/platform/script/src/vm.rs @@ -684,7 +684,26 @@ impl<'a> ScriptVm<'a> { .trap .goto(try_frame.start_ip + try_frame.jump); } else { + // An uncaught error terminates this evaluation before another + // instruction (and therefore another host effect) can execute. + let error = self + .bx + .threads + .cur_ref() + .trap + .err + .borrow() + .front() + .map(|e| e.value); self.drain_errors(); + if let Some(error) = error { + self.bx + .threads + .cur() + .trap + .on + .set(Some(ScriptTrapOn::Bail(error))); + } } } @@ -888,10 +907,8 @@ impl<'a> ScriptVm<'a> { // If there's a value on the stack, return it (for expression-style scripts) let stack_len = self.bx.threads.cur().stack.len(); if stack_len > 0 { - log!("run_core: returning stack value, stack_len={}", stack_len); return self.bx.threads.cur().pop_stack_value(); } - log!("run_core: stack empty, returning NIL"); return NIL; }; @@ -1494,6 +1511,9 @@ pub struct ScriptVmBase { pub is_reload: bool, pub debug_trace: bool, pub silence_errors: bool, + /// Trusted raw Makepad hosts may log; standalone Splash disables this + /// before either canonical or compatibility source can execute. + pub allow_debug_output: bool, /// When Some, drained errors are pushed here (formatted) instead of being /// logged or dropped — even under `silence_errors`. Install before an /// eval/call, take after, to feed diagnostics back to a host (e.g. an AI @@ -1513,6 +1533,7 @@ impl ScriptVmBase { is_reload: false, debug_trace: false, silence_errors: false, + allow_debug_output: true, captured_errors: None, run_budget: None, } @@ -1546,6 +1567,7 @@ impl ScriptVmBase { is_reload: false, debug_trace: false, silence_errors: false, + allow_debug_output: true, captured_errors: None, run_budget: None, } @@ -1620,6 +1642,42 @@ mod tests { assert_eq!(result.as_f64(), Some(42.0)); } + #[test] + fn streaming_logical_precedence_repatches_pending_short_circuits() { + for (prefix, suffix, expected) in [ + ("true || false ", "&& false", true), + ("false && true ", "|| true", true), + ("true == 2 ", "> 1", true), + ] { + let mut host = (); + let mut std = (); + let mut vm = ScriptVm { + host: &mut host, + std: &mut std, + bx: Box::new(ScriptVmBase::new()), + }; + let module = || ScriptMod { + file: "streaming-precedence.splash".into(), + ..Default::default() + }; + let partial = vm.with_instruction_limit(1000, |vm| { + vm.eval_with_append_source(module(), prefix, ScriptObject::ZERO) + }); + assert!( + !partial.is_err(), + "unfinished logical expression must terminate: {prefix}" + ); + let result = vm.with_instruction_limit(1000, |vm| { + vm.eval_with_append_source( + module(), + &format!("{prefix}{suffix}\n;"), + ScriptObject::ZERO, + ) + }); + assert_eq!(result.as_bool(), Some(expected), "{prefix}{suffix}"); + } + } + #[test] fn streaming_legacy_try_ok_repatches_the_success_jump() { let mut host = (); @@ -1697,7 +1755,7 @@ mod tests { }\n\ try { payload + payload } { \"ok\" }\n\ ;" - .to_owned(), + .to_owned(), ..Default::default() }); @@ -1728,7 +1786,7 @@ mod tests { code: "let values = []\n\ try { values[268435456] = 1 } { \"ok\" }\n\ ;" - .to_owned(), + .to_owned(), ..Default::default() }); @@ -1823,10 +1881,7 @@ mod tests { bx: Box::new(ScriptVmBase::new()), }; - let bytes = vm - .bx - .heap - .new_array_from_vec_u8(vec![b'a', 0xFF, b'b']); + let bytes = vm.bx.heap.new_array_from_vec_u8(vec![b'a', 0xFF, b'b']); vm.set_injected_global(id!(bytes), bytes.into()); let result = vm.eval(ScriptMod {