microsandbox-rb 0.5.8 → 0.5.9

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.
@@ -0,0 +1,317 @@
1
+ //! SSH over a sandbox: `Microsandbox::Native::{SshClient, SftpClient, SshServer}`.
2
+ //!
3
+ //! Mirrors `sdk/python/src/ssh.rs`. The SSH ops are reached from the sandbox
4
+ //! (`Sandbox::ssh_open_client` / `ssh_prepare_server` in `sandbox.rs`); this
5
+ //! module holds the session wrappers. Each owns its core session behind an
6
+ //! `Arc<tokio::Mutex<Option<…>>>` so it can be consumed exactly once on close.
7
+ //!
8
+ //! Discipline: the async work runs inside `block_on` (GVL released), so it must
9
+ //! never touch the Ruby C API. Each method drives the future to a plain Rust
10
+ //! `Result`, then maps it to a Ruby exception *after* `block_on` returns.
11
+
12
+ use std::sync::Arc;
13
+
14
+ use magnus::{method, prelude::*, Error, RHash, RModule, RString, Ruby};
15
+ use microsandbox::sandbox::{
16
+ SftpClient as CoreSftp, SshClient as CoreSshClient, SshOutput, SshServer as CoreSshServer,
17
+ SshStdioStream,
18
+ };
19
+ use microsandbox::{MicrosandboxError, MicrosandboxResult};
20
+ use tokio::io::AsyncWriteExt;
21
+ use tokio::sync::Mutex;
22
+
23
+ use crate::error;
24
+ use crate::runtime::{block_on, ruby};
25
+
26
+ /// A core error for a session whose value was already taken (closed). Built
27
+ /// inside `block_on`, so it must be a plain Rust error, not a Ruby one.
28
+ fn consumed() -> MicrosandboxError {
29
+ MicrosandboxError::Custom("SSH session is already closed".into())
30
+ }
31
+
32
+ //--------------------------------------------------------------------------------------------------
33
+ // SshClient
34
+ //--------------------------------------------------------------------------------------------------
35
+
36
+ #[magnus::wrap(class = "Microsandbox::Native::SshClient", free_immediately, size)]
37
+ pub struct SshClient {
38
+ inner: Arc<Mutex<Option<CoreSshClient>>>,
39
+ }
40
+
41
+ impl SshClient {
42
+ pub fn from_core(inner: CoreSshClient) -> Self {
43
+ Self {
44
+ inner: Arc::new(Mutex::new(Some(inner))),
45
+ }
46
+ }
47
+
48
+ /// Run a command over SSH and collect {status, stdout, stderr}.
49
+ fn exec(&self, command: String, tty: bool) -> Result<RHash, Error> {
50
+ let inner = Arc::clone(&self.inner);
51
+ let result: MicrosandboxResult<SshOutput> = block_on(async move {
52
+ let guard = inner.lock().await;
53
+ let client = guard.as_ref().ok_or_else(consumed)?;
54
+ client.exec_with(command, |b| b.tty(tty)).await
55
+ });
56
+ Ok(ssh_output_to_hash(result.map_err(error::to_ruby)?))
57
+ }
58
+
59
+ /// Attach the local terminal to an interactive SSH shell; returns the exit
60
+ /// status. Host-TTY coupled (raw mode); not meaningful without a real tty.
61
+ fn attach(&self, term: Option<String>, detach_keys: Option<String>) -> Result<i32, Error> {
62
+ let inner = Arc::clone(&self.inner);
63
+ let result: MicrosandboxResult<i32> = block_on(async move {
64
+ let guard = inner.lock().await;
65
+ let client = guard.as_ref().ok_or_else(consumed)?;
66
+ client
67
+ .attach_with(|mut b| {
68
+ if let Some(t) = term {
69
+ b = b.term(t);
70
+ }
71
+ if let Some(k) = detach_keys {
72
+ b = b.detach_keys(k);
73
+ }
74
+ b
75
+ })
76
+ .await
77
+ });
78
+ result.map_err(error::to_ruby)
79
+ }
80
+
81
+ /// Open an SFTP session over this SSH connection.
82
+ fn sftp(&self) -> Result<SftpClient, Error> {
83
+ let inner = Arc::clone(&self.inner);
84
+ let result: MicrosandboxResult<CoreSftp> = block_on(async move {
85
+ let guard = inner.lock().await;
86
+ let client = guard.as_ref().ok_or_else(consumed)?;
87
+ client.sftp().await
88
+ });
89
+ Ok(SftpClient::from_core(result.map_err(error::to_ruby)?))
90
+ }
91
+
92
+ /// Close the SSH client session. Idempotent.
93
+ fn close(&self) -> Result<(), Error> {
94
+ let inner = Arc::clone(&self.inner);
95
+ let result: MicrosandboxResult<()> = block_on(async move {
96
+ let client = {
97
+ let mut guard = inner.lock().await;
98
+ guard.take()
99
+ };
100
+ match client {
101
+ Some(c) => c.close().await,
102
+ None => Ok(()),
103
+ }
104
+ });
105
+ result.map_err(error::to_ruby)
106
+ }
107
+ }
108
+
109
+ fn ssh_output_to_hash(output: SshOutput) -> RHash {
110
+ let r = ruby();
111
+ let hash = r.hash_new();
112
+ let _ = hash.aset("status", output.status);
113
+ let _ = hash.aset("success", output.status == 0);
114
+ let _ = hash.aset("stdout", r.str_from_slice(output.stdout.as_ref()));
115
+ let _ = hash.aset("stderr", r.str_from_slice(output.stderr.as_ref()));
116
+ hash
117
+ }
118
+
119
+ //--------------------------------------------------------------------------------------------------
120
+ // SftpClient
121
+ //--------------------------------------------------------------------------------------------------
122
+
123
+ #[magnus::wrap(class = "Microsandbox::Native::SftpClient", free_immediately, size)]
124
+ pub struct SftpClient {
125
+ inner: Arc<Mutex<Option<CoreSftp>>>,
126
+ }
127
+
128
+ /// Format an SFTP-layer error as a plain string inside `block_on`; the Ruby
129
+ /// exception is built from it afterward.
130
+ fn sftp_str(e: impl std::fmt::Display) -> String {
131
+ format!("SFTP error: {e}")
132
+ }
133
+
134
+ impl SftpClient {
135
+ pub fn from_core(inner: CoreSftp) -> Self {
136
+ Self {
137
+ inner: Arc::new(Mutex::new(Some(inner))),
138
+ }
139
+ }
140
+
141
+ fn read(&self, path: String) -> Result<RString, Error> {
142
+ let inner = Arc::clone(&self.inner);
143
+ let result: Result<Vec<u8>, String> = block_on(async move {
144
+ let guard = inner.lock().await;
145
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
146
+ sftp.read(path).await.map_err(sftp_str)
147
+ });
148
+ Ok(ruby().str_from_slice(&result.map_err(error::base_error)?))
149
+ }
150
+
151
+ fn write(&self, path: String, data: RString) -> Result<(), Error> {
152
+ let bytes = unsafe { data.as_slice() }.to_vec();
153
+ let inner = Arc::clone(&self.inner);
154
+ let result: Result<(), String> = block_on(async move {
155
+ let guard = inner.lock().await;
156
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
157
+ let mut file = sftp.create(path).await.map_err(sftp_str)?;
158
+ file.write_all(&bytes).await.map_err(sftp_str)?;
159
+ file.shutdown().await.map_err(sftp_str)?;
160
+ Ok(())
161
+ });
162
+ result.map_err(error::base_error)
163
+ }
164
+
165
+ fn mkdir(&self, path: String) -> Result<(), Error> {
166
+ let inner = Arc::clone(&self.inner);
167
+ let result: Result<(), String> = block_on(async move {
168
+ let guard = inner.lock().await;
169
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
170
+ sftp.create_dir(path).await.map_err(sftp_str)
171
+ });
172
+ result.map_err(error::base_error)
173
+ }
174
+
175
+ fn remove_file(&self, path: String) -> Result<(), Error> {
176
+ let inner = Arc::clone(&self.inner);
177
+ let result: Result<(), String> = block_on(async move {
178
+ let guard = inner.lock().await;
179
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
180
+ sftp.remove_file(path).await.map_err(sftp_str)
181
+ });
182
+ result.map_err(error::base_error)
183
+ }
184
+
185
+ fn remove_dir(&self, path: String) -> Result<(), Error> {
186
+ let inner = Arc::clone(&self.inner);
187
+ let result: Result<(), String> = block_on(async move {
188
+ let guard = inner.lock().await;
189
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
190
+ sftp.remove_dir(path).await.map_err(sftp_str)
191
+ });
192
+ result.map_err(error::base_error)
193
+ }
194
+
195
+ fn rename(&self, old_path: String, new_path: String) -> Result<(), Error> {
196
+ let inner = Arc::clone(&self.inner);
197
+ let result: Result<(), String> = block_on(async move {
198
+ let guard = inner.lock().await;
199
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
200
+ sftp.rename(old_path, new_path).await.map_err(sftp_str)
201
+ });
202
+ result.map_err(error::base_error)
203
+ }
204
+
205
+ fn symlink(&self, target: String, link_path: String) -> Result<(), Error> {
206
+ let inner = Arc::clone(&self.inner);
207
+ let result: Result<(), String> = block_on(async move {
208
+ let guard = inner.lock().await;
209
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
210
+ sftp.symlink(target, link_path).await.map_err(sftp_str)
211
+ });
212
+ result.map_err(error::base_error)
213
+ }
214
+
215
+ fn real_path(&self, path: String) -> Result<String, Error> {
216
+ let inner = Arc::clone(&self.inner);
217
+ let result: Result<String, String> = block_on(async move {
218
+ let guard = inner.lock().await;
219
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
220
+ sftp.canonicalize(path).await.map_err(sftp_str)
221
+ });
222
+ result.map_err(error::base_error)
223
+ }
224
+
225
+ fn read_link(&self, path: String) -> Result<String, Error> {
226
+ let inner = Arc::clone(&self.inner);
227
+ let result: Result<String, String> = block_on(async move {
228
+ let guard = inner.lock().await;
229
+ let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
230
+ sftp.read_link(path).await.map_err(sftp_str)
231
+ });
232
+ result.map_err(error::base_error)
233
+ }
234
+
235
+ fn close(&self) -> Result<(), Error> {
236
+ let inner = Arc::clone(&self.inner);
237
+ let result: Result<(), String> = block_on(async move {
238
+ let sftp = {
239
+ let mut guard = inner.lock().await;
240
+ guard.take()
241
+ };
242
+ match sftp {
243
+ Some(s) => s.close().await.map_err(sftp_str),
244
+ None => Ok(()),
245
+ }
246
+ });
247
+ result.map_err(error::base_error)
248
+ }
249
+ }
250
+
251
+ //--------------------------------------------------------------------------------------------------
252
+ // SshServer
253
+ //--------------------------------------------------------------------------------------------------
254
+
255
+ #[magnus::wrap(class = "Microsandbox::Native::SshServer", free_immediately, size)]
256
+ pub struct SshServer {
257
+ inner: Arc<Mutex<Option<CoreSshServer>>>,
258
+ }
259
+
260
+ impl SshServer {
261
+ pub fn from_core(inner: CoreSshServer) -> Self {
262
+ Self {
263
+ inner: Arc::new(Mutex::new(Some(inner))),
264
+ }
265
+ }
266
+
267
+ /// Serve one SSH connection over this process's stdin/stdout.
268
+ fn serve_connection(&self) -> Result<(), Error> {
269
+ let inner = Arc::clone(&self.inner);
270
+ let result: MicrosandboxResult<()> = block_on(async move {
271
+ let server = {
272
+ let guard = inner.lock().await;
273
+ guard.as_ref().cloned()
274
+ };
275
+ match server {
276
+ Some(s) => s.serve_connection(SshStdioStream::new()).await,
277
+ None => Err(consumed()),
278
+ }
279
+ });
280
+ result.map_err(error::to_ruby)
281
+ }
282
+
283
+ /// Release the prepared server endpoint. Idempotent.
284
+ fn close(&self) -> Result<(), Error> {
285
+ let inner = Arc::clone(&self.inner);
286
+ block_on(async move {
287
+ inner.lock().await.take();
288
+ });
289
+ Ok(())
290
+ }
291
+ }
292
+
293
+ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
294
+ let client = native.define_class("SshClient", ruby.class_object())?;
295
+ client.define_method("exec", method!(SshClient::exec, 2))?;
296
+ client.define_method("attach", method!(SshClient::attach, 2))?;
297
+ client.define_method("sftp", method!(SshClient::sftp, 0))?;
298
+ client.define_method("close", method!(SshClient::close, 0))?;
299
+
300
+ let sftp = native.define_class("SftpClient", ruby.class_object())?;
301
+ sftp.define_method("read", method!(SftpClient::read, 1))?;
302
+ sftp.define_method("write", method!(SftpClient::write, 2))?;
303
+ sftp.define_method("mkdir", method!(SftpClient::mkdir, 1))?;
304
+ sftp.define_method("remove_file", method!(SftpClient::remove_file, 1))?;
305
+ sftp.define_method("remove_dir", method!(SftpClient::remove_dir, 1))?;
306
+ sftp.define_method("rename", method!(SftpClient::rename, 2))?;
307
+ sftp.define_method("symlink", method!(SftpClient::symlink, 2))?;
308
+ sftp.define_method("real_path", method!(SftpClient::real_path, 1))?;
309
+ sftp.define_method("read_link", method!(SftpClient::read_link, 1))?;
310
+ sftp.define_method("close", method!(SftpClient::close, 0))?;
311
+
312
+ let server = native.define_class("SshServer", ruby.class_object())?;
313
+ server.define_method("serve_connection", method!(SshServer::serve_connection, 0))?;
314
+ server.define_method("close", method!(SshServer::close, 0))?;
315
+
316
+ Ok(())
317
+ }
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # A single raw agent-protocol frame: a correlation id, flag bits, and a
5
+ # CBOR-encoded body. Decode {#body} with a CBOR library of your choice.
6
+ class AgentFrame
7
+ # @return [Integer] correlation id from the frame header
8
+ attr_reader :id
9
+ # @return [Integer] flag bits (see {AgentClient::FLAG_TERMINAL} etc.)
10
+ attr_reader :flags
11
+ # @return [String] raw CBOR body bytes (ASCII-8BIT)
12
+ attr_reader :body
13
+
14
+ def initialize(data)
15
+ @id = data["id"]
16
+ @flags = data["flags"]
17
+ @body = data["body"]
18
+ end
19
+
20
+ # @return [Boolean] last frame of a stream
21
+ def terminal? = (@flags & AgentClient::FLAG_TERMINAL) != 0
22
+
23
+ # @return [Boolean] opens a new session
24
+ def session_start? = (@flags & AgentClient::FLAG_SESSION_START) != 0
25
+
26
+ # @return [Boolean] connection-shutdown signal
27
+ def shutdown? = (@flags & AgentClient::FLAG_SHUTDOWN) != 0
28
+
29
+ def inspect
30
+ "#<Microsandbox::AgentFrame id=#{@id} flags=0x#{@flags.to_s(16)} body=#{@body&.bytesize}B>"
31
+ end
32
+ end
33
+
34
+ # An open raw agent stream, from {AgentClient#stream}. {Enumerable}: iterate to
35
+ # consume {AgentFrame}s until the stream ends (the terminal frame is delivered,
36
+ # then iteration stops). Mirrors the official SDKs' `AgentStream`.
37
+ #
38
+ # @example
39
+ # stream = client.stream(0, request_body)
40
+ # stream.each { |frame| handle(frame) }
41
+ class AgentStream
42
+ include Enumerable
43
+
44
+ # @return [Integer] the protocol correlation id (pass to {AgentClient#send_frame})
45
+ attr_reader :id
46
+
47
+ def initialize(native, id, handle)
48
+ @native = native
49
+ @id = id
50
+ @handle = handle
51
+ end
52
+
53
+ # Pull the next frame, or nil at end-of-stream.
54
+ # @return [AgentFrame, nil]
55
+ def recv
56
+ frame = @native.stream_next(@handle)
57
+ frame && AgentFrame.new(frame)
58
+ end
59
+
60
+ # @yieldparam frame [AgentFrame]
61
+ # @return [self, Enumerator]
62
+ def each
63
+ return enum_for(:each) unless block_given?
64
+
65
+ while (frame = recv)
66
+ yield frame
67
+ end
68
+ self
69
+ end
70
+
71
+ # Close the stream and release its handle. Idempotent.
72
+ # @return [nil]
73
+ def close
74
+ @native.stream_close(@handle)
75
+ nil
76
+ end
77
+ end
78
+
79
+ # A low-level **raw agent client** — the byte-level transport to a sandbox's
80
+ # `agentd` over its relay socket. This is the rawest tier of the SDK: it moves
81
+ # CBOR-encoded protocol frames in and out; encoding/decoding the bodies is up
82
+ # to you. Most users want {Sandbox#exec}/{Sandbox#fs} instead; reach for this
83
+ # to drive `agentd` protocol features the high-level API does not expose, or to
84
+ # bridge the relay socket to another transport (see {socket_path}).
85
+ #
86
+ # Mirrors the `AgentClient` in the official Python/Node/Go SDKs.
87
+ #
88
+ # @example one-shot request/response
89
+ # Microsandbox::AgentClient.connect_sandbox("my-box") do |client|
90
+ # frame = client.request(0, cbor_encoded_request)
91
+ # handle(frame.body)
92
+ # end
93
+ class AgentClient
94
+ # Frame flag bits (mirror the protocol constants in the other SDKs).
95
+ FLAG_TERMINAL = 0b0000_0001
96
+ FLAG_SESSION_START = 0b0000_0010
97
+ FLAG_SHUTDOWN = 0b0000_0100
98
+
99
+ class << self
100
+ # Connect to a running sandbox by name (max 128 UTF-8 bytes). With a block,
101
+ # the client is yielded and closed when the block returns.
102
+ # @param name [String]
103
+ # @param timeout [Numeric, nil] handshake timeout in seconds (default ~10s)
104
+ # @yieldparam client [AgentClient]
105
+ # @return [AgentClient, Object]
106
+ def connect_sandbox(name, timeout: nil, &block)
107
+ wrap(Native::AgentClient.connect_sandbox(name.to_s, timeout && Float(timeout)), &block)
108
+ end
109
+
110
+ # Connect to an agentd relay socket by path. See {connect_sandbox}.
111
+ # @return [AgentClient, Object]
112
+ def connect_path(path, timeout: nil, &block)
113
+ wrap(Native::AgentClient.connect_path(path.to_s, timeout && Float(timeout)), &block)
114
+ end
115
+
116
+ # Resolve a sandbox's agent relay socket path **without** connecting — the
117
+ # same path {connect_sandbox} would dial. Useful for bridging the socket to
118
+ # another byte transport. The sandbox need not be running.
119
+ # @return [String]
120
+ def socket_path(name)
121
+ Native::AgentClient.socket_path(name.to_s)
122
+ end
123
+
124
+ private
125
+
126
+ def wrap(native, &block)
127
+ client = new(native)
128
+ return client unless block
129
+
130
+ begin
131
+ block.call(client)
132
+ ensure
133
+ client.close
134
+ end
135
+ end
136
+ end
137
+
138
+ def initialize(native)
139
+ @native = native
140
+ end
141
+
142
+ # Send one frame and await a single response frame.
143
+ # @param flags [Integer] frame flag bits
144
+ # @param body [String] CBOR-encoded body bytes
145
+ # @return [AgentFrame]
146
+ def request(flags, body)
147
+ AgentFrame.new(@native.request(Integer(flags), body.to_s))
148
+ end
149
+
150
+ # Open a streaming session.
151
+ # @param flags [Integer]
152
+ # @param body [String]
153
+ # @return [AgentStream]
154
+ def stream(flags, body)
155
+ opened = @native.stream_open(Integer(flags), body.to_s)
156
+ AgentStream.new(@native, opened["id"], opened["handle"])
157
+ end
158
+
159
+ # Send a follow-up frame on an existing correlation id (e.g. a stream's
160
+ # {AgentStream#id}). Named +send_frame+ rather than +send+ so it does not
161
+ # shadow Ruby's `Object#send`. Maps to the protocol "send" in the other SDKs.
162
+ # @return [nil]
163
+ def send_frame(id, flags, body)
164
+ @native.send(Integer(id), Integer(flags), body.to_s)
165
+ nil
166
+ end
167
+
168
+ # The cached handshake `core.ready` frame body (CBOR bytes).
169
+ # @return [String]
170
+ def ready_bytes
171
+ @native.ready_bytes
172
+ end
173
+
174
+ # Close the connection. Idempotent.
175
+ # @return [nil]
176
+ def close
177
+ @native.close
178
+ nil
179
+ end
180
+ end
181
+ end