microsandbox-rb 0.17.0 → 0.17.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -8
  3. data/Cargo.lock +45 -46
  4. data/DESIGN.md +90 -72
  5. data/README.md +178 -669
  6. data/SECURITY.md +65 -0
  7. data/docs/configuration.md +154 -0
  8. data/docs/errors.md +74 -0
  9. data/docs/execution.md +108 -0
  10. data/docs/filesystem.md +45 -0
  11. data/docs/images.md +65 -0
  12. data/docs/lifecycle.md +141 -0
  13. data/docs/observability.md +99 -0
  14. data/docs/releasing.md +117 -0
  15. data/docs/runtime.md +154 -0
  16. data/docs/surface.md +75 -0
  17. data/ext/microsandbox/Cargo.toml +9 -5
  18. data/ext/microsandbox/src/agent.rs +13 -11
  19. data/ext/microsandbox/src/backend.rs +1 -1
  20. data/ext/microsandbox/src/exec.rs +12 -8
  21. data/ext/microsandbox/src/fs_stream.rs +19 -7
  22. data/ext/microsandbox/src/host_path.rs +108 -0
  23. data/ext/microsandbox/src/image.rs +2 -2
  24. data/ext/microsandbox/src/lib.rs +3 -2
  25. data/ext/microsandbox/src/runtime.rs +189 -13
  26. data/ext/microsandbox/src/sandbox.rs +149 -102
  27. data/ext/microsandbox/src/snapshot.rs +17 -11
  28. data/ext/microsandbox/src/ssh.rs +22 -16
  29. data/ext/microsandbox/src/stream.rs +6 -2
  30. data/ext/microsandbox/src/volume.rs +50 -17
  31. data/lib/microsandbox/fs.rb +4 -8
  32. data/lib/microsandbox/image.rb +2 -2
  33. data/lib/microsandbox/log_entry.rb +1 -1
  34. data/lib/microsandbox/metrics.rb +1 -1
  35. data/lib/microsandbox/outbound_proxy.rb +8 -0
  36. data/lib/microsandbox/sandbox.rb +128 -13
  37. data/lib/microsandbox/snapshot.rb +1 -1
  38. data/lib/microsandbox/ssh.rb +2 -10
  39. data/lib/microsandbox/streams.rb +8 -5
  40. data/lib/microsandbox/version.rb +1 -1
  41. data/lib/microsandbox/volume.rb +31 -14
  42. data/lib/microsandbox-rb.rb +7 -0
  43. data/lib/microsandbox.rb +88 -31
  44. data/sig/microsandbox.rbs +24 -0
  45. metadata +14 -1
@@ -110,7 +110,7 @@ fn create(name: String, opts: RHash) -> Result<RHash, Error> {
110
110
  b = b.resumable();
111
111
  }
112
112
 
113
- let snap = block_on(b.create()).map_err(error::to_ruby)?;
113
+ let snap = block_on(b.create())?.map_err(error::to_ruby)?;
114
114
  Ok(snapshot_to_hash(&snap))
115
115
  }
116
116
 
