@tishlang/tish-lsp 3.0.0 → 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.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lsp"
3
- version = "3.0.0"
3
+ version = "3.1.0"
4
4
  edition = "2021"
5
5
  description = "Language Server Protocol implementation for Tish"
6
6
  license-file = { workspace = true }
@@ -18,6 +18,10 @@ const RUNTIME_CARGO_FEATURES: &[&str] = &[
18
18
  "ws",
19
19
  "tty",
20
20
  "pty",
21
+ "net",
22
+ "encoding",
23
+ "crypto",
24
+ "zip",
21
25
  ];
22
26
 
23
27
  /// Map CLI/compile features to flags passed to `tishlang_runtime` in the temp crate's Cargo.toml.
@@ -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
- tokio-tungstenite = { version = "0.29", optional = true }
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
 
@@ -1778,6 +1778,38 @@ pub mod pty;
1778
1778
  #[cfg(feature = "pty")]
1779
1779
  pub use pty::{pty_kill, pty_pid, pty_read, pty_resize, pty_spawn, pty_write};
1780
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
+
1781
1813
  #[cfg(feature = "ws")]
1782
1814
  pub use ws::{
1783
1815
  web_socket_client, web_socket_server_accept, web_socket_server_construct,