@tishlang/tish-lsp 2.35.0 → 2.36.2
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/bin/tish-lsp +0 -0
- package/crates/tish/tests/fixtures/process_capture.tish +16 -0
- package/crates/tish/tests/process_capture.rs +40 -0
- package/crates/tish_compile/src/codegen.rs +6 -2
- package/crates/tish_compile/src/infer.rs +93 -5
- package/crates/tish_eval/src/eval.rs +6 -0
- package/crates/tish_eval/src/natives.rs +54 -0
- package/crates/tish_lsp/Cargo.toml +1 -1
- package/crates/tish_runtime/src/lib.rs +108 -1
- package/crates/tish_vm/src/vm.rs +24 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-lsp +0 -0
- package/platform/darwin-x64/tish-lsp +0 -0
- package/platform/linux-arm64/tish-lsp +0 -0
- package/platform/linux-x64/tish-lsp +0 -0
- package/platform/win32-x64/tish-lsp.exe +0 -0
package/bin/tish-lsp
CHANGED
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Issue #475: process.execCapture / execFileCapture return { code, stdout, stderr }.
|
|
2
|
+
import { execFileCapture, execCapture } from "tish:process"
|
|
3
|
+
|
|
4
|
+
let ok = execFileCapture("echo", ["hi"])
|
|
5
|
+
console.log("ok=" + ok.code + "," + ok.stdout.trim() + "," + (ok.stderr === ""))
|
|
6
|
+
|
|
7
|
+
let bad = execFileCapture("false", [])
|
|
8
|
+
console.log("bad=" + bad.code)
|
|
9
|
+
|
|
10
|
+
let miss = execFileCapture("/no/such/prog/xyz", [])
|
|
11
|
+
console.log("miss=" + miss.code + "," + (miss.stderr !== ""))
|
|
12
|
+
|
|
13
|
+
let shell = execCapture("echo a; echo b 1>&2")
|
|
14
|
+
console.log("shell=" + shell.code + "," + shell.stdout.trim() + "," + shell.stderr.trim())
|
|
15
|
+
|
|
16
|
+
console.log("done")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//! Issue #475: `process.execCapture` / `execFileCapture` return `{ code, stdout, stderr }` identically
|
|
2
|
+
//! on every backend (interpreter, bytecode VM, native AOT).
|
|
3
|
+
|
|
4
|
+
use std::path::PathBuf;
|
|
5
|
+
use std::process::Command;
|
|
6
|
+
|
|
7
|
+
fn run_on(backend: &str) -> String {
|
|
8
|
+
let tish = PathBuf::from(env!("CARGO_BIN_EXE_tish"));
|
|
9
|
+
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
10
|
+
.join("tests")
|
|
11
|
+
.join("fixtures")
|
|
12
|
+
.join("process_capture.tish");
|
|
13
|
+
let out = Command::new(&tish)
|
|
14
|
+
.args([
|
|
15
|
+
"run",
|
|
16
|
+
"--backend",
|
|
17
|
+
backend,
|
|
18
|
+
"--feature",
|
|
19
|
+
"process",
|
|
20
|
+
path.to_str().unwrap(),
|
|
21
|
+
])
|
|
22
|
+
.output()
|
|
23
|
+
.expect("spawn tish run");
|
|
24
|
+
assert!(
|
|
25
|
+
out.status.success(),
|
|
26
|
+
"tish run --backend {backend} process_capture.tish failed: {}",
|
|
27
|
+
String::from_utf8_lossy(&out.stderr)
|
|
28
|
+
);
|
|
29
|
+
String::from_utf8_lossy(&out.stdout).into_owned()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EXPECTED: &str = "ok=0,hi,true\nbad=1\nmiss=-1,true\nshell=0,a,b\ndone\n";
|
|
33
|
+
|
|
34
|
+
#[test]
|
|
35
|
+
#[cfg(unix)]
|
|
36
|
+
fn exec_capture_on_every_backend() {
|
|
37
|
+
for backend in ["vm", "interp", "native"] {
|
|
38
|
+
assert_eq!(run_on(backend), EXPECTED, "exec-capture mismatch on {backend}");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1690,9 +1690,11 @@ impl Codegen {
|
|
|
1690
1690
|
"cwd" => Some("Value::native(|args: &[Value]| tish_process_cwd(args))"),
|
|
1691
1691
|
"exec" => Some("Value::native(|args: &[Value]| tish_process_exec(args))"),
|
|
1692
1692
|
"execFile" => Some("Value::native(|args: &[Value]| tish_process_exec_file(args))"),
|
|
1693
|
+
"execCapture" => Some("Value::native(|args: &[Value]| tish_process_exec_capture(args))"),
|
|
1694
|
+
"execFileCapture" => Some("Value::native(|args: &[Value]| tish_process_exec_file_capture(args))"),
|
|
1693
1695
|
"argv" => Some("Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))"),
|
|
1694
1696
|
"env" => Some("Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect())"),
|
|
1695
|
-
"process" => Some("{ let mut m = ObjectMap::default(); m.insert(Arc::from(\"exit\"), Value::native(|args: &[Value]| tish_process_exit(args))); m.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args))); m.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args))); m.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args))); m.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))); m.insert(Arc::from(\"env\"), Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect::<ObjectMap>())); Value::object(m) }"),
|
|
1697
|
+
"process" => Some("{ let mut m = ObjectMap::default(); m.insert(Arc::from(\"exit\"), Value::native(|args: &[Value]| tish_process_exit(args))); m.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args))); m.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args))); m.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args))); m.insert(Arc::from(\"execCapture\"), Value::native(|args: &[Value]| tish_process_exec_capture(args))); m.insert(Arc::from(\"execFileCapture\"), Value::native(|args: &[Value]| tish_process_exec_file_capture(args))); m.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))); m.insert(Arc::from(\"env\"), Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect::<ObjectMap>())); Value::object(m) }"),
|
|
1696
1698
|
_ => None,
|
|
1697
1699
|
},
|
|
1698
1700
|
"tish:ws" if self.has_feature("ws") => match export_name {
|
|
@@ -2246,7 +2248,7 @@ impl Codegen {
|
|
|
2246
2248
|
self.write("use tishlang_ui::{fragment_value, install_thread_local_host, native_create_root, native_use_state, ui_h, ui_text, HeadlessHost};\n");
|
|
2247
2249
|
}
|
|
2248
2250
|
if self.has_feature("process") {
|
|
2249
|
-
self.write("use tishlang_runtime::{process_exit as tish_process_exit, process_cwd as tish_process_cwd, process_exec as tish_process_exec, process_exec_file as tish_process_exec_file};\n");
|
|
2251
|
+
self.write("use tishlang_runtime::{process_exit as tish_process_exit, process_cwd as tish_process_cwd, process_exec as tish_process_exec, process_exec_file as tish_process_exec_file, process_exec_capture as tish_process_exec_capture, process_exec_file_capture as tish_process_exec_file_capture};\n");
|
|
2250
2252
|
}
|
|
2251
2253
|
if self.has_feature("timers") {
|
|
2252
2254
|
self.write("use tishlang_runtime::{timer_set_timeout as tish_timer_set_timeout, timer_clear_timeout as tish_timer_clear_timeout, timer_set_interval as tish_timer_set_interval, timer_clear_interval as tish_timer_clear_interval};\n");
|
|
@@ -2651,6 +2653,8 @@ impl Codegen {
|
|
|
2651
2653
|
self.writeln("p.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args)));");
|
|
2652
2654
|
self.writeln("p.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args)));");
|
|
2653
2655
|
self.writeln("p.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args)));");
|
|
2656
|
+
self.writeln("p.insert(Arc::from(\"execCapture\"), Value::native(|args: &[Value]| tish_process_exec_capture(args)));");
|
|
2657
|
+
self.writeln("p.insert(Arc::from(\"execFileCapture\"), Value::native(|args: &[Value]| tish_process_exec_file_capture(args)));");
|
|
2654
2658
|
self.writeln("let argv: Vec<Value> = std::env::args().map(|s| Value::String(s.into())).collect();");
|
|
2655
2659
|
self.writeln("p.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(argv)));");
|
|
2656
2660
|
self.writeln("let mut env_obj = ObjectMap::default();");
|
|
@@ -284,16 +284,45 @@ fn param_infer_program(program: Program) -> Program {
|
|
|
284
284
|
// panic on / turn to NaN where JS keeps the value. Skip those fns (they stay fully boxed). Same
|
|
285
285
|
// set `collect_native_fns` uses to refuse the M5 native form.
|
|
286
286
|
let nonnumeric = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
|
|
287
|
+
// Soundness (#477): a fn referenced as a first-class VALUE — passed as a call ARGUMENT
|
|
288
|
+
// (`serve(port, handler)`), assigned, returned, or stored, i.e. any use that is NOT a direct
|
|
289
|
+
// `f(...)` call — escapes into the boxed `Value::Function` path and is later invoked via
|
|
290
|
+
// `.call(&[Value])` with arbitrary runtime Values (a `serve` handler is called with the request
|
|
291
|
+
// OBJECT). Such a fn's params CANNOT be soundly `: number`-shadowed: the escaping boxed closure
|
|
292
|
+
// unboxes arg0 as `match … Value::Number(n) => *n, _ => panic!("expected number")` and would panic
|
|
293
|
+
// on the first non-numeric call. `fns_called_with_nonnumeric_arg` only catches SYNTACTIC
|
|
294
|
+
// non-numeric call args — it can't see this value-escape — so exclude escaping fns explicitly.
|
|
295
|
+
// Over-approximation is safe: an escaping fn just stays fully boxed (correct, at worst slower).
|
|
296
|
+
let escaping = escaping_fn_names(&program.statements);
|
|
287
297
|
Program {
|
|
288
298
|
statements: program
|
|
289
299
|
.statements
|
|
290
300
|
.into_iter()
|
|
291
|
-
.map(|s| pi_stmt(s, &nonnumeric))
|
|
301
|
+
.map(|s| pi_stmt(s, &nonnumeric, &escaping))
|
|
292
302
|
.collect(),
|
|
293
303
|
}
|
|
294
304
|
}
|
|
295
305
|
|
|
296
|
-
|
|
306
|
+
/// Names of fns referenced as a first-class VALUE anywhere in the program — any use of the name that
|
|
307
|
+
/// is NOT a direct `f(...)` call (a bare-ident call argument like `serve(port, handler)`, an
|
|
308
|
+
/// assignment, a return, storage in an object/array). Such a fn is reachable via the boxed
|
|
309
|
+
/// `Value::Function` `.call(&[Value])` path with arbitrary runtime Values, so its params must stay
|
|
310
|
+
/// boxed and are never `: number`-shadowed. Backed by the same `fn_name_uses` escape counter the
|
|
311
|
+
/// inliner uses. See `param_infer_program`. (#477)
|
|
312
|
+
fn escaping_fn_names(statements: &[Statement]) -> HashSet<String> {
|
|
313
|
+
let mut out = HashSet::new();
|
|
314
|
+
for s in statements {
|
|
315
|
+
if let Statement::FunDecl { name, .. } = s {
|
|
316
|
+
let (_, others) = fn_name_uses(statements, name.as_ref());
|
|
317
|
+
if others > 0 {
|
|
318
|
+
out.insert(name.to_string());
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
out
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>, escaping: &HashSet<String>) -> Statement {
|
|
297
326
|
if let Statement::FunDecl {
|
|
298
327
|
async_,
|
|
299
328
|
name,
|
|
@@ -305,8 +334,10 @@ fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>) -> Statement {
|
|
|
305
334
|
span,
|
|
306
335
|
} = s
|
|
307
336
|
{
|
|
308
|
-
// A fn reached with a non-numeric arg somewhere
|
|
309
|
-
|
|
337
|
+
// A fn reached with a non-numeric arg somewhere, OR escaping as a first-class value
|
|
338
|
+
// (#477 — a `serve` handler / stored callback invoked via the boxed path with arbitrary
|
|
339
|
+
// Values): leave its params dynamic (boxed).
|
|
340
|
+
if nonnumeric.contains(name.as_ref()) || escaping.contains(name.as_ref()) {
|
|
310
341
|
return Statement::FunDecl {
|
|
311
342
|
async_,
|
|
312
343
|
name,
|
|
@@ -3680,6 +3711,12 @@ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
|
|
|
3680
3711
|
// would otherwise emit a `match … panic!("expected number")` coercion on the String. Same set the
|
|
3681
3712
|
// M4 `param_infer` path uses (infer.rs:273).
|
|
3682
3713
|
let nonnumeric = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
|
|
3714
|
+
// Soundness (#477): a fn referenced as a first-class VALUE (a `serve` handler / stored callback)
|
|
3715
|
+
// is invoked via the boxed `Value::Function` path with arbitrary runtime Values, so — exactly as
|
|
3716
|
+
// in `param_infer_program` — its params must NOT get an S-0 `: number` shadow either. Without this
|
|
3717
|
+
// an UNUSED param (vacuously "numeric" under `pus_stmt`) would be stamped `: number` and the
|
|
3718
|
+
// escaping boxed closure would `panic!("expected number")` on the request object.
|
|
3719
|
+
let escaping = escaping_fn_names(&program.statements);
|
|
3683
3720
|
|
|
3684
3721
|
// ---- S-0 + S-A: per top-level function, find numeric params and a return shape.
|
|
3685
3722
|
for s in &program.statements {
|
|
@@ -3731,7 +3768,10 @@ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
|
|
|
3731
3768
|
}
|
|
3732
3769
|
}
|
|
3733
3770
|
}
|
|
3734
|
-
if !numeric_params.is_empty()
|
|
3771
|
+
if !numeric_params.is_empty()
|
|
3772
|
+
&& !nonnumeric.contains(name.as_ref())
|
|
3773
|
+
&& !escaping.contains(name.as_ref())
|
|
3774
|
+
{
|
|
3735
3775
|
analysis.fn_numeric_params.insert(name.to_string(), numeric_params);
|
|
3736
3776
|
}
|
|
3737
3777
|
// S-A: a single all-f64 object-literal return shape ⇒ a struct alias.
|
|
@@ -4677,6 +4717,54 @@ mod param_infer_tests {
|
|
|
4677
4717
|
let src = "fn g(x) { let r = x; return r + \"!\" }";
|
|
4678
4718
|
assert_eq!(inferred_param(src, "g", "x"), None);
|
|
4679
4719
|
}
|
|
4720
|
+
|
|
4721
|
+
/// Final annotation name (if any) for `fn <fn_name>`'s `<param>` after the FULL pipeline
|
|
4722
|
+
/// (`infer_program`: M4 `param_infer` + local/struct + S-0 aggregate).
|
|
4723
|
+
fn end_to_end_param_ann(src: &str, fn_name: &str, param: &str) -> Option<String> {
|
|
4724
|
+
let prog = infer_program(&parse(src).unwrap());
|
|
4725
|
+
for s in &prog.statements {
|
|
4726
|
+
if let Statement::FunDecl { name, params, .. } = s {
|
|
4727
|
+
if name.as_ref() == fn_name {
|
|
4728
|
+
for p in params {
|
|
4729
|
+
if let FunParam::Simple(tp) = p {
|
|
4730
|
+
if tp.name.as_ref() == param {
|
|
4731
|
+
return tp.type_ann.as_ref().map(|a| match a {
|
|
4732
|
+
TypeAnnotation::Simple(s, _) => s.to_string(),
|
|
4733
|
+
_ => "<complex>".to_string(),
|
|
4734
|
+
});
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
}
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
None
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4744
|
+
#[test]
|
|
4745
|
+
fn escaping_value_fn_param_stays_boxed_477() {
|
|
4746
|
+
// #477: `handler` is passed as a call ARGUMENT (escapes as a first-class value, exactly like a
|
|
4747
|
+
// `serve` handler). Its `req` param is UNUSED and the body is all-numeric — so BOTH the M4
|
|
4748
|
+
// `param_infer` and the S-0 aggregate paths would otherwise stamp it `: number`. Because the
|
|
4749
|
+
// fn is invoked via the boxed `Value::Function` path with arbitrary runtime Values, `req` MUST
|
|
4750
|
+
// stay dynamic across the WHOLE pipeline (else the boxed closure panics "expected number" on
|
|
4751
|
+
// the request object). Locks both promotion sites.
|
|
4752
|
+
let src = "fn handler(req) { let s = 200; return s } \
|
|
4753
|
+
fn callWith(f, a) { return f(a) } \
|
|
4754
|
+
console.log(callWith(handler, { method: \"GET\" }))";
|
|
4755
|
+
assert_eq!(end_to_end_param_ann(src, "handler", "req"), None);
|
|
4756
|
+
}
|
|
4757
|
+
|
|
4758
|
+
#[test]
|
|
4759
|
+
fn non_escaping_numeric_fn_keeps_native_fast_path_477() {
|
|
4760
|
+
// CONTROL (no over-broad regression): a fn only ever called DIRECTLY with numbers still gets
|
|
4761
|
+
// its `: number` native fast path — the #477 fix narrows promotion to escaping fns only.
|
|
4762
|
+
let src = "fn square(n) { return n * n } console.log(square(7))";
|
|
4763
|
+
assert_eq!(
|
|
4764
|
+
end_to_end_param_ann(src, "square", "n").as_deref(),
|
|
4765
|
+
Some("number")
|
|
4766
|
+
);
|
|
4767
|
+
}
|
|
4680
4768
|
}
|
|
4681
4769
|
|
|
4682
4770
|
#[cfg(test)]
|
|
@@ -320,6 +320,8 @@ impl Evaluator {
|
|
|
320
320
|
process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
|
|
321
321
|
process_obj.insert("exec".into(), Value::Native(natives::process_exec));
|
|
322
322
|
process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
|
|
323
|
+
process_obj.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
|
|
324
|
+
process_obj.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
|
|
323
325
|
let argv: Vec<Value> = tishlang_core::process_argv()
|
|
324
326
|
.into_iter()
|
|
325
327
|
.map(|s| Value::String(s.into()))
|
|
@@ -1412,6 +1414,8 @@ impl Evaluator {
|
|
|
1412
1414
|
exports.insert("cwd".into(), Value::Native(natives::process_cwd));
|
|
1413
1415
|
exports.insert("exec".into(), Value::Native(natives::process_exec));
|
|
1414
1416
|
exports.insert("execFile".into(), Value::Native(natives::process_exec_file));
|
|
1417
|
+
exports.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
|
|
1418
|
+
exports.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
|
|
1415
1419
|
let argv: Vec<Value> = tishlang_core::process_argv()
|
|
1416
1420
|
.into_iter()
|
|
1417
1421
|
.map(|s| Value::String(s.into()))
|
|
@@ -1432,6 +1436,8 @@ impl Evaluator {
|
|
|
1432
1436
|
process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
|
|
1433
1437
|
process_obj.insert("exec".into(), Value::Native(natives::process_exec));
|
|
1434
1438
|
process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
|
|
1439
|
+
process_obj.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
|
|
1440
|
+
process_obj.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
|
|
1435
1441
|
process_obj.insert("argv".into(), Value::Array(Rc::new(RefCell::new(argv))));
|
|
1436
1442
|
process_obj.insert("env".into(), Value::object(env_obj));
|
|
1437
1443
|
exports.insert(
|
|
@@ -523,6 +523,60 @@ pub fn process_exec_file(args: &[Value]) -> Result<Value, String> {
|
|
|
523
523
|
Ok(Value::Number(output.status.code().unwrap_or(1) as f64))
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
/// `{ code, stdout, stderr }` capture object (#475) — lossy-UTF-8 streams (fs convention). Insertion
|
|
527
|
+
/// order (code, stdout, stderr) matches the vm/native builders so JSON.stringify agrees cross-backend.
|
|
528
|
+
#[cfg(feature = "process")]
|
|
529
|
+
fn process_capture_obj(code: i32, stdout: String, stderr: String) -> Value {
|
|
530
|
+
let mut m = crate::value::PropMap::default();
|
|
531
|
+
m.insert("code".into(), Value::Number(code as f64));
|
|
532
|
+
m.insert("stdout".into(), Value::String(stdout.into()));
|
|
533
|
+
m.insert("stderr".into(), Value::String(stderr.into()));
|
|
534
|
+
Value::object(m)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#[cfg(feature = "process")]
|
|
538
|
+
fn process_capture_result(out: std::io::Result<std::process::Output>) -> Value {
|
|
539
|
+
match out {
|
|
540
|
+
Ok(o) => process_capture_obj(
|
|
541
|
+
o.status.code().unwrap_or(-1),
|
|
542
|
+
String::from_utf8_lossy(&o.stdout).into_owned(),
|
|
543
|
+
String::from_utf8_lossy(&o.stderr).into_owned(),
|
|
544
|
+
),
|
|
545
|
+
Err(e) => process_capture_obj(-1, String::new(), e.to_string()),
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/// `process.execCapture(cmd)` — `sh -c cmd`, capturing `{ code, stdout, stderr }` (#475).
|
|
550
|
+
#[cfg(feature = "process")]
|
|
551
|
+
pub fn process_exec_capture(args: &[Value]) -> Result<Value, String> {
|
|
552
|
+
use std::process::Command;
|
|
553
|
+
let cmd = args.first().map(|v| v.to_string()).unwrap_or_default();
|
|
554
|
+
if cmd.is_empty() {
|
|
555
|
+
return Ok(process_capture_obj(0, String::new(), String::new()));
|
|
556
|
+
}
|
|
557
|
+
Ok(process_capture_result(
|
|
558
|
+
Command::new("sh").arg("-c").arg(&cmd).output(),
|
|
559
|
+
))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/// `process.execFileCapture(program, [args])` — no-shell run with output capture (safe for untrusted
|
|
563
|
+
/// args, #384/#475). Returns `{ code, stdout, stderr }`.
|
|
564
|
+
#[cfg(feature = "process")]
|
|
565
|
+
pub fn process_exec_file_capture(args: &[Value]) -> Result<Value, String> {
|
|
566
|
+
use std::process::Command;
|
|
567
|
+
let program = args.first().map(|v| v.to_string()).unwrap_or_default();
|
|
568
|
+
if program.is_empty() {
|
|
569
|
+
return Ok(process_capture_obj(0, String::new(), String::new()));
|
|
570
|
+
}
|
|
571
|
+
let argv: Vec<String> = match args.get(1) {
|
|
572
|
+
Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_string()).collect(),
|
|
573
|
+
_ => Vec::new(),
|
|
574
|
+
};
|
|
575
|
+
Ok(process_capture_result(
|
|
576
|
+
Command::new(&program).args(&argv).output(),
|
|
577
|
+
))
|
|
578
|
+
}
|
|
579
|
+
|
|
526
580
|
#[cfg(feature = "fs")]
|
|
527
581
|
pub fn read_file(args: &[Value]) -> Result<Value, String> {
|
|
528
582
|
let path = args.first().map(|v| v.to_string()).unwrap_or_default();
|
|
@@ -1049,9 +1049,75 @@ pub fn process_exec_file(args: &[Value]) -> Value {
|
|
|
1049
1049
|
}
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
1052
|
+
/// Build the `{ code: number, stdout: string, stderr: string }` capture object (#475). stdout/stderr
|
|
1053
|
+
/// are lossy-UTF-8 decoded, matching the `fs` string convention (invalid bytes → replacement chars,
|
|
1054
|
+
/// never dropped output).
|
|
1055
|
+
#[cfg(feature = "process")]
|
|
1056
|
+
fn process_capture_obj(code: i32, stdout: String, stderr: String) -> Value {
|
|
1057
|
+
let mut m = tishlang_core::ObjectMap::default();
|
|
1058
|
+
m.insert("code".into(), Value::Number(code as f64));
|
|
1059
|
+
m.insert("stdout".into(), Value::String(stdout.into()));
|
|
1060
|
+
m.insert("stderr".into(), Value::String(stderr.into()));
|
|
1061
|
+
Value::object(m)
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/// Build the capture object from a `Command::output()` result. `code` is the exit status, or `-1`
|
|
1065
|
+
/// when the process was killed by a signal or failed to spawn (spawn failure puts the OS error in
|
|
1066
|
+
/// `stderr`).
|
|
1067
|
+
#[cfg(feature = "process")]
|
|
1068
|
+
fn process_capture_result(out: std::io::Result<std::process::Output>) -> Value {
|
|
1069
|
+
match out {
|
|
1070
|
+
Ok(o) => process_capture_obj(
|
|
1071
|
+
o.status.code().unwrap_or(-1),
|
|
1072
|
+
String::from_utf8_lossy(&o.stdout).into_owned(),
|
|
1073
|
+
String::from_utf8_lossy(&o.stderr).into_owned(),
|
|
1074
|
+
),
|
|
1075
|
+
Err(e) => process_capture_obj(-1, String::new(), e.to_string()),
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/// `process.execCapture(cmd)` — run `sh -c cmd` and CAPTURE its output as
|
|
1080
|
+
/// `{ code: number, stdout: string, stderr: string }` (Rust `Command::output`; Node
|
|
1081
|
+
/// `execSync`/`spawnSync({ encoding })`). Use `execFileCapture` for untrusted arguments — this shell
|
|
1082
|
+
/// form interprets metacharacters.
|
|
1083
|
+
#[cfg(feature = "process")]
|
|
1084
|
+
pub fn process_exec_capture(args: &[Value]) -> Value {
|
|
1085
|
+
use std::process::Command;
|
|
1086
|
+
let cmd = args
|
|
1087
|
+
.first()
|
|
1088
|
+
.map(|v| v.to_display_string())
|
|
1089
|
+
.unwrap_or_default();
|
|
1090
|
+
if cmd.is_empty() {
|
|
1091
|
+
return process_capture_obj(0, String::new(), String::new());
|
|
1092
|
+
}
|
|
1093
|
+
process_capture_result(Command::new("sh").arg("-c").arg(&cmd).output())
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/// `process.execFileCapture(program, [args])` — run a program directly, WITHOUT a shell, capturing
|
|
1097
|
+
/// output as `{ code, stdout, stderr }`. Arguments are passed verbatim, so shell metacharacters in
|
|
1098
|
+
/// untrusted data are never interpreted — the safe capturing counterpart for input-derived args
|
|
1099
|
+
/// (#384, #475). This is the primitive for shelling out to a CLI and reading its output (e.g.
|
|
1100
|
+
/// `git status --porcelain`).
|
|
1101
|
+
#[cfg(feature = "process")]
|
|
1102
|
+
pub fn process_exec_file_capture(args: &[Value]) -> Value {
|
|
1103
|
+
use std::process::Command;
|
|
1104
|
+
let program = args
|
|
1105
|
+
.first()
|
|
1106
|
+
.map(|v| v.to_display_string())
|
|
1107
|
+
.unwrap_or_default();
|
|
1108
|
+
if program.is_empty() {
|
|
1109
|
+
return process_capture_obj(0, String::new(), String::new());
|
|
1110
|
+
}
|
|
1111
|
+
let argv: Vec<String> = match args.get(1) {
|
|
1112
|
+
Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_display_string()).collect(),
|
|
1113
|
+
_ => Vec::new(),
|
|
1114
|
+
};
|
|
1115
|
+
process_capture_result(Command::new(&program).args(&argv).output())
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1052
1118
|
#[cfg(all(test, feature = "process", unix))]
|
|
1053
1119
|
mod execfile_tests_384 {
|
|
1054
|
-
use super::process_exec_file;
|
|
1120
|
+
use super::{process_exec_file, process_exec_file_capture};
|
|
1055
1121
|
use tishlang_core::{Value, VmRef};
|
|
1056
1122
|
|
|
1057
1123
|
#[test]
|
|
@@ -1065,6 +1131,47 @@ mod execfile_tests_384 {
|
|
|
1065
1131
|
// empty program is a no-op returning 0, matching `exec`.
|
|
1066
1132
|
assert!(matches!(process_exec_file(&[]), Value::Number(n) if n == 0.0));
|
|
1067
1133
|
}
|
|
1134
|
+
|
|
1135
|
+
// #475: `{ code, stdout, stderr }` capture.
|
|
1136
|
+
fn fields(v: &Value) -> (f64, String, String) {
|
|
1137
|
+
let Value::Object(o) = v else {
|
|
1138
|
+
panic!("execFileCapture must return an object, got {v:?}")
|
|
1139
|
+
};
|
|
1140
|
+
let b = o.borrow();
|
|
1141
|
+
let num = |k: &str| match b.strings.get(k) {
|
|
1142
|
+
Some(Value::Number(n)) => *n,
|
|
1143
|
+
_ => f64::NAN,
|
|
1144
|
+
};
|
|
1145
|
+
let string = |k: &str| match b.strings.get(k) {
|
|
1146
|
+
Some(Value::String(s)) => s.to_string(),
|
|
1147
|
+
_ => String::new(),
|
|
1148
|
+
};
|
|
1149
|
+
(num("code"), string("stdout"), string("stderr"))
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
#[test]
|
|
1153
|
+
fn execfile_capture_returns_code_stdout_stderr() {
|
|
1154
|
+
// Success: stdout captured, code 0, stderr empty.
|
|
1155
|
+
let args = Value::Array(VmRef::new(vec![Value::String("captured".into())]));
|
|
1156
|
+
let (code, out, err) =
|
|
1157
|
+
fields(&process_exec_file_capture(&[Value::String("echo".into()), args]));
|
|
1158
|
+
assert_eq!(code, 0.0);
|
|
1159
|
+
assert_eq!(out.trim(), "captured");
|
|
1160
|
+
assert_eq!(err, "");
|
|
1161
|
+
// Non-zero exit is reported, not an error.
|
|
1162
|
+
let (code, _, _) = fields(&process_exec_file_capture(&[Value::String("false".into())]));
|
|
1163
|
+
assert_eq!(code, 1.0);
|
|
1164
|
+
// Spawn failure → code -1 with the OS error in stderr (never panics).
|
|
1165
|
+
let (code, out, err) =
|
|
1166
|
+
fields(&process_exec_file_capture(&[Value::String("/no/such/prog/xyz".into())]));
|
|
1167
|
+
assert_eq!(code, -1.0);
|
|
1168
|
+
assert!(out.is_empty());
|
|
1169
|
+
assert!(!err.is_empty());
|
|
1170
|
+
// Empty program → 0 / empty (mirrors execFile).
|
|
1171
|
+
let (code, out, err) = fields(&process_exec_file_capture(&[]));
|
|
1172
|
+
assert_eq!(code, 0.0);
|
|
1173
|
+
assert!(out.is_empty() && err.is_empty());
|
|
1174
|
+
}
|
|
1068
1175
|
}
|
|
1069
1176
|
|
|
1070
1177
|
#[cfg(feature = "fs")]
|
package/crates/tish_vm/src/vm.rs
CHANGED
|
@@ -337,6 +337,12 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
|
|
|
337
337
|
"execFile" => Some(Value::native(|args: &[Value]| {
|
|
338
338
|
tishlang_runtime::process_exec_file(args)
|
|
339
339
|
})),
|
|
340
|
+
"execCapture" => Some(Value::native(|args: &[Value]| {
|
|
341
|
+
tishlang_runtime::process_exec_capture(args)
|
|
342
|
+
})),
|
|
343
|
+
"execFileCapture" => Some(Value::native(|args: &[Value]| {
|
|
344
|
+
tishlang_runtime::process_exec_file_capture(args)
|
|
345
|
+
})),
|
|
340
346
|
"argv" => Some(Value::Array(VmRef::new(
|
|
341
347
|
tishlang_core::process_argv()
|
|
342
348
|
.into_iter()
|
|
@@ -366,6 +372,16 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
|
|
|
366
372
|
"execFile".into(),
|
|
367
373
|
Value::native(|args: &[Value]| tishlang_runtime::process_exec_file(args)),
|
|
368
374
|
);
|
|
375
|
+
m.insert(
|
|
376
|
+
"execCapture".into(),
|
|
377
|
+
Value::native(|args: &[Value]| tishlang_runtime::process_exec_capture(args)),
|
|
378
|
+
);
|
|
379
|
+
m.insert(
|
|
380
|
+
"execFileCapture".into(),
|
|
381
|
+
Value::native(|args: &[Value]| {
|
|
382
|
+
tishlang_runtime::process_exec_file_capture(args)
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
369
385
|
m.insert(
|
|
370
386
|
"argv".into(),
|
|
371
387
|
Value::Array(VmRef::new(
|
|
@@ -959,6 +975,14 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
|
|
|
959
975
|
"execFile".into(),
|
|
960
976
|
Value::native(|args: &[Value]| tishlang_runtime::process_exec_file(args)),
|
|
961
977
|
);
|
|
978
|
+
process_obj.insert(
|
|
979
|
+
"execCapture".into(),
|
|
980
|
+
Value::native(|args: &[Value]| tishlang_runtime::process_exec_capture(args)),
|
|
981
|
+
);
|
|
982
|
+
process_obj.insert(
|
|
983
|
+
"execFileCapture".into(),
|
|
984
|
+
Value::native(|args: &[Value]| tishlang_runtime::process_exec_file_capture(args)),
|
|
985
|
+
);
|
|
962
986
|
process_obj.insert(
|
|
963
987
|
"argv".into(),
|
|
964
988
|
Value::Array(VmRef::new(
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|