@@ -119,14 +119,14 @@ fn create(name: String, opts: RHash) -> Result<RHash, Error> {
119
119
  /// Hash — the only way to inspect an artifact addressed by path (`get`/`list`
120
120
  /// read the local index, which path-addressed artifacts are absent from).
121
121
  fn open(path_or_name: String) -> Result<RHash, Error> {
122
- let snap = block_on(Snapshot::open(&path_or_name)).map_err(error::to_ruby)?;
122
+ let snap = block_on(Snapshot::open(&path_or_name))?.map_err(error::to_ruby)?;
123
123
  Ok(snapshot_to_hash(&snap))
124
124
  }
125
125
 
126
126
  /// Walk `dir` and parse each subdirectory's `snapshot.json` without touching
127
127
  /// the local index — for enumerating external/un-imported snapshot collections.
128
128
  fn list_dir(dir: String) -> Result<RArray, Error> {
129
- let snaps = block_on(Snapshot::list_dir(Path::new(&dir))).map_err(error::to_ruby)?;
129
+ let snaps = block_on(Snapshot::list_dir(Path::new(&dir)))?.map_err(error::to_ruby)?;
130
130
  let arr = ruby().ary_new();
131
131
  for snap in &snaps {
132
132
  arr.push(snapshot_to_hash(snap))?;
@@ -145,19 +145,19 @@ fn reindex(dir: Option<String>) -> Result<u64, Error> {
145
145
  .map(|l| l.snapshots_dir())
146
146
  .unwrap_or_else(|| PathBuf::from(".")),
147
147
  };
148
- let n = block_on(Snapshot::reindex(&dir)).map_err(error::to_ruby)?;
148
+ let n = block_on(Snapshot::reindex(&dir))?.map_err(error::to_ruby)?;
149
149
  Ok(n as u64)
150
150
  }
151
151
 
152
152
  /// Metadata for one snapshot by name or digest.
153
153
  fn get(name_or_digest: String) -> Result<RHash, Error> {
154
- let handle = block_on(Snapshot::get(&name_or_digest)).map_err(error::to_ruby)?;
154
+ let handle = block_on(Snapshot::get(&name_or_digest))?.map_err(error::to_ruby)?;
155
155
  Ok(handle_to_hash(&handle))
156
156
  }
157
157
 
158
158
  /// All snapshots as metadata hashes.
159
159
  fn list() -> Result<RArray, Error> {
160
- let handles = block_on(Snapshot::list()).map_err(error::to_ruby)?;
160
+ let handles = block_on(Snapshot::list())?.map_err(error::to_ruby)?;
161
161
  let arr = ruby().ary_new();
162
162
  for h in &handles {
163
163
  arr.push(handle_to_hash(h))?;
@@ -167,7 +167,7 @@ fn list() -> Result<RArray, Error> {
167
167
 
168
168
  /// Remove a snapshot artifact by name or path.
169
169
  fn remove(name_or_path: String, force: bool) -> Result<(), Error> {
170
- block_on(Snapshot::remove(&name_or_path, force)).map_err(error::to_ruby)
170
+ block_on(Snapshot::remove(&name_or_path, force))?.map_err(error::to_ruby)
171
171
  }
172
172
 
173
173
  /// Verify a snapshot's recorded upper-layer integrity. Returns
@@ -176,8 +176,14 @@ fn remove(name_or_path: String, force: bool) -> Result<(), Error> {
176
176
  /// "verified" (mismatches raise SnapshotIntegrityError), snapshots without
177
177
  /// recorded integrity report "not_recorded" with no algorithm/digest keys.
178
178
  fn verify(name_or_path: String) -> Result<RHash, Error> {
179
- let snap = block_on(Snapshot::open(&name_or_path)).map_err(error::to_ruby)?;
180
- let report = block_on(snap.verify()).map_err(error::to_ruby)?;
179
+ // Open AND verify in ONE `block_on`, so the opened snapshot never sits in
180
+ // this frame between two calls (`block_on` returns `Err` when an interrupt
181
+ // is delivered before it starts; one call leaves no such in-between state).
182
+ let report = block_on(async move {
183
+ let snap = Snapshot::open(&name_or_path).await?;
184
+ snap.verify().await
185
+ })?
186
+ .map_err(error::to_ruby)?;
181
187
  Ok(verify_report_to_hash(&report))
182
188
  }
183
189
 
@@ -193,7 +199,7 @@ fn save(name_or_path: String, out_path: String, opts: RHash) -> Result<(), Error
193
199
  &name_or_path,
194
200
  std::path::Path::new(&out_path),
195
201
  save_opts,
196
- ))
202
+ ))?
197
203
  .map_err(error::to_ruby)
198
204
  }
199
205
 
@@ -204,7 +210,7 @@ fn load(archive_path: String, dest: Option<String>) -> Result<RHash, Error> {
204
210
  let handle = block_on(Snapshot::load(
205
211
  std::path::Path::new(&archive_path),
206
212
  dest_path.as_deref(),
207
- ))
213
+ ))?
208
214
  .map_err(error::to_ruby)?;
209
215
  Ok(handle_to_hash(&handle))
210
216
  }
