@tishlang/tish-format 2.43.8 → 3.1.0
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-format +0 -0
- package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
- package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
- package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
- package/crates/tish/tests/regr_typed_externs.rs +59 -0
- package/crates/tish/tests/scope_merge.rs +31 -0
- package/crates/tish_build_utils/src/lib.rs +131 -6
- package/crates/tish_builtins/src/globals.rs +27 -0
- package/crates/tish_compile/src/codegen.rs +1498 -132
- package/crates/tish_compile/src/resolve.rs +65 -9
- package/crates/tish_compile/src/schemes.rs +60 -5
- package/crates/tish_compile/src/types.rs +64 -32
- package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
- package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
- package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
- package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
- package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
- package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
- package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
- package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
- package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
- package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
- package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
- package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
- package/crates/tish_core/src/console_style.rs +4 -0
- package/crates/tish_core/src/json.rs +8 -0
- package/crates/tish_core/src/shape.rs +28 -3
- package/crates/tish_core/src/value.rs +123 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_native/src/build.rs +4 -0
- package/crates/tish_runtime/Cargo.toml +20 -1
- package/crates/tish_runtime/src/crypto.rs +127 -0
- package/crates/tish_runtime/src/encoding.rs +157 -0
- package/crates/tish_runtime/src/fs_ext.rs +27 -0
- package/crates/tish_runtime/src/lib.rs +33 -0
- package/crates/tish_runtime/src/net.rs +482 -0
- package/crates/tish_runtime/src/process_spawn.rs +508 -0
- package/crates/tish_runtime/src/ws.rs +206 -23
- package/crates/tish_runtime/src/zip.rs +196 -0
- package/crates/tish_runtime_gba/src/lib.rs +188 -4
- package/crates/tish_vm/src/vm.rs +48 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
|
@@ -165,6 +165,35 @@ pub trait TishOpaque {
|
|
|
165
165
|
fn as_any(&self) -> &dyn core::any::Any;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/// A typed native struct (from an `interface` / typed object literal in native codegen) carried inside
|
|
169
|
+
/// a [`Value::Struct`] WITHOUT serialising to a hashmap object. Each generated `TishStruct_*` implements
|
|
170
|
+
/// it: a boxed function that returns/passes a typed struct keeps it by reference, property access
|
|
171
|
+
/// dispatches here (a small `match` on the field name — no string hash, no allocation), and it
|
|
172
|
+
/// materialises to a real `Value::Object` only when a boxed `Value::Struct` (a struct whose static
|
|
173
|
+
/// type was erased) crosses into genuinely dynamic territory (`JSON.stringify`, display,
|
|
174
|
+
/// `Object.keys`/`values`/`entries`, `for..in`, `in`, `Object.assign`, object-literal spread `{...s}`).
|
|
175
|
+
/// This is the RUNTIME fallback: when the struct's static type IS known at a reflection site, GBA
|
|
176
|
+
/// codegen lowers `Object.keys`/`values`/`entries`/`in`/`{...s}` to native field reads instead — the
|
|
177
|
+
/// struct is never boxed and this materialisation never runs. No `Send + Sync` bound: the `Value::Struct` variant is
|
|
178
|
+
/// `cfg(portable)` and `portable` excludes `send-values` (they are mutually exclusive), so the ref is
|
|
179
|
+
/// single-threaded `Rc` — mirroring the single-threaded [`TishOpaque`].
|
|
180
|
+
pub trait TishStruct {
|
|
181
|
+
/// Read a field as a boxed `Value` (`Null` if `key` is not a field). Backs `get_prop`.
|
|
182
|
+
fn tish_get(&self, key: &str) -> Value;
|
|
183
|
+
/// Write a field from a boxed `Value` (no-op if `key` is not a field). Backs `set_prop`.
|
|
184
|
+
fn tish_set(&mut self, key: &str, value: Value);
|
|
185
|
+
/// Materialise an ordered `Value::Object` copy (field-declaration order, JS insertion order). Used
|
|
186
|
+
/// for JSON / display / `Object`-interop, and as the non-portable `Value::from_struct` fallback so
|
|
187
|
+
/// off-GBA backends behave EXACTLY as the historical `object_from_pairs` boundary did.
|
|
188
|
+
fn tish_to_object(&self) -> Value;
|
|
189
|
+
/// `typeof` result — always "object".
|
|
190
|
+
fn tish_type_name(&self) -> &'static str {
|
|
191
|
+
"object"
|
|
192
|
+
}
|
|
193
|
+
/// Downcast hook — recover the concrete `TishStruct_*` (e.g. for a future typed fast path).
|
|
194
|
+
fn as_any(&self) -> &dyn core::any::Any;
|
|
195
|
+
}
|
|
196
|
+
|
|
168
197
|
/// Trait for Promise-like values that can be awaited (block until settled).
|
|
169
198
|
/// Implemented by the runtime for native compile; interpreter uses its own Promise.
|
|
170
199
|
pub trait TishPromise: Send + Sync {
|
|
@@ -467,8 +496,24 @@ pub enum Value {
|
|
|
467
496
|
Promise(Arc<dyn TishPromise>),
|
|
468
497
|
/// Opaque handle to a native Rust type (e.g. Polars DataFrame).
|
|
469
498
|
Opaque(Arc<dyn TishOpaque>),
|
|
499
|
+
/// A typed native struct carried BY REFERENCE (portable / GBA native codegen). Lets a boxed
|
|
500
|
+
/// function return or pass a typed struct without serialising it to a hashmap `Object` —
|
|
501
|
+
/// property access dispatches through [`TishStruct`] (a small match, no hash lookup / alloc), and
|
|
502
|
+
/// it materialises to an `Object` only on JSON / display / `Object.keys`-family / `for..in` /
|
|
503
|
+
/// `in` / `Object.assign` / `{...s}` spread. Built via
|
|
504
|
+
/// [`Value::from_struct`]. Portable-only: the ref is a single-threaded `Rc<RefCell<dyn …>>` and
|
|
505
|
+
/// `portable` excludes `send-values`, so this never violates `Send` (which off-portable `Value`
|
|
506
|
+
/// may need). A fat pointer is 16 B, so the 24 B `Value` size guard still holds.
|
|
507
|
+
#[cfg(feature = "portable")]
|
|
508
|
+
Struct(StructRef),
|
|
470
509
|
}
|
|
471
510
|
|
|
511
|
+
/// Reference to a boxed typed struct: `Rc<RefCell<dyn TishStruct>>`. Portable-only (see [`TishStruct`]
|
|
512
|
+
/// / [`Value::Struct`]). `RefCell` is OUTSIDE the trait object so `set_prop` can borrow it mutably; the
|
|
513
|
+
/// `Rc<RefCell<Concrete>> → Rc<RefCell<dyn TishStruct>>` unsizing is a stable coercion (no nightly).
|
|
514
|
+
#[cfg(feature = "portable")]
|
|
515
|
+
pub type StructRef = alloc::rc::Rc<core::cell::RefCell<dyn TishStruct>>;
|
|
516
|
+
|
|
472
517
|
// Size guard. `Value` is 24 bytes: `String` is thin (`ArcStr`, 8B), but `Function`/`Promise`/`Opaque`
|
|
473
518
|
// are fat `Arc<dyn …>` (data+vtable, 16B) ⇒ 16B payload + discriminant = 24.
|
|
474
519
|
//
|
|
@@ -513,6 +558,8 @@ pub enum ValueTag {
|
|
|
513
558
|
RegExp,
|
|
514
559
|
Promise,
|
|
515
560
|
Opaque,
|
|
561
|
+
#[cfg(feature = "portable")]
|
|
562
|
+
Struct,
|
|
516
563
|
}
|
|
517
564
|
|
|
518
565
|
/// Borrowed view of a [`Value`]'s payload, mirroring the enum variants. Lets call sites
|
|
@@ -533,6 +580,8 @@ pub enum ValueRef<'a> {
|
|
|
533
580
|
RegExp(&'a VmRef<TishRegExp>),
|
|
534
581
|
Promise(&'a Arc<dyn TishPromise>),
|
|
535
582
|
Opaque(&'a Arc<dyn TishOpaque>),
|
|
583
|
+
#[cfg(feature = "portable")]
|
|
584
|
+
Struct(&'a StructRef),
|
|
536
585
|
}
|
|
537
586
|
|
|
538
587
|
impl Value {
|
|
@@ -578,6 +627,8 @@ impl Value {
|
|
|
578
627
|
Value::RegExp(r) => ValueRef::RegExp(r),
|
|
579
628
|
Value::Promise(p) => ValueRef::Promise(p),
|
|
580
629
|
Value::Opaque(o) => ValueRef::Opaque(o),
|
|
630
|
+
#[cfg(feature = "portable")]
|
|
631
|
+
Value::Struct(s) => ValueRef::Struct(s),
|
|
581
632
|
}
|
|
582
633
|
}
|
|
583
634
|
|
|
@@ -598,6 +649,8 @@ impl Value {
|
|
|
598
649
|
Value::RegExp(_) => ValueTag::RegExp,
|
|
599
650
|
Value::Promise(_) => ValueTag::Promise,
|
|
600
651
|
Value::Opaque(_) => ValueTag::Opaque,
|
|
652
|
+
#[cfg(feature = "portable")]
|
|
653
|
+
Value::Struct(_) => ValueTag::Struct,
|
|
601
654
|
}
|
|
602
655
|
}
|
|
603
656
|
|
|
@@ -1256,6 +1309,8 @@ impl core::fmt::Debug for Value {
|
|
|
1256
1309
|
),
|
|
1257
1310
|
Value::Promise(_) => write!(f, "Promise"),
|
|
1258
1311
|
Value::Opaque(o) => write!(f, "{}(opaque)", o.type_name()),
|
|
1312
|
+
#[cfg(feature = "portable")]
|
|
1313
|
+
Value::Struct(s) => write!(f, "Struct({})", s.borrow().tish_type_name()),
|
|
1259
1314
|
}
|
|
1260
1315
|
}
|
|
1261
1316
|
}
|
|
@@ -1466,6 +1521,9 @@ impl Value {
|
|
|
1466
1521
|
Value::Function(_) => "[Function]".to_string(),
|
|
1467
1522
|
Value::Promise(_) => "[object Promise]".to_string(),
|
|
1468
1523
|
Value::Opaque(o) => format!("[object {}]", o.type_name()),
|
|
1524
|
+
// Display a boxed struct like the object it is (materialise once, then reuse the Object path).
|
|
1525
|
+
#[cfg(feature = "portable")]
|
|
1526
|
+
Value::Struct(s) => s.borrow().tish_to_object().to_display_string_guarded(ancestors),
|
|
1469
1527
|
#[cfg(feature = "regex")]
|
|
1470
1528
|
Value::RegExp(re) => {
|
|
1471
1529
|
let re = re.borrow();
|
|
@@ -1517,6 +1575,8 @@ impl Value {
|
|
|
1517
1575
|
.collect::<Vec<_>>()
|
|
1518
1576
|
.join(","),
|
|
1519
1577
|
Value::Object(_) => "[object Object]".to_string(),
|
|
1578
|
+
#[cfg(feature = "portable")]
|
|
1579
|
+
Value::Struct(_) => "[object Object]".to_string(),
|
|
1520
1580
|
// ECMAScript ToString of a number (drops `-0`'s sign), distinct from the inspect form
|
|
1521
1581
|
// that `to_display_string` would give for `-0`. (#247)
|
|
1522
1582
|
Value::Number(n) => js_number_to_string(*n),
|
|
@@ -1561,6 +1621,10 @@ impl Value {
|
|
|
1561
1621
|
(Value::Promise(a), Value::Promise(b)) => Arc::ptr_eq(a, b),
|
|
1562
1622
|
(Value::Opaque(a), Value::Opaque(b)) => Arc::ptr_eq(a, b),
|
|
1563
1623
|
(Value::Symbol(a), Value::Symbol(b)) => Arc::ptr_eq(a, b),
|
|
1624
|
+
// Struct-Value identity is `Rc` pointer equality, exactly like `Object` — two distinct
|
|
1625
|
+
// boxings compare unequal, but a struct aliased through one `Value` is `=== ` itself.
|
|
1626
|
+
#[cfg(feature = "portable")]
|
|
1627
|
+
(Value::Struct(a), Value::Struct(b)) => alloc::rc::Rc::ptr_eq(a, b),
|
|
1564
1628
|
_ => false,
|
|
1565
1629
|
}
|
|
1566
1630
|
}
|
|
@@ -1613,6 +1677,63 @@ impl Value {
|
|
|
1613
1677
|
}))
|
|
1614
1678
|
}
|
|
1615
1679
|
|
|
1680
|
+
/// Box a typed native struct (a generated `TishStruct_*`). On portable (GBA) it is kept BY
|
|
1681
|
+
/// REFERENCE as a cheap [`Value::Struct`] — one `Rc` allocation, no per-field string keys / hashmap;
|
|
1682
|
+
/// property access later dispatches through [`TishStruct`]. On every other backend it serialises to
|
|
1683
|
+
/// an ordered `Value::Object` (via [`TishStruct::tish_to_object`]) — byte-identical to the historical
|
|
1684
|
+
/// `object_from_pairs` boundary, so off-GBA behaviour is unchanged. Codegen's struct-to-`Value`
|
|
1685
|
+
/// boxing emits a single `Value::from_struct(..)` call for all native targets.
|
|
1686
|
+
#[cfg(feature = "portable")]
|
|
1687
|
+
#[inline]
|
|
1688
|
+
pub fn from_struct<T: TishStruct + 'static>(v: T) -> Value {
|
|
1689
|
+
Value::Struct(alloc::rc::Rc::new(core::cell::RefCell::new(v)))
|
|
1690
|
+
}
|
|
1691
|
+
/// See the portable variant above — off-GBA this is exactly the old `object_from_pairs` boundary.
|
|
1692
|
+
#[cfg(not(feature = "portable"))]
|
|
1693
|
+
#[inline]
|
|
1694
|
+
pub fn from_struct<T: TishStruct>(v: T) -> Value {
|
|
1695
|
+
v.tish_to_object()
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/// Box a typed native struct BY SHARED REFERENCE — the boxing path codegen actually emits, so the
|
|
1699
|
+
/// whole-struct clone is paid ONLY where it's genuinely needed. On portable (GBA) the struct must
|
|
1700
|
+
/// be OWNED by the `Rc`, so it clones (identical to `from_struct((v).clone())`). Off-GBA it
|
|
1701
|
+
/// serialises the borrow directly via [`TishStruct::tish_to_object`] with NO whole-struct clone —
|
|
1702
|
+
/// byte-identical to the historical `object_from_pairs` boundary that read fields straight off the
|
|
1703
|
+
/// place (#565: the old `from_struct((v).clone())` cloned the entire struct just to serialise it).
|
|
1704
|
+
#[cfg(feature = "portable")]
|
|
1705
|
+
#[inline]
|
|
1706
|
+
pub fn from_struct_ref<T: TishStruct + Clone + 'static>(v: &T) -> Value {
|
|
1707
|
+
Value::Struct(alloc::rc::Rc::new(core::cell::RefCell::new(v.clone())))
|
|
1708
|
+
}
|
|
1709
|
+
/// See the portable variant — off-GBA this serialises the borrow with no whole-struct clone.
|
|
1710
|
+
#[cfg(not(feature = "portable"))]
|
|
1711
|
+
#[inline]
|
|
1712
|
+
pub fn from_struct_ref<T: TishStruct>(v: &T) -> Value {
|
|
1713
|
+
v.tish_to_object()
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/// If this value is a by-reference boxed struct of concrete type `T` (a `Value::Struct`), return a
|
|
1717
|
+
/// CLONE of it — the native fast path for `let x: T = <boxed struct return>`, so subsequent field
|
|
1718
|
+
/// reads are plain Rust loads instead of `get_prop`. Portable only: off-GBA there is no `Value::Struct`
|
|
1719
|
+
/// variant, so this is always `None` and codegen's `from_value_expr` falls back to the field-by-field
|
|
1720
|
+
/// `get_prop` rebuild (which also handles a genuine `Value::Object`). Returns `None` for any non-struct
|
|
1721
|
+
/// value or a struct of a different concrete type.
|
|
1722
|
+
#[cfg(feature = "portable")]
|
|
1723
|
+
#[inline]
|
|
1724
|
+
pub fn downcast_struct<T: TishStruct + Clone + 'static>(&self) -> Option<T> {
|
|
1725
|
+
match self {
|
|
1726
|
+
Value::Struct(s) => s.borrow().as_any().downcast_ref::<T>().cloned(),
|
|
1727
|
+
_ => None,
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
/// See the portable variant — off-GBA there is no struct variant, so always `None`.
|
|
1731
|
+
#[cfg(not(feature = "portable"))]
|
|
1732
|
+
#[inline]
|
|
1733
|
+
pub fn downcast_struct<T: TishStruct + Clone + 'static>(&self) -> Option<T> {
|
|
1734
|
+
None
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1616
1737
|
/// Create an empty array Value.
|
|
1617
1738
|
pub fn empty_array() -> Self {
|
|
1618
1739
|
Value::Array(VmRef::new(Vec::new()))
|
|
@@ -1700,6 +1821,8 @@ impl Value {
|
|
|
1700
1821
|
Value::Promise(_) => "object",
|
|
1701
1822
|
Value::Opaque(o) => o.type_name(),
|
|
1702
1823
|
Value::Symbol(_) => "symbol",
|
|
1824
|
+
#[cfg(feature = "portable")]
|
|
1825
|
+
Value::Struct(_) => "object",
|
|
1703
1826
|
}
|
|
1704
1827
|
}
|
|
1705
1828
|
|
|
@@ -56,6 +56,15 @@ http-hyper = [
|
|
|
56
56
|
# `mkdtemp` needs a random suffix (predictable/timestamp names are a temp-dir security smell).
|
|
57
57
|
fs = ["promise", "dep:rand"]
|
|
58
58
|
process = []
|
|
59
|
+
# Raw TCP sockets: DAP TCP transports, OAuth loopback listener, connect probes.
|
|
60
|
+
net = []
|
|
61
|
+
# Base64 encode/decode (tish:encoding) — bytes <-> string, for file-bytes-over-JSON (extension host
|
|
62
|
+
# vscode.workspace.fs, broker_read_file_base64) and PKCE (URL-safe, no-pad).
|
|
63
|
+
encoding = ["dep:base64"]
|
|
64
|
+
# SHA-256 + secure random (tish:crypto) — PKCE code_challenge/verifier, checkpoint dir hashing.
|
|
65
|
+
crypto = ["dep:sha2", "dep:rand"]
|
|
66
|
+
# Zip extraction (tish:zip) — VSIX / archive extract with `..`-traversal guard + unix-mode preserved.
|
|
67
|
+
zip = ["dep:zip"]
|
|
59
68
|
# Interactive terminal I/O (issue #101): raw mode, key/resize events, size, alt screen.
|
|
60
69
|
tty = ["dep:crossterm"]
|
|
61
70
|
# Pseudoterminal hosting (issue #493): spawn a shell/program on a real PTY, read/write/resize/kill —
|
|
@@ -71,6 +80,7 @@ ws = [
|
|
|
71
80
|
"tokio/net",
|
|
72
81
|
"tokio/io-util",
|
|
73
82
|
"dep:tokio-tungstenite",
|
|
83
|
+
"dep:rustls",
|
|
74
84
|
"dep:futures-util",
|
|
75
85
|
"dep:lazy_static",
|
|
76
86
|
"send-values",
|
|
@@ -84,6 +94,9 @@ tishlang_builtins = { path = "../tish_builtins", version = ">=0.1" }
|
|
|
84
94
|
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "time", "sync"], optional = true }
|
|
85
95
|
reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "json", "stream", "multipart"], optional = true }
|
|
86
96
|
futures = { version = "0.3.32", optional = true }
|
|
97
|
+
base64 = { version = "0.22", optional = true }
|
|
98
|
+
sha2 = { version = "0.10", optional = true }
|
|
99
|
+
zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
|
|
87
100
|
bytes = { version = "1", optional = true }
|
|
88
101
|
tiny_http = { version = "0.12.0", optional = true }
|
|
89
102
|
# fs feature only: random suffix for `mkdtemp`.
|
|
@@ -100,7 +113,13 @@ hyper = { version = "1", default-features = false, features = ["server", "http1"
|
|
|
100
113
|
hyper-util = { version = "0.1", features = ["server", "tokio"], optional = true }
|
|
101
114
|
http-body-util = { version = "0.1", optional = true }
|
|
102
115
|
core_affinity = { version = "0.8", optional = true }
|
|
103
|
-
|
|
116
|
+
# rustls-tls-native-roots gives `wss://` support (system cert store, no OpenSSL — matches reqwest's
|
|
117
|
+
# rustls backend). Without a TLS feature, connect_async can only do plain `ws://` and rejects wss.
|
|
118
|
+
tokio-tungstenite = { version = "0.29", optional = true, features = ["rustls-tls-native-roots"] }
|
|
119
|
+
# Direct rustls handle so ws.rs can install a process-default CryptoProvider: both aws-lc-rs and ring
|
|
120
|
+
# are in the tree (reqwest + transitive), so rustls 0.23 can't auto-select one, and connect_async's
|
|
121
|
+
# wss path needs the process default. aws_lc_rs matches reqwest's provider.
|
|
122
|
+
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"], optional = true }
|
|
104
123
|
futures-util = { version = "0.3", optional = true }
|
|
105
124
|
lazy_static = { version = "1", optional = true }
|
|
106
125
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
//! Cryptographic hashing + secure random for Tish (`tish:crypto`), behind the `crypto` feature.
|
|
2
|
+
//!
|
|
3
|
+
//! import { sha256, sha256Hex, randomBytes } from 'tish:crypto'
|
|
4
|
+
//!
|
|
5
|
+
//! - `sha256(bytesOrString) -> number[]` (the 32-byte digest as a byte array)
|
|
6
|
+
//! - `sha256Hex(bytesOrString) -> string` (64 lowercase hex chars)
|
|
7
|
+
//! - `randomBytes(n) -> number[]` (n cryptographically-secure random bytes; n clamped to 1 MiB)
|
|
8
|
+
//!
|
|
9
|
+
//! Bytes are the `tish:fs` / `tish:encoding` representation (Numbers 0-255). A string argument is
|
|
10
|
+
//! hashed as its UTF-8 bytes. For PKCE: `base64UrlEncode(sha256(verifier))` (see `tish:encoding`).
|
|
11
|
+
|
|
12
|
+
use sha2::{Digest, Sha256};
|
|
13
|
+
use tishlang_core::{Value, VmRef};
|
|
14
|
+
|
|
15
|
+
/// Coerce arg 0 to bytes: a byte array (Numbers 0-255) or a string's UTF-8 bytes.
|
|
16
|
+
fn arg_bytes(args: &[Value]) -> Option<Vec<u8>> {
|
|
17
|
+
match args.first() {
|
|
18
|
+
Some(Value::Array(a)) => Some(
|
|
19
|
+
a.borrow()
|
|
20
|
+
.iter()
|
|
21
|
+
.map(|v| if let Value::Number(n) = v { *n as u8 } else { 0 })
|
|
22
|
+
.collect(),
|
|
23
|
+
),
|
|
24
|
+
Some(Value::String(s)) => Some(s.as_bytes().to_vec()),
|
|
25
|
+
_ => None,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fn bytes_to_value(bytes: &[u8]) -> Value {
|
|
30
|
+
Value::Array(VmRef::new(
|
|
31
|
+
bytes.iter().map(|b| Value::Number(*b as f64)).collect(),
|
|
32
|
+
))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn digest_of(args: &[Value]) -> Option<[u8; 32]> {
|
|
36
|
+
let b = arg_bytes(args)?;
|
|
37
|
+
let mut h = Sha256::new();
|
|
38
|
+
h.update(&b);
|
|
39
|
+
Some(h.finalize().into())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub fn sha256(args: &[Value]) -> Value {
|
|
43
|
+
match digest_of(args) {
|
|
44
|
+
Some(d) => bytes_to_value(&d),
|
|
45
|
+
None => Value::Null,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pub fn sha256_hex(args: &[Value]) -> Value {
|
|
50
|
+
match digest_of(args) {
|
|
51
|
+
Some(d) => {
|
|
52
|
+
let mut s = String::with_capacity(64);
|
|
53
|
+
for byte in d.iter() {
|
|
54
|
+
s.push_str(&format!("{byte:02x}"));
|
|
55
|
+
}
|
|
56
|
+
Value::String(s.into())
|
|
57
|
+
}
|
|
58
|
+
None => Value::Null,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pub fn random_bytes(args: &[Value]) -> Value {
|
|
63
|
+
let n = match args.first() {
|
|
64
|
+
Some(Value::Number(x)) if x.is_finite() && *x >= 0.0 => (*x as usize).min(1_048_576),
|
|
65
|
+
_ => return Value::Null,
|
|
66
|
+
};
|
|
67
|
+
// rand::random draws from the thread CSPRNG (ChaCha, OS-seeded) — same source fs_ext uses.
|
|
68
|
+
let bytes: Vec<u8> = (0..n).map(|_| rand::random::<u8>()).collect();
|
|
69
|
+
bytes_to_value(&bytes)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[cfg(test)]
|
|
73
|
+
mod tests {
|
|
74
|
+
use super::*;
|
|
75
|
+
|
|
76
|
+
fn to_bytes(v: Value) -> Vec<u8> {
|
|
77
|
+
let Value::Array(a) = v else { panic!("not an array") };
|
|
78
|
+
let g = a.borrow();
|
|
79
|
+
let out: Vec<u8> = g
|
|
80
|
+
.iter()
|
|
81
|
+
.map(|x| if let Value::Number(n) = x { *n as u8 } else { 0 })
|
|
82
|
+
.collect();
|
|
83
|
+
out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn sha256_hex_known_vector() {
|
|
88
|
+
// The canonical NIST vector: SHA-256("abc").
|
|
89
|
+
let Value::String(s) = sha256_hex(&[Value::String("abc".into())]) else { panic!() };
|
|
90
|
+
assert_eq!(
|
|
91
|
+
s.to_string(),
|
|
92
|
+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[test]
|
|
97
|
+
fn sha256_returns_32_bytes_matching_hex() {
|
|
98
|
+
let bytes = to_bytes(sha256(&[Value::String("abc".into())]));
|
|
99
|
+
assert_eq!(bytes.len(), 32);
|
|
100
|
+
assert_eq!(bytes[0], 0xba);
|
|
101
|
+
assert_eq!(bytes[31], 0xad);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn sha256_accepts_byte_array() {
|
|
106
|
+
// "abc" as a byte array must hash identically to the string.
|
|
107
|
+
let arr = Value::Array(VmRef::new(vec![
|
|
108
|
+
Value::Number(97.0),
|
|
109
|
+
Value::Number(98.0),
|
|
110
|
+
Value::Number(99.0),
|
|
111
|
+
]));
|
|
112
|
+
let Value::String(s) = sha256_hex(&[arr]) else { panic!() };
|
|
113
|
+
assert_eq!(
|
|
114
|
+
s.to_string(),
|
|
115
|
+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[test]
|
|
120
|
+
fn random_bytes_length_and_variance() {
|
|
121
|
+
assert_eq!(to_bytes(random_bytes(&[Value::Number(0.0)])).len(), 0);
|
|
122
|
+
let a = to_bytes(random_bytes(&[Value::Number(32.0)]));
|
|
123
|
+
let b = to_bytes(random_bytes(&[Value::Number(32.0)]));
|
|
124
|
+
assert_eq!(a.len(), 32);
|
|
125
|
+
assert_ne!(a, b, "two 32-byte draws must (almost surely) differ");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//! Base64 encoding for Tish (`tish:encoding`), behind the `encoding` feature.
|
|
2
|
+
//!
|
|
3
|
+
//! import { base64Encode, base64Decode, base64UrlEncode, base64UrlDecode } from 'tish:encoding'
|
|
4
|
+
//!
|
|
5
|
+
//! - `base64Encode(bytesOrString) -> string` (standard alphabet, padded)
|
|
6
|
+
//! - `base64Decode(string) -> number[] | null` (bytes 0-255; null on invalid input)
|
|
7
|
+
//! - `base64UrlEncode(bytesOrString) -> string` (URL-safe alphabet, NO padding — PKCE etc.)
|
|
8
|
+
//! - `base64UrlDecode(string) -> number[] | null`
|
|
9
|
+
//!
|
|
10
|
+
//! Bytes are the same representation `tish:fs` uses: an array of Numbers 0-255 (`readFileBytes`
|
|
11
|
+
//! returns this; `writeFile` accepts it). A string argument is encoded as its UTF-8 bytes, so
|
|
12
|
+
//! `base64Encode("hi")` works without a byte round-trip.
|
|
13
|
+
|
|
14
|
+
use base64::Engine;
|
|
15
|
+
use tishlang_core::{Value, VmRef};
|
|
16
|
+
|
|
17
|
+
/// Coerce arg 0 to bytes: a byte array (Numbers 0-255) or a string's UTF-8 bytes.
|
|
18
|
+
fn arg_bytes(args: &[Value]) -> Option<Vec<u8>> {
|
|
19
|
+
match args.first() {
|
|
20
|
+
Some(Value::Array(a)) => Some(
|
|
21
|
+
a.borrow()
|
|
22
|
+
.iter()
|
|
23
|
+
.map(|v| if let Value::Number(n) = v { *n as u8 } else { 0 })
|
|
24
|
+
.collect(),
|
|
25
|
+
),
|
|
26
|
+
Some(Value::String(s)) => Some(s.as_bytes().to_vec()),
|
|
27
|
+
_ => None,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fn bytes_to_value(bytes: Vec<u8>) -> Value {
|
|
32
|
+
Value::Array(VmRef::new(
|
|
33
|
+
bytes.into_iter().map(|b| Value::Number(b as f64)).collect(),
|
|
34
|
+
))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub fn base64_encode(args: &[Value]) -> Value {
|
|
38
|
+
match arg_bytes(args) {
|
|
39
|
+
Some(b) => Value::String(base64::engine::general_purpose::STANDARD.encode(&b).into()),
|
|
40
|
+
None => Value::Null,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pub fn base64_decode(args: &[Value]) -> Value {
|
|
45
|
+
let s = match args.first() {
|
|
46
|
+
Some(Value::String(s)) => s.to_string(),
|
|
47
|
+
_ => return Value::Null,
|
|
48
|
+
};
|
|
49
|
+
match base64::engine::general_purpose::STANDARD.decode(s.as_bytes()) {
|
|
50
|
+
Ok(b) => bytes_to_value(b),
|
|
51
|
+
Err(_) => Value::Null,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pub fn base64_url_encode(args: &[Value]) -> Value {
|
|
56
|
+
match arg_bytes(args) {
|
|
57
|
+
Some(b) => Value::String(
|
|
58
|
+
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
59
|
+
.encode(&b)
|
|
60
|
+
.into(),
|
|
61
|
+
),
|
|
62
|
+
None => Value::Null,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
pub fn base64_url_decode(args: &[Value]) -> Value {
|
|
67
|
+
let s = match args.first() {
|
|
68
|
+
Some(Value::String(s)) => s.to_string(),
|
|
69
|
+
_ => return Value::Null,
|
|
70
|
+
};
|
|
71
|
+
match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s.as_bytes()) {
|
|
72
|
+
Ok(b) => bytes_to_value(b),
|
|
73
|
+
Err(_) => Value::Null,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// `utf8Decode(byteArray) -> string` — decode bytes as UTF-8 (lossy: invalid sequences become the
|
|
78
|
+
/// replacement char). The counterpart to `readFileBytes` / a streaming `reader.read()` chunk, whose
|
|
79
|
+
/// `value` is a byte array. Split streamed bytes on a newline BEFORE decoding a line so a multi-byte
|
|
80
|
+
/// sequence is never cut mid-decode (a `\n`=0x0A byte never occurs inside a UTF-8 multi-byte char).
|
|
81
|
+
pub fn utf8_decode(args: &[Value]) -> Value {
|
|
82
|
+
match arg_bytes(args) {
|
|
83
|
+
Some(b) => Value::String(String::from_utf8_lossy(&b).into_owned().into()),
|
|
84
|
+
None => Value::Null,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// `utf8Encode(string) -> number[]` — the string's UTF-8 bytes as a byte array. (A byte-array
|
|
89
|
+
/// argument is passed through, so `utf8Encode(utf8Decode(x))` is a no-op round trip.)
|
|
90
|
+
pub fn utf8_encode(args: &[Value]) -> Value {
|
|
91
|
+
match arg_bytes(args) {
|
|
92
|
+
Some(b) => bytes_to_value(b),
|
|
93
|
+
None => Value::Null,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[cfg(test)]
|
|
98
|
+
mod tests {
|
|
99
|
+
use super::*;
|
|
100
|
+
|
|
101
|
+
fn to_bytes(v: Value) -> Vec<u8> {
|
|
102
|
+
let Value::Array(a) = v else { panic!("not an array") };
|
|
103
|
+
let g = a.borrow();
|
|
104
|
+
let out: Vec<u8> = g
|
|
105
|
+
.iter()
|
|
106
|
+
.map(|x| if let Value::Number(n) = x { *n as u8 } else { 0 })
|
|
107
|
+
.collect();
|
|
108
|
+
out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[test]
|
|
112
|
+
fn encode_string_matches_known_vector() {
|
|
113
|
+
let enc = base64_encode(&[Value::String("hello world".into())]);
|
|
114
|
+
let Value::String(s) = &enc else { panic!() };
|
|
115
|
+
assert_eq!(s.to_string(), "aGVsbG8gd29ybGQ=");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#[test]
|
|
119
|
+
fn roundtrip_all_byte_values() {
|
|
120
|
+
let arr = Value::Array(VmRef::new(
|
|
121
|
+
(0u8..=255).map(|b| Value::Number(b as f64)).collect(),
|
|
122
|
+
));
|
|
123
|
+
let enc = base64_encode(&[arr]);
|
|
124
|
+
assert_eq!(to_bytes(base64_decode(&[enc])), (0u8..=255).collect::<Vec<u8>>());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#[test]
|
|
128
|
+
fn url_safe_has_no_pad_plus_or_slash() {
|
|
129
|
+
// 0xFB,0xFF,0xBF encodes to +/ in standard; url-safe must use -_ and drop padding.
|
|
130
|
+
let arr = Value::Array(VmRef::new(vec![
|
|
131
|
+
Value::Number(251.0),
|
|
132
|
+
Value::Number(255.0),
|
|
133
|
+
Value::Number(191.0),
|
|
134
|
+
]));
|
|
135
|
+
let Value::String(sv) = base64_url_encode(&[arr]) else { panic!() };
|
|
136
|
+
let s = sv.to_string();
|
|
137
|
+
assert!(!s.contains('=') && !s.contains('+') && !s.contains('/'), "got {s}");
|
|
138
|
+
// and it round-trips
|
|
139
|
+
assert_eq!(to_bytes(base64_url_decode(&[Value::String(sv)])), vec![251u8, 255, 191]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn decode_invalid_is_null() {
|
|
144
|
+
assert!(matches!(
|
|
145
|
+
base64_decode(&[Value::String("!!! not base64 !!!".into())]),
|
|
146
|
+
Value::Null
|
|
147
|
+
));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[test]
|
|
151
|
+
fn utf8_roundtrip_multibyte() {
|
|
152
|
+
// "héllo 🚀" exercises 2-byte (é) and 4-byte (🚀) sequences.
|
|
153
|
+
let bytes = utf8_encode(&[Value::String("héllo 🚀".into())]);
|
|
154
|
+
let Value::String(s) = utf8_decode(&[bytes]) else { panic!() };
|
|
155
|
+
assert_eq!(s.to_string(), "héllo 🚀");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -230,6 +230,32 @@ pub fn truncate_core(args: &[Value]) -> Result<Value, Value> {
|
|
|
230
230
|
.map_err(io_err)
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/// `chmod(path, mode)` — set the file mode (a number, e.g. `0o600` = 384). Unix only; a no-op that
|
|
234
|
+
/// succeeds on other platforms. Needed to write a secrets file 0600.
|
|
235
|
+
pub fn chmod_core(args: &[Value]) -> Result<Value, Value> {
|
|
236
|
+
let mode = match args.get(1) {
|
|
237
|
+
Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => *n as u32,
|
|
238
|
+
_ => {
|
|
239
|
+
return Err(io_err(std::io::Error::new(
|
|
240
|
+
std::io::ErrorKind::InvalidInput,
|
|
241
|
+
"chmod: mode must be a number",
|
|
242
|
+
)))
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
#[cfg(unix)]
|
|
246
|
+
{
|
|
247
|
+
use std::os::unix::fs::PermissionsExt;
|
|
248
|
+
std::fs::set_permissions(path_arg(args, 0), std::fs::Permissions::from_mode(mode))
|
|
249
|
+
.map(|_| Value::Null)
|
|
250
|
+
.map_err(io_err)
|
|
251
|
+
}
|
|
252
|
+
#[cfg(not(unix))]
|
|
253
|
+
{
|
|
254
|
+
let _ = mode;
|
|
255
|
+
Ok(Value::Null)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
233
259
|
/// `mkdtemp(prefix)` — create a uniquely-named temp dir and return its path. Node appends 6 RANDOM
|
|
234
260
|
/// characters; a timestamp suffix is both predictable (a temp-dir security smell) and collision-prone
|
|
235
261
|
/// under rapid calls. Use a random suffix and rely on `create_dir`'s exclusive semantics, retrying on
|
|
@@ -319,6 +345,7 @@ fs_method!(copy_file, copy_file_promise, copy_file_core);
|
|
|
319
345
|
fs_method!(realpath, realpath_promise, realpath_core);
|
|
320
346
|
fs_method!(readlink, readlink_promise, readlink_core);
|
|
321
347
|
fs_method!(truncate, truncate_promise, truncate_core);
|
|
348
|
+
fs_method!(chmod, chmod_promise, chmod_core);
|
|
322
349
|
fs_method!(mkdtemp, mkdtemp_promise, mkdtemp_core);
|
|
323
350
|
fs_method!(cp, cp_promise, cp_core);
|
|
324
351
|
|
|
@@ -16,6 +16,7 @@ pub use tishlang_core::ObjectMap;
|
|
|
16
16
|
// pass) instead of materializing an intermediate `AHashMap` and rebuilding from it.
|
|
17
17
|
pub use tishlang_core::{ObjectData, PropMap};
|
|
18
18
|
pub use tishlang_core::Value;
|
|
19
|
+
pub use tishlang_core::TishStruct;
|
|
19
20
|
pub use tishlang_core::NumArrayBacking;
|
|
20
21
|
pub use tishlang_core::ArcStr;
|
|
21
22
|
/// Used by native codegen for `f()` / `obj()` dispatch (`Value::Function` or `__call` on objects).
|
|
@@ -1777,6 +1778,38 @@ pub mod pty;
|
|
|
1777
1778
|
#[cfg(feature = "pty")]
|
|
1778
1779
|
pub use pty::{pty_kill, pty_pid, pty_read, pty_resize, pty_spawn, pty_write};
|
|
1779
1780
|
|
|
1781
|
+
#[cfg(feature = "process")]
|
|
1782
|
+
pub mod process_spawn;
|
|
1783
|
+
#[cfg(feature = "process")]
|
|
1784
|
+
pub use process_spawn::{
|
|
1785
|
+
process_close_stdin, process_kill, process_pid, process_read_stderr, process_read_stdout,
|
|
1786
|
+
process_spawn, process_wait, process_write_stdin,
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
#[cfg(feature = "net")]
|
|
1790
|
+
pub mod net;
|
|
1791
|
+
#[cfg(feature = "net")]
|
|
1792
|
+
pub use net::{
|
|
1793
|
+
net_accept, net_close, net_connect, net_listen, net_probe, net_read, net_sleep, net_write,
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
#[cfg(feature = "encoding")]
|
|
1797
|
+
pub mod encoding;
|
|
1798
|
+
#[cfg(feature = "encoding")]
|
|
1799
|
+
pub use encoding::{
|
|
1800
|
+
base64_decode, base64_encode, base64_url_decode, base64_url_encode, utf8_decode, utf8_encode,
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
#[cfg(feature = "crypto")]
|
|
1804
|
+
pub mod crypto;
|
|
1805
|
+
#[cfg(feature = "crypto")]
|
|
1806
|
+
pub use crypto::{random_bytes, sha256, sha256_hex};
|
|
1807
|
+
|
|
1808
|
+
#[cfg(feature = "zip")]
|
|
1809
|
+
pub mod zip;
|
|
1810
|
+
#[cfg(feature = "zip")]
|
|
1811
|
+
pub use zip::{zip_entries, zip_extract};
|
|
1812
|
+
|
|
1780
1813
|
#[cfg(feature = "ws")]
|
|
1781
1814
|
pub use ws::{
|
|
1782
1815
|
web_socket_client, web_socket_server_accept, web_socket_server_construct,
|