@tishlang/tish 2.12.0 → 2.16.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/tests/integration_test.rs +80 -0
  5. package/crates/tish_ast/src/ast.rs +10 -0
  6. package/crates/tish_builtins/src/array.rs +529 -56
  7. package/crates/tish_builtins/src/collections.rs +114 -40
  8. package/crates/tish_builtins/src/string.rs +95 -8
  9. package/crates/tish_bytecode/src/chunk.rs +7 -0
  10. package/crates/tish_bytecode/src/compiler.rs +560 -64
  11. package/crates/tish_bytecode/src/lib.rs +1 -1
  12. package/crates/tish_bytecode/src/opcode.rs +154 -2
  13. package/crates/tish_bytecode/src/serialize.rs +2 -0
  14. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  15. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  16. package/crates/tish_compile/src/codegen.rs +17736 -5269
  17. package/crates/tish_compile/src/infer.rs +1707 -190
  18. package/crates/tish_compile/src/lib.rs +29 -4
  19. package/crates/tish_compile/src/resolve.rs +66 -5
  20. package/crates/tish_compile/src/types.rs +224 -28
  21. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  22. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  23. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  24. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  25. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  26. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  27. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  28. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  29. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  30. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  31. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  32. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  33. package/crates/tish_compile_js/src/codegen.rs +33 -0
  34. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  35. package/crates/tish_core/Cargo.toml +4 -0
  36. package/crates/tish_core/src/json.rs +104 -5
  37. package/crates/tish_core/src/lib.rs +174 -0
  38. package/crates/tish_core/src/shape.rs +4 -2
  39. package/crates/tish_core/src/value.rs +565 -35
  40. package/crates/tish_core/src/vmref.rs +14 -0
  41. package/crates/tish_eval/src/eval.rs +675 -76
  42. package/crates/tish_eval/src/natives.rs +19 -0
  43. package/crates/tish_eval/src/value.rs +76 -21
  44. package/crates/tish_ffi/src/lib.rs +11 -1
  45. package/crates/tish_ffi/tests/double_free.rs +35 -0
  46. package/crates/tish_fmt/src/lib.rs +75 -1
  47. package/crates/tish_lexer/src/lib.rs +76 -0
  48. package/crates/tish_lexer/src/token.rs +4 -0
  49. package/crates/tish_lint/src/lib.rs +126 -0
  50. package/crates/tish_lsp/README.md +2 -1
  51. package/crates/tish_lsp/src/main.rs +378 -28
  52. package/crates/tish_native/src/build.rs +41 -0
  53. package/crates/tish_parser/Cargo.toml +4 -0
  54. package/crates/tish_parser/src/lib.rs +27 -0
  55. package/crates/tish_parser/src/parser.rs +302 -20
  56. package/crates/tish_resolve/src/lib.rs +9 -0
  57. package/crates/tish_runtime/Cargo.toml +5 -0
  58. package/crates/tish_runtime/src/http.rs +28 -10
  59. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  60. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  61. package/crates/tish_runtime/src/lib.rs +523 -42
  62. package/crates/tish_runtime/src/timers.rs +49 -2
  63. package/crates/tish_ui/src/jsx.rs +3 -0
  64. package/crates/tish_vm/src/jit.rs +2514 -117
  65. package/crates/tish_vm/src/vm.rs +1227 -182
  66. package/package.json +1 -1
  67. package/platform/darwin-arm64/tish +0 -0
  68. package/platform/darwin-x64/tish +0 -0
  69. package/platform/linux-arm64/tish +0 -0
  70. package/platform/linux-x64/tish +0 -0
  71. package/platform/win32-x64/tish.exe +0 -0
@@ -201,7 +201,7 @@ impl HttpBody {
201
201
  };
202
202
  Box::pin(async move {
203
203
  match resp {
204
- Ok(r) => r.text().await.map_err(|e| e.to_string()),
204
+ Ok(r) => read_text_capped(r, fetch_max_body()).await,
205
205
  Err(e) => Err(e),
206
206
  }
207
207
  })
@@ -373,12 +373,116 @@ fn fetch_client() -> &'static reqwest::Client {
373
373
  })
374
374
  }
375
375
 
376
+ /// Maximum outbound-`fetch` response body we will buffer, in bytes. `reqwest::Response::text()`
377
+ /// reads the whole body with no bound, so a compromised or hostile upstream (or a
378
+ /// compression-amplified response) could OOM the worker (#383). Override with `TISH_FETCH_MAX_BODY`;
379
+ /// default 100 MiB — generous enough for normal API responses and moderate downloads, low enough to
380
+ /// stop a pathological body. Set it higher for legitimately large downloads.
381
+ fn fetch_max_body() -> usize {
382
+ use std::sync::OnceLock;
383
+ static MAX: OnceLock<usize> = OnceLock::new();
384
+ *MAX.get_or_init(|| parse_max_body(std::env::var("TISH_FETCH_MAX_BODY").ok()))
385
+ }
386
+
387
+ /// Pure `TISH_FETCH_MAX_BODY` parse: a valid byte count, else the 100 MiB default. Split out so it is
388
+ /// testable without touching process-global env / the `OnceLock` cache.
389
+ fn parse_max_body(env: Option<String>) -> usize {
390
+ env.and_then(|v| v.parse().ok()).unwrap_or(100 * 1024 * 1024)
391
+ }
392
+
393
+ /// Read a response body to a `String`, streaming with a byte budget so it can never buffer more than
394
+ /// `max` bytes (#383). Rejects early on a `Content-Length` that already exceeds the cap, and again mid
395
+ /// stream for chunked/lying responses. Decodes UTF-8 lossily (matching `reqwest::text()` for the
396
+ /// UTF-8 responses that dominate `fetch`).
397
+ async fn read_text_capped(mut resp: reqwest::Response, max: usize) -> Result<String, String> {
398
+ if let Some(len) = resp.content_length() {
399
+ if len as usize > max {
400
+ return Err(format!(
401
+ "fetch: response body ({len} bytes, Content-Length) exceeds the {max}-byte cap (set TISH_FETCH_MAX_BODY to raise it)"
402
+ ));
403
+ }
404
+ }
405
+ let mut buf: Vec<u8> = Vec::new();
406
+ while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
407
+ if buf.len() + chunk.len() > max {
408
+ return Err(format!(
409
+ "fetch: response body exceeds the {max}-byte cap (set TISH_FETCH_MAX_BODY to raise it)"
410
+ ));
411
+ }
412
+ buf.extend_from_slice(&chunk);
413
+ }
414
+ Ok(String::from_utf8_lossy(&buf).into_owned())
415
+ }
416
+
417
+ /// Opt-in SSRF guard (#383): when `TISH_FETCH_BLOCK_PRIVATE_IPS=1`, `fetch()` refuses to connect to a
418
+ /// loopback / private / link-local / unique-local address. Off by default so localhost development is
419
+ /// unaffected; enable it when the request URL can derive from untrusted input (metadata-endpoint /
420
+ /// internal-service pivots). NOTE: this is a pre-flight resolution check, so a DNS-rebinding attacker
421
+ /// could in principle change the record between this check and reqwest's own resolution — a follow-up
422
+ /// could move the filter into a custom `reqwest` resolver to close that window.
423
+ fn fetch_block_private() -> bool {
424
+ use std::sync::OnceLock;
425
+ static ON: OnceLock<bool> = OnceLock::new();
426
+ *ON.get_or_init(|| std::env::var("TISH_FETCH_BLOCK_PRIVATE_IPS").as_deref() == Ok("1"))
427
+ }
428
+
429
+ /// True for addresses a hardened `fetch()` must not reach: loopback, RFC1918 private, link-local
430
+ /// (incl. the `169.254.169.254` cloud-metadata endpoint), unspecified/broadcast, `0.0.0.0/8`, IPv6
431
+ /// unique-local (`fc00::/7`) and link-local (`fe80::/10`), and IPv4-mapped forms of all the above.
432
+ fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
433
+ use std::net::IpAddr;
434
+ match ip {
435
+ IpAddr::V4(v4) => {
436
+ v4.is_loopback()
437
+ || v4.is_private()
438
+ || v4.is_link_local()
439
+ || v4.is_broadcast()
440
+ || v4.is_unspecified()
441
+ || v4.octets()[0] == 0 // 0.0.0.0/8 "this network"
442
+ }
443
+ IpAddr::V6(v6) => {
444
+ v6.is_loopback()
445
+ || v6.is_unspecified()
446
+ || (v6.segments()[0] & 0xfe00) == 0xfc00 // unique-local fc00::/7
447
+ || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local fe80::/10
448
+ || v6
449
+ .to_ipv4_mapped()
450
+ .is_some_and(|m| is_blocked_ip(IpAddr::V4(m)))
451
+ }
452
+ }
453
+ }
454
+
455
+ /// Reject the request if the URL's host resolves to a blocked address (only when the opt-in policy is
456
+ /// enabled). `tokio::net::lookup_host` handles both literal IPs and hostnames.
457
+ async fn ssrf_preflight(url: &str) -> Result<(), String> {
458
+ let parsed = reqwest::Url::parse(url).map_err(|e| format!("fetch: invalid URL: {e}"))?;
459
+ let host = parsed
460
+ .host_str()
461
+ .ok_or_else(|| "fetch: URL has no host".to_string())?;
462
+ let port = parsed.port_or_known_default().unwrap_or(80);
463
+ let addrs = tokio::net::lookup_host((host, port))
464
+ .await
465
+ .map_err(|e| format!("fetch: cannot resolve {host}: {e}"))?;
466
+ for addr in addrs {
467
+ if is_blocked_ip(addr.ip()) {
468
+ return Err(format!(
469
+ "fetch: blocked by SSRF policy — host {host} resolves to internal address {}",
470
+ addr.ip()
471
+ ));
472
+ }
473
+ }
474
+ Ok(())
475
+ }
476
+
376
477
  async fn send_request_parts(
377
478
  url: String,
378
479
  method: String,
379
480
  headers: Vec<(String, String)>,
380
481
  body: Option<String>,
381
482
  ) -> Result<reqwest::Response, String> {
483
+ if fetch_block_private() {
484
+ ssrf_preflight(&url).await?;
485
+ }
382
486
  let client = fetch_client();
383
487
  let mut req = match method.as_str() {
384
488
  "GET" => client.get(&url),
@@ -490,3 +594,48 @@ pub fn fetch_all_promise_from_args(args: Vec<Value>) -> Value {
490
594
  rx: Mutex::new(Some(rx)),
491
595
  }))
492
596
  }
597
+
598
+ #[cfg(test)]
599
+ mod fetch_cap_tests_383 {
600
+ use super::{is_blocked_ip, parse_max_body};
601
+ use std::net::IpAddr;
602
+
603
+ #[test]
604
+ fn default_cap_is_100_mib() {
605
+ assert_eq!(parse_max_body(None), 100 * 1024 * 1024);
606
+ assert_eq!(parse_max_body(Some("not-a-number".into())), 100 * 1024 * 1024);
607
+ }
608
+
609
+ #[test]
610
+ fn env_override_parses() {
611
+ assert_eq!(parse_max_body(Some("1048576".into())), 1024 * 1024);
612
+ assert_eq!(parse_max_body(Some("0".into())), 0);
613
+ }
614
+
615
+ #[test]
616
+ fn ssrf_blocks_internal_addresses() {
617
+ for s in [
618
+ "127.0.0.1", // loopback
619
+ "10.0.0.5", // RFC1918
620
+ "172.16.9.9", // RFC1918
621
+ "192.168.1.1", // RFC1918
622
+ "169.254.169.254", // link-local / cloud metadata
623
+ "0.0.0.0", // unspecified
624
+ "::1", // IPv6 loopback
625
+ "fc00::1", // IPv6 unique-local
626
+ "fe80::1", // IPv6 link-local
627
+ "::ffff:127.0.0.1", // IPv4-mapped loopback
628
+ ] {
629
+ let ip: IpAddr = s.parse().unwrap();
630
+ assert!(is_blocked_ip(ip), "{s} must be blocked");
631
+ }
632
+ }
633
+
634
+ #[test]
635
+ fn ssrf_allows_public_addresses() {
636
+ for s in ["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1::"] {
637
+ let ip: IpAddr = s.parse().unwrap();
638
+ assert!(!is_blocked_ip(ip), "{s} must be allowed");
639
+ }
640
+ }
641
+ }
@@ -57,6 +57,8 @@
57
57
  use std::io;
58
58
  use std::process::{Child, Command};
59
59
  use std::sync::atomic::{AtomicBool, Ordering};
60
+ #[cfg(unix)]
61
+ use std::sync::atomic::{AtomicI32, AtomicUsize};
60
62
  use std::sync::Arc;
61
63
 
62
64
  /// Role of the current process in a prefork group.
@@ -142,28 +144,57 @@ pub fn install_parent_signal_handler(children: Vec<Child>) -> Arc<AtomicBool> {
142
144
  stop
143
145
  }
144
146
 
147
+ /// Managed child PIDs, in a fixed atomic array (not a `Vec`/`OnceLock`): the signal handler may only
148
+ /// do async-signal-safe work, so it reads plain atomics — never a lock or an allocation. Each
149
+ /// `serve()` APPENDS its group here (#384), so a second `serve()` in the same parent no longer orphans
150
+ /// its children on SIGINT/SIGTERM (the previous `OnceLock` silently dropped every group after the
151
+ /// first). Ample for any realistic worker count; extra children beyond the cap simply aren't managed.
152
+ #[cfg(unix)]
153
+ const MAX_MANAGED_PIDS: usize = 4096;
154
+ #[cfg(unix)]
155
+ static CHILD_PIDS: [AtomicI32; MAX_MANAGED_PIDS] =
156
+ [const { AtomicI32::new(0) }; MAX_MANAGED_PIDS];
157
+ #[cfg(unix)]
158
+ static CHILD_PID_COUNT: AtomicUsize = AtomicUsize::new(0);
159
+
160
+ /// Append a `serve()` group's child PIDs to the managed set. Stores land before the count is published
161
+ /// (the SeqCst count store is the release), and the signal handler skips non-positive slots — so it
162
+ /// never calls `kill(0, …)` (which would signal the whole process group). Installs are sequential (the
163
+ /// parent's own thread), so no compare-exchange is needed.
164
+ #[cfg(unix)]
165
+ fn register_managed_pids(pids: &[u32]) {
166
+ let start = CHILD_PID_COUNT.load(Ordering::SeqCst);
167
+ for (i, pid) in pids.iter().enumerate() {
168
+ if let Some(slot) = CHILD_PIDS.get(start + i) {
169
+ slot.store(*pid as i32, Ordering::SeqCst);
170
+ }
171
+ }
172
+ CHILD_PID_COUNT.store((start + pids.len()).min(MAX_MANAGED_PIDS), Ordering::SeqCst);
173
+ }
174
+
145
175
  #[cfg(unix)]
146
176
  fn install_shutdown_handler(stop: Arc<AtomicBool>, pids: Vec<u32>) {
147
- // Store state in process-global statics so an `extern "C"` fn can reach
148
- // them from inside a signal handler. This is the usual pattern for
149
- // libc::signal callbacks setting a flag + waking up listeners is the
150
- // only async-signal-safe work we do here.
177
+ // A single process-global stop flag the handler sets. First `serve()` wins the slot; that is fine
178
+ // because the handler re-raises the signal with default disposition below, which terminates the
179
+ // parent regardless of which accept loop is polling the flag only matters for the non-signal
180
+ // (max-requests) path, which each loop drives on its own returned `stop` handle.
151
181
  use std::sync::OnceLock;
152
182
  static STOP_FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();
153
- static CHILD_PIDS: OnceLock<Vec<u32>> = OnceLock::new();
154
-
155
183
  let _ = STOP_FLAG.set(stop);
156
- let _ = CHILD_PIDS.set(pids);
184
+
185
+ register_managed_pids(&pids);
157
186
 
158
187
  extern "C" fn on_signal(sig: libc::c_int) {
159
188
  if let Some(flag) = STOP_FLAG.get() {
160
189
  flag.store(true, Ordering::Relaxed);
161
190
  }
162
- if let Some(pids) = CHILD_PIDS.get() {
163
- for pid in pids {
191
+ let count = CHILD_PID_COUNT.load(Ordering::SeqCst).min(MAX_MANAGED_PIDS);
192
+ for slot in CHILD_PIDS.iter().take(count) {
193
+ let pid = slot.load(Ordering::SeqCst);
194
+ if pid > 0 {
164
195
  // codacy-disable-next-line
165
- unsafe {
166
- libc::kill(*pid as libc::pid_t, libc::SIGTERM);
196
+ unsafe {
197
+ libc::kill(pid as libc::pid_t, libc::SIGTERM);
167
198
  }
168
199
  }
169
200
  }
@@ -187,3 +218,33 @@ fn install_shutdown_handler(stop: Arc<AtomicBool>, pids: Vec<u32>) {
187
218
  fn install_shutdown_handler(_stop: Arc<AtomicBool>, _pids: Vec<u32>) {
188
219
  // TODO: SetConsoleCtrlHandler on Windows.
189
220
  }
221
+
222
+ #[cfg(all(test, unix))]
223
+ mod prefork_pid_tests_384 {
224
+ use super::*;
225
+
226
+ /// Read the managed PID set the way the signal handler does (skipping empty slots).
227
+ fn managed_snapshot() -> Vec<i32> {
228
+ let count = CHILD_PID_COUNT.load(Ordering::SeqCst).min(MAX_MANAGED_PIDS);
229
+ CHILD_PIDS
230
+ .iter()
231
+ .take(count)
232
+ .map(|s| s.load(Ordering::SeqCst))
233
+ .filter(|&p| p > 0)
234
+ .collect()
235
+ }
236
+
237
+ #[test]
238
+ fn repeat_serve_appends_child_pids() {
239
+ // Reset the process-global set (this is the only test that touches it).
240
+ CHILD_PID_COUNT.store(0, Ordering::SeqCst);
241
+ for s in CHILD_PIDS.iter() {
242
+ s.store(0, Ordering::SeqCst);
243
+ }
244
+ // First serve() group, then a SECOND — the old OnceLock silently dropped the second, orphaning
245
+ // its children on shutdown. Now both groups are managed.
246
+ register_managed_pids(&[101, 102]);
247
+ register_managed_pids(&[201, 202, 203]);
248
+ assert_eq!(managed_snapshot(), vec![101, 102, 201, 202, 203]);
249
+ }
250
+ }