From 2f1788bdf64fd475e72cf81acd08aa303a7ac10d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:48:14 -0400 Subject: [PATCH] M13.12: script socket port validation, resource-string trailing commas, parser and socket edge-case tests Co-Authored-By: Claude Opus 5.5 --- crates/script/src/scriptui.js | 6 ++ crates/script/src/socket.rs | 5 +- crates/script/src/tests_ui_more.rs | 134 +++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 57e0f445..525479c6 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -143,6 +143,12 @@ function __uiParseRes(src) { ws(); if (s.charAt(i) === ",") { i++; + // A trailing comma, as JavaScript allows. + ws(); + if (s.charAt(i) === "]") { + i++; + return a; + } continue; } if (s.charAt(i) === "]") { diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 8b1074d4..6ac0d41d 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -144,7 +144,10 @@ mod imp { err(format!("cannot connect to {host}: {last}")) } "listen" => { - let port = p.get("port").and_then(J::as_u64).unwrap_or(0) as u16; + let port = match p.get("port").and_then(J::as_f64).unwrap_or(0.0) { + v if v.fract() == 0.0 && (0.0..=65535.0).contains(&v) => v as u16, + v => return err(format!("bad port {v} (0 to 65535)")), + }; match TcpListener::bind(("127.0.0.1", port)).and_then(|l| l.set_nonblocking(true).map(|_| l)) { Ok(l) => { let port = l.local_addr().map(|a| a.port()).unwrap_or(port); diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs index 2c24329a..69abb61d 100644 --- a/crates/script/src/tests_ui_more.rs +++ b/crates/script/src/tests_ui_more.rs @@ -240,3 +240,137 @@ fn sockets_talk_tcp_behind_the_network_preference() { let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx"); assert_eq!(o.result, json!("true|true")); } + +/// `__uiParseRes(src)` as JSON, or the error message. +fn parse_res(s: &mut Session, src: &str) -> Result { + let code = format!("JSON.stringify(__uiParseRes({}))", serde_json::to_string(src).unwrap()); + let o = run_code(s, &code, "res.jsx"); + match o.error { + Some(e) => Err(e.message), + None => Ok(serde_json::from_str(o.result.as_str().unwrap_or("null")).unwrap_or_default()), + } +} + +#[test] +fn resource_string_parser_edge_cases() { + let mut s = session(); + // Accepted: trailing commas, quoted keys, number forms, escapes, comments, empty bodies, + // null / undefined, bare words, nested arrays and object literals. + let ok = parse_res( + &mut s, + "group { 'quoted key': 1, \"dq\": -.5, n: +3, e: 1e2, arr: [[1, [2]], 'x', ], \ + esc: 'it\\'s\\n\\\\', /* block */ nul: null, u: undefined, w: fill, // line\n \ + obj: { a: { b: true } }, kid: Button {}, }", + ) + .unwrap(); + assert_eq!(ok["type"], "group"); + let list = ok["list"].as_array().unwrap(); + let get = |k: &str| list.iter().find(|e| e[0] == k).map(|e| e[1].clone()).unwrap(); + assert_eq!(get("quoted key"), json!(1)); + assert_eq!(get("dq"), json!(-0.5)); + assert_eq!(get("n"), json!(3)); + assert_eq!(get("e"), json!(100)); + assert_eq!(get("arr"), json!([[1, [2]], "x"])); + assert_eq!(get("esc"), json!("it's\n\\")); + assert_eq!(get("nul"), json!(null)); + assert_eq!(get("w"), json!("fill")); + assert_eq!(get("kid"), json!({"__element": "Button", "list": []})); + // Rejected with a located message (never a crash). + for bad in [ + "", + " ", + "dialog", + "dialog {", + "dialog { text: 'unterminated }", + "dialog { text 'x' }", + "dialog { a: [1, 2 }", + "dialog { a: 1 } trailing", + "dialog { /* unterminated comment", + "dialog { a: 'x\\", + "{ }", + "123 { }", + "dialog { a: 1 b: 2 }", + "dialog { : 1 }", + "dialog { a: }", + "dialog { a: Button { }", + ] { + let e = parse_res(&mut s, bad).expect_err(bad); + assert!(e.contains("resource string"), "{bad:?}: {e}"); + assert!(e.contains("offset"), "{bad:?}: {e}"); + } + // Deep nesting builds; absurd nesting fails cleanly (the script engine's recursion limit). + let deep = |n: usize| format!("group {{ {}{} }}", "g: Group { ".repeat(n), "}".repeat(n)); + let d = parse_res(&mut s, &deep(40)).unwrap(); + assert_eq!(d["list"][0][1]["__element"], "Group"); + let _ = parse_res(&mut s, &deep(5000)); + // A window from a deep resource. + let o = run_code( + &mut s, + &format!( + "var w = new Window({}); w.children.length", + serde_json::to_string(&format!("palette {{ {}{} }}", "g: Group { ".repeat(20), "}".repeat(20))).unwrap() + ), + "deep.jsx", + ); + assert_eq!(o.result, json!(1), "{:?}", o.error); +} + +#[test] +fn socket_edge_cases() { + let mut s = session(); + s.prefs.scripting.allow_scripts_write_files = true; + // Bad addresses and ports: false with an error, never a throw or crash. + for host in ["", "nohostport", "127.0.0.1", "127.0.0.1:99999", "[::1", "a:b:c", "127.0.0.1:-5"] { + let code = format!( + "var s = new Socket(); s.timeout = 1; var ok = s.open({}); [ok, s.error.length > 0, s.connected, s.eof].join('|')", + serde_json::to_string(host).unwrap() + ); + let o = run_code(&mut s, &code, "net.jsx"); + assert_eq!(o.result, json!("false|true|false|true"), "{host:?}: {:?}", o.error); + } + for port in ["70000", "-1", "1.5"] { + let o = run_code(&mut s, &format!("var l = new Socket(); [l.listen({port}), l.error.length > 0].join('|')"), "net.jsx"); + assert_eq!(o.result, json!("false|true"), "{port}: {:?}", o.error); + } + // Never opened or closed: reads are empty, writes fail, close is idempotent. + let o = run_code( + &mut s, + "var s = new Socket(); var r = [s.read(), s.read(5), s.readln(), s.write('x'), s.connected, s.eof, s.poll(), s.close(), s.close()]; r.join('|')", + "net.jsx", + ); + assert_eq!(o.result, json!("|||false|false|true||true|true"), "{:?}", o.error); + // Binary bytes round trip in BINARY encoding; invalid UTF-8 is replaced in UTF-8; partial + // and zero-length reads; read stops at the timeout without data. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + use std::io::Write; + for _ in 0..2 { + let (mut c, _) = listener.accept().unwrap(); + let bytes: Vec = (0..=255u8).collect(); + c.write_all(&bytes).unwrap(); + } + }); + let code = format!( + "var s = new Socket(); s.open('127.0.0.1:{port}', 'BINARY'); var z = s.read(0); var a = s.read(10); var b = s.read(); s.close(); \ + var ok = true; for (var i = 0; i < 10; i++) ok = ok && a.charCodeAt(i) === i; for (var j = 0; j < 246; j++) ok = ok && b.charCodeAt(j) === j + 10; \ + [z.length, a.length, b.length, ok].join('|')" + ); + let o = run_code(&mut s, &code, "net.jsx"); + assert_eq!(o.result, json!("0|10|246|true"), "{:?}", o.error); + let code = format!( + "var s = new Socket(); s.open('127.0.0.1:{port}', 'UTF-8'); var d = s.read(); s.close(); [d.charCodeAt(0), d.charCodeAt(127), d.indexOf('\\ufffd') > 0].join('|')" + ); + let o = run_code(&mut s, &code, "net.jsx"); + assert_eq!(o.result, json!("0|127|true"), "{:?}", o.error); + server.join().unwrap(); + // A listener with nobody connecting: a short read timeout returns quickly. + let t = std::time::Instant::now(); + let o = run_code( + &mut s, + "var l = new Socket(); l.listen(0); l.timeout = 0; var c = new Socket(); c.timeout = 0.2; c.open(l.host); var x = c.read(); c.close(); l.close(); x.length", + "net.jsx", + ); + assert_eq!(o.result, json!(0), "{:?}", o.error); + assert!(t.elapsed().as_secs_f64() < 5.0); +}