@@ -52,7 +52,7 @@ impl SshClient {
52
52
  let guard = inner.lock().await;
53
53
  let client = guard.as_ref().ok_or_else(consumed)?;
54
54
  client.exec_with(command, |b| b.tty(tty)).await
55
- });
55
+ })?;
56
56
  Ok(ssh_output_to_hash(result.map_err(error::to_ruby)?))
57
57
  }
58
58
 
@@ -74,7 +74,7 @@ impl SshClient {
74
74
  b
75
75
  })
76
76
  .await
77
- });
77
+ })?;
78
78
  result.map_err(error::to_ruby)
79
79
  }
80
80
 
@@ -85,7 +85,7 @@ impl SshClient {
85
85
  let guard = inner.lock().await;
86
86
  let client = guard.as_ref().ok_or_else(consumed)?;
87
87
  client.sftp().await
88
- });
88
+ })?;
89
89
  Ok(SftpClient::from_core(result.map_err(error::to_ruby)?))
90
90
  }
91
91
 
@@ -101,7 +101,7 @@ impl SshClient {
101
101
  Some(c) => c.close().await,
102
102
  None => Ok(()),
103
103
  }
104
- });
104
+ })?;
105
105
  result.map_err(error::to_ruby)
106
106
  }
107
107
  }
@@ -144,7 +144,7 @@ impl SftpClient {
144
144
  let guard = inner.lock().await;
145
145
  let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
146
146
  sftp.read(path).await.map_err(sftp_str)
147
- });
147
+ })?;
148
148
  Ok(ruby().str_from_slice(&result.map_err(error::base_error)?))
149
149
  }
150
150
 
@@ -158,7 +158,7 @@ impl SftpClient {
158
158
  file.write_all(&bytes).await.map_err(sftp_str)?;
159
159
  file.shutdown().await.map_err(sftp_str)?;
160
160
  Ok(())
161
- });
161
+ })?;
162
162
  result.map_err(error::base_error)
163
163
  }
164
164
 
@@ -168,7 +168,7 @@ impl SftpClient {
168
168
  let guard = inner.lock().await;
169
169
  let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
170
170
  sftp.create_dir(path).await.map_err(sftp_str)
171
- });
171
+ })?;
172
172
  result.map_err(error::base_error)
173
173
  }
174
174
 
@@ -178,7 +178,7 @@ impl SftpClient {
178
178
  let guard = inner.lock().await;
179
179
  let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
180
180
  sftp.remove_file(path).await.map_err(sftp_str)
181
- });
181
+ })?;
182
182
  result.map_err(error::base_error)
183
183
  }
184
184
 
@@ -188,7 +188,7 @@ impl SftpClient {
188
188
  let guard = inner.lock().await;
189
189
  let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
190
190
  sftp.remove_dir(path).await.map_err(sftp_str)
191
- });
191
+ })?;
192
192
  result.map_err(error::base_error)
193
193
  }
194
194
 
@@ -198,7 +198,7 @@ impl SftpClient {
198
198
  let guard = inner.lock().await;
199
199
  let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
200
200
  sftp.rename(old_path, new_path).await.map_err(sftp_str)
201
- });
201
+ })?;
202
202
  result.map_err(error::base_error)
203
203
  }
204
204
 
@@ -208,7 +208,7 @@ impl SftpClient {
208
208
  let guard = inner.lock().await;
209
209
  let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
210
210
  sftp.symlink(target, link_path).await.map_err(sftp_str)
211
- });
211
+ })?;
212
212
  result.map_err(error::base_error)
213
213
  }
214
214
 
@@ -218,7 +218,7 @@ impl SftpClient {
218
218
  let guard = inner.lock().await;
219
219
  let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
220
220
  sftp.canonicalize(path).await.map_err(sftp_str)
221
- });
221
+ })?;
222
222
  result.map_err(error::base_error)
223
223
  }
224
224
 
@@ -228,7 +228,7 @@ impl SftpClient {
228
228
  let guard = inner.lock().await;
229
229
  let sftp = guard.as_ref().ok_or_else(|| sftp_str("session closed"))?;
230
230
  sftp.read_link(path).await.map_err(sftp_str)
231
- });
231
+ })?;
232
232
  result.map_err(error::base_error)
233
233
  }
234
234
 
@@ -243,7 +243,7 @@ impl SftpClient {
243
243
  Some(s) => s.close().await.map_err(sftp_str),
244
244
  None => Ok(()),
245
245
  }
246
- });
246
+ })?;
247
247
  result.map_err(error::base_error)
248
248
  }
249
249
  }
@@ -276,7 +276,7 @@ impl SshServer {
276
276
  Some(s) => s.serve_connection(SshStdioStream::new()).await,
277
277
  None => Err(consumed()),
278
278
  }
279
- });
279
+ })?;
280
280
  result.map_err(error::to_ruby)
281
281
  }
282
282
 
@@ -285,19 +285,23 @@ impl SshServer {
285
285
  let inner = Arc::clone(&self.inner);
286
286
  block_on(async move {
287
287
  inner.lock().await.take();
288
- });
288
+ })?;
289
289
  Ok(())
290
290
  }
291
291
  }
292
292
 
293
293
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
294
294
  let client = native.define_class("SshClient", ruby.class_object())?;
295
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
296
+ crate::runtime::preload_class::<SshClient>(ruby);
295
297
  client.define_method("exec", method!(SshClient::exec, 2))?;
296
298
  client.define_method("attach", method!(SshClient::attach, 2))?;
297
299
  client.define_method("sftp", method!(SshClient::sftp, 0))?;
298
300
  client.define_method("close", method!(SshClient::close, 0))?;
299
301
 
300
302
  let sftp = native.define_class("SftpClient", ruby.class_object())?;
303
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
304
+ crate::runtime::preload_class::<SftpClient>(ruby);
301
305
  sftp.define_method("read", method!(SftpClient::read, 1))?;
302
306
  sftp.define_method("write", method!(SftpClient::write, 2))?;
303
307
  sftp.define_method("mkdir", method!(SftpClient::mkdir, 1))?;
@@ -310,6 +314,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
310
314
  sftp.define_method("close", method!(SftpClient::close, 0))?;
311
315
 
312
316
  let server = native.define_class("SshServer", ruby.class_object())?;
317
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
318
+ crate::runtime::preload_class::<SshServer>(ruby);
313
319
  server.define_method("serve_connection", method!(SshServer::serve_connection, 0))?;
314
320
  server.define_method("close", method!(SshServer::close, 0))?;
315
321
 
@@ -41,7 +41,7 @@ impl LogStream {
41
41
  fn recv(&self) -> Result<Option<RHash>, Error> {
42
42
  use futures::StreamExt;
43
43
  let inner = Arc::clone(&self.inner);
44
- match block_on(async move { inner.lock().await.next().await }) {
44
+ match block_on(async move { inner.lock().await.next().await })? {
45
45
  Some(Ok(entry)) => Ok(Some(log_entry_to_hash(&entry))),
46
46
  Some(Err(e)) => Err(error::to_ruby(e)),
47
47
  None => Ok(None),
@@ -67,7 +67,7 @@ impl MetricsStream {
67
67
  fn recv(&self) -> Result<Option<RHash>, Error> {
68
68
  use futures::StreamExt;
69
69
  let inner = Arc::clone(&self.inner);
70
- match block_on(async move { inner.lock().await.next().await }) {
70
+ match block_on(async move { inner.lock().await.next().await })? {
71
71
  Some(Ok(metrics)) => Ok(Some(metrics_to_hash(&metrics))),
72
72
  Some(Err(e)) => Err(error::to_ruby(e)),
73
73
  None => Ok(None),
@@ -77,9 +77,13 @@ impl MetricsStream {
77
77
 
78
78
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
79
79
  let logs = native.define_class("LogStream", ruby.class_object())?;
80
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
81
+ crate::runtime::preload_class::<LogStream>(ruby);
80
82
  logs.define_method("recv", method!(LogStream::recv, 0))?;
81
83
 
82
84
  let metrics = native.define_class("MetricsStream", ruby.class_object())?;
85
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
86
+ crate::runtime::preload_class::<MetricsStream>(ruby);
83
87
  metrics.define_method("recv", method!(MetricsStream::recv, 0))?;
84
88
 
85
89
  Ok(())
@@ -3,11 +3,12 @@
3
3
  //! Mirrors `sdk/python/src/volume.rs`. Static async CRUD over the volume store;
4
4
  //! results are returned as plain Ruby Hashes/Arrays.
5
5
 
6
+ use std::path::{Component, Path};
6
7
  use std::sync::Arc;
7
8
 
8
9
  use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby};
9
10
  use microsandbox::volume::VolumeHandle;
10
- use microsandbox::Backend;
11
+ use microsandbox::{Backend, MicrosandboxError};
11
12
 
12
13
  use crate::conv;
13
14
  use crate::error;
@@ -67,7 +68,7 @@ fn create(name: String, opts: RHash) -> Result<RHash, Error> {
67
68
  builder = builder.label(k, v);
68
69
  }
69
70
 
70
- let vol = block_on(builder.create()).map_err(error::to_ruby)?;
71
+ let vol = block_on(builder.create())?.map_err(error::to_ruby)?;
71
72
  let hash = ruby().hash_new();
72
73
  hash.aset("name", vol.name().to_string())?;
73
74
  // `path()` is fallible as of v0.5.8 (returns `Unsupported` for cloud
@@ -80,19 +81,19 @@ fn create(name: String, opts: RHash) -> Result<RHash, Error> {
80
81
  }
81
82
 
82
83
  fn get(name: String) -> Result<RHash, Error> {
83
- let handle = block_on(microsandbox::Volume::get(&name)).map_err(error::to_ruby)?;
84
+ let handle = block_on(microsandbox::Volume::get(&name))?.map_err(error::to_ruby)?;
84
85
  Ok(handle_to_hash(&handle))
85
86
  }
86
87
 
87
88
  /// The backend's default volume (v0.6.9). Cloud-backend only — the local
88
89
  /// backend rejects it (Unsupported) to avoid accidental host access.
89
90
  fn get_default() -> Result<RHash, Error> {
90
- let handle = block_on(microsandbox::Volume::get_default()).map_err(error::to_ruby)?;
91
+ let handle = block_on(microsandbox::Volume::get_default())?.map_err(error::to_ruby)?;
91
92
  Ok(handle_to_hash(&handle))
92
93
  }
93
94
 
94
95
  fn list() -> Result<RArray, Error> {
95
- let handles = block_on(microsandbox::Volume::list()).map_err(error::to_ruby)?;
96
+ let handles = block_on(microsandbox::Volume::list())?.map_err(error::to_ruby)?;
96
97
  let arr = ruby().ary_new();
97
98
  for h in handles.iter() {
98
99
  arr.push(handle_to_hash(h))?;
@@ -101,7 +102,24 @@ fn list() -> Result<RArray, Error> {
101
102
  }
102
103
 
103
104
  fn remove(name: String) -> Result<(), Error> {
104
- block_on(microsandbox::Volume::remove(&name)).map_err(error::to_ruby)
105
+ block_on(microsandbox::Volume::remove(&name))?.map_err(error::to_ruby)
106
+ }
107
+
108
+ /// Volume paths are relative to the volume root and never need a `..` segment,
109
+ /// so one is refused before the path reaches the core's resolver (which keeps
110
+ /// its own containment check). Enforced here rather than only in the Ruby
111
+ /// wrapper because `Microsandbox::Native::VolumeFs` is callable directly.
112
+ fn contained(path: &str) -> Result<&str, Error> {
113
+ if Path::new(path)
114
+ .components()
115
+ .any(|part| matches!(part, Component::ParentDir))
116
+ {
117
+ return Err(error::to_ruby(MicrosandboxError::SandboxFsOps(
118
+ "path traversal outside volume root (`..` segments are not allowed in volume paths)"
119
+ .into(),
120
+ )));
121
+ }
122
+ Ok(path)
105
123
  }
106
124
 
107
125
  /// A host-side filesystem view over a named volume — read/write its contents
@@ -131,26 +149,30 @@ impl VolumeFs {
131
149
  }
132
150
 
133
151
  fn read(&self, path: String) -> Result<RString, Error> {
152
+ let path = contained(&path)?;
134
153
  let fs = self.fs();
135
- let bytes = block_on(fs.read(&path)).map_err(error::to_ruby)?;
154
+ let bytes = block_on(fs.read(path))?.map_err(error::to_ruby)?;
136
155
  Ok(ruby().str_from_slice(bytes.as_ref()))
137
156
  }
138
157
 
139
158
  fn read_text(&self, path: String) -> Result<String, Error> {
159
+ let path = contained(&path)?;
140
160
  let fs = self.fs();
141
- block_on(fs.read_to_string(&path)).map_err(error::to_ruby)
161
+ block_on(fs.read_to_string(path))?.map_err(error::to_ruby)
142
162
  }
143
163
 
144
164
  fn write(&self, path: String, data: RString) -> Result<(), Error> {
145
165
  // Copy the bytes out while the GVL is held (GC.compact could move them).
146
166
  let bytes = unsafe { data.as_slice() }.to_vec();
167
+ let path = contained(&path)?;
147
168
  let fs = self.fs();
148
- block_on(fs.write(&path, &bytes)).map_err(error::to_ruby)
169
+ block_on(fs.write(path, &bytes))?.map_err(error::to_ruby)
149
170
  }
150
171
 
151
172
  fn list(&self, path: String) -> Result<RArray, Error> {
173
+ let path = contained(&path)?;
152
174
  let fs = self.fs();
153
- let entries = block_on(fs.list(&path)).map_err(error::to_ruby)?;
175
+ let entries = block_on(fs.list(path))?.map_err(error::to_ruby)?;
154
176
  let arr = ruby().ary_new();
155
177
  for entry in &entries {
156
178
  arr.push(fs_entry_to_hash(entry))?;
@@ -159,38 +181,47 @@ impl VolumeFs {
159
181
  }
160
182
 
161
183
  fn mkdir(&self, path: String) -> Result<(), Error> {
184
+ let path = contained(&path)?;
162
185
  let fs = self.fs();
163
- block_on(fs.mkdir(&path)).map_err(error::to_ruby)
186
+ block_on(fs.mkdir(path))?.map_err(error::to_ruby)
164
187
  }
165
188
 
166
189
  fn remove_file(&self, path: String) -> Result<(), Error> {
190
+ let path = contained(&path)?;
167
191
  let fs = self.fs();
168
- block_on(fs.remove(&path)).map_err(error::to_ruby)
192
+ block_on(fs.remove(path))?.map_err(error::to_ruby)
169
193
  }
170
194
 
171
195
  fn remove_dir(&self, path: String) -> Result<(), Error> {
196
+ let path = contained(&path)?;
172
197
  let fs = self.fs();
173
- block_on(fs.remove_dir(&path)).map_err(error::to_ruby)
198
+ block_on(fs.remove_dir(path))?.map_err(error::to_ruby)
174
199
  }
175
200
 
176
201
  fn exists(&self, path: String) -> Result<bool, Error> {
202
+ let path = contained(&path)?;
177
203
  let fs = self.fs();
178
- block_on(fs.exists(&path)).map_err(error::to_ruby)
204
+ block_on(fs.exists(path))?.map_err(error::to_ruby)
179
205
  }
180
206
 
181
207
  fn copy(&self, from: String, to: String) -> Result<(), Error> {
208
+ let from = contained(&from)?;
209
+ let to = contained(&to)?;
182
210
  let fs = self.fs();
183
- block_on(fs.copy(&from, &to)).map_err(error::to_ruby)
211
+ block_on(fs.copy(from, to))?.map_err(error::to_ruby)
184
212
  }
185
213
 
186
214
  fn rename(&self, from: String, to: String) -> Result<(), Error> {
215
+ let from = contained(&from)?;
216
+ let to = contained(&to)?;
187
217
  let fs = self.fs();
188
- block_on(fs.rename(&from, &to)).map_err(error::to_ruby)
218
+ block_on(fs.rename(from, to))?.map_err(error::to_ruby)
189
219
  }
190
220
 
191
221
  fn stat(&self, path: String) -> Result<RHash, Error> {
222
+ let path = contained(&path)?;
192
223
  let fs = self.fs();
193
- let meta = block_on(fs.stat(&path)).map_err(error::to_ruby)?;
224
+ let meta = block_on(fs.stat(path))?.map_err(error::to_ruby)?;
194
225
  Ok(fs_metadata_to_hash(&meta))
195
226
  }
196
227
  }
@@ -210,6 +241,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
210
241
  class.define_singleton_method("fs", function!(fs, 1))?;
211
242
 
212
243
  let vfs = native.define_class("VolumeFs", ruby.class_object())?;
244
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
245
+ crate::runtime::preload_class::<VolumeFs>(ruby);
213
246
  vfs.define_method("read", method!(VolumeFs::read, 1))?;
214
247
  vfs.define_method("read_text", method!(VolumeFs::read_text, 1))?;
215
248
  vfs.define_method("write", method!(VolumeFs::write, 2))?;
@@ -27,7 +27,7 @@ module Microsandbox
27
27
 
28
28
  # @return [Time, nil] last-modified time, if known
29
29
  def modified
30
- @modified_ms && Time.at(@modified_ms / 1000.0)
30
+ Microsandbox.time_from_ms(@modified_ms)
31
31
  end
32
32
 
33
33
  def file? = @type == :file
@@ -64,12 +64,12 @@ module Microsandbox
64
64
 
65
65
  # @return [Time, nil] last-modified time, if known
66
66
  def modified
67
- @modified_ms && Time.at(@modified_ms / 1000.0)
67
+ Microsandbox.time_from_ms(@modified_ms)
68
68
  end
69
69
 
70
70
  # @return [Time, nil] creation time, if known
71
71
  def created
72
- @created_ms && Time.at(@created_ms / 1000.0)
72
+ Microsandbox.time_from_ms(@created_ms)
73
73
  end
74
74
 
75
75
  def inspect
@@ -188,11 +188,7 @@ module Microsandbox
188
188
  sink = FsWriteSink.new(@native.fs_write_stream(path.to_s))
189
189
  return sink unless block_given?
190
190
 
191
- begin
192
- yield sink
193
- ensure
194
- sink.close
195
- end
191
+ Microsandbox.close_after(sink) { yield sink }
196
192
  end
197
193
  end
198
194
 
@@ -18,12 +18,12 @@ module Microsandbox
18
18
 
19
19
  # @return [Time, nil]
20
20
  def created_at
21
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
21
+ Microsandbox.time_from_ms(@created_at_ms)
22
22
  end
23
23
 
24
24
  # @return [Time, nil]
25
25
  def last_used_at
26
- @last_used_at_ms && Time.at(@last_used_at_ms / 1000.0)
26
+ Microsandbox.time_from_ms(@last_used_at_ms)
27
27
  end
28
28
 
29
29
  def inspect
@@ -22,7 +22,7 @@ module Microsandbox
22
22
 
23
23
  # @return [Time] wall-clock capture time
24
24
  def timestamp
25
- Time.at(@timestamp_ms / 1000.0)
25
+ Microsandbox.time_from_ms(@timestamp_ms)
26
26
  end
27
27
 
28
28
  # @return [String] the captured bytes decoded as UTF-8 (lossy — invalid byte
@@ -53,7 +53,7 @@ module Microsandbox
53
53
 
54
54
  # @return [Time] when this snapshot was captured
55
55
  def timestamp
56
- Time.at(@timestamp_ms / 1000.0)
56
+ Microsandbox.time_from_ms(@timestamp_ms)
57
57
  end
58
58
 
59
59
  def inspect
@@ -246,6 +246,14 @@ module Microsandbox
246
246
  if address.empty?
247
247
  raise ArgumentError, "proxy address must be a non-empty \"IP:port\" String (got \"\")"
248
248
  end
249
+ # A URL-style address (`socks5://user:pass@host:1080`) is never valid, and
250
+ # the core would quote it back in its error. Refuse it by shape, without
251
+ # echoing it, so the embedded credentials stay out of logs.
252
+ if address.match?(%r{@|://|\s})
253
+ raise ArgumentError,
254
+ "proxy address must be \"IP:port\" with no scheme, credentials or whitespace " \
255
+ "(pass SOCKS5 credentials as username:/password:)"
256
+ end
249
257
  # Same rules as the Python SDK's OutboundProxy.__post_init__; the address
250
258
  # itself is parsed by the core (which reports e.g. "invalid SOCKS5 proxy
251
259
  # address" as an InvalidConfigError at create time).