@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.
- package/Cargo.toml +3 -0
- package/bin/tish +0 -0
- package/crates/tish/Cargo.toml +1 -1
- package/crates/tish/tests/integration_test.rs +80 -0
- package/crates/tish_ast/src/ast.rs +10 -0
- package/crates/tish_builtins/src/array.rs +529 -56
- package/crates/tish_builtins/src/collections.rs +114 -40
- package/crates/tish_builtins/src/string.rs +95 -8
- package/crates/tish_bytecode/src/chunk.rs +7 -0
- package/crates/tish_bytecode/src/compiler.rs +560 -64
- package/crates/tish_bytecode/src/lib.rs +1 -1
- package/crates/tish_bytecode/src/opcode.rs +154 -2
- package/crates/tish_bytecode/src/serialize.rs +2 -0
- package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
- package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
- package/crates/tish_compile/src/codegen.rs +17736 -5269
- package/crates/tish_compile/src/infer.rs +1707 -190
- package/crates/tish_compile/src/lib.rs +29 -4
- package/crates/tish_compile/src/resolve.rs +66 -5
- package/crates/tish_compile/src/types.rs +224 -28
- package/crates/tish_compile/tests/dump_codegen.rs +21 -0
- package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
- package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
- package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
- package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
- package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
- package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
- package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
- package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
- package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
- package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
- package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
- package/crates/tish_compile_js/src/codegen.rs +33 -0
- package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
- package/crates/tish_core/Cargo.toml +4 -0
- package/crates/tish_core/src/json.rs +104 -5
- package/crates/tish_core/src/lib.rs +174 -0
- package/crates/tish_core/src/shape.rs +4 -2
- package/crates/tish_core/src/value.rs +565 -35
- package/crates/tish_core/src/vmref.rs +14 -0
- package/crates/tish_eval/src/eval.rs +675 -76
- package/crates/tish_eval/src/natives.rs +19 -0
- package/crates/tish_eval/src/value.rs +76 -21
- package/crates/tish_ffi/src/lib.rs +11 -1
- package/crates/tish_ffi/tests/double_free.rs +35 -0
- package/crates/tish_fmt/src/lib.rs +75 -1
- package/crates/tish_lexer/src/lib.rs +76 -0
- package/crates/tish_lexer/src/token.rs +4 -0
- package/crates/tish_lint/src/lib.rs +126 -0
- package/crates/tish_lsp/README.md +2 -1
- package/crates/tish_lsp/src/main.rs +378 -28
- package/crates/tish_native/src/build.rs +41 -0
- package/crates/tish_parser/Cargo.toml +4 -0
- package/crates/tish_parser/src/lib.rs +27 -0
- package/crates/tish_parser/src/parser.rs +302 -20
- package/crates/tish_resolve/src/lib.rs +9 -0
- package/crates/tish_runtime/Cargo.toml +5 -0
- package/crates/tish_runtime/src/http.rs +28 -10
- package/crates/tish_runtime/src/http_fetch.rs +150 -1
- package/crates/tish_runtime/src/http_prefork.rs +72 -11
- package/crates/tish_runtime/src/lib.rs +523 -42
- package/crates/tish_runtime/src/timers.rs +49 -2
- package/crates/tish_ui/src/jsx.rs +3 -0
- package/crates/tish_vm/src/jit.rs +2514 -117
- package/crates/tish_vm/src/vm.rs +1227 -182
- package/package.json +1 -1
- package/platform/darwin-arm64/tish +0 -0
- package/platform/darwin-x64/tish +0 -0
- package/platform/linux-arm64/tish +0 -0
- package/platform/linux-x64/tish +0 -0
- 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
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
libc::kill(
|
|
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
|
+
}
|