microsandbox-rb 0.5.7

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,158 @@
1
+ //! Snapshot management: `Microsandbox::Native::Snapshot`.
2
+ //!
3
+ //! Snapshots capture a stopped sandbox's upper layer into a portable artifact
4
+ //! that a later `Sandbox.create(from_snapshot:)` can boot from. Exposed as
5
+ //! singleton functions returning plain Hashes/Arrays (shaped into value objects
6
+ //! by the Ruby layer) — there is no long-lived handle to own.
7
+
8
+ use std::path::PathBuf;
9
+
10
+ use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
11
+ use microsandbox::snapshot::{
12
+ ExportOpts, Snapshot, SnapshotDestination, SnapshotFormat, SnapshotHandle,
13
+ SnapshotVerifyReport, UpperVerifyStatus,
14
+ };
15
+
16
+ use crate::conv;
17
+ use crate::error;
18
+ use crate::runtime::{block_on, ruby};
19
+
20
+ fn format_str(format: SnapshotFormat) -> &'static str {
21
+ match format {
22
+ SnapshotFormat::Raw => "raw",
23
+ SnapshotFormat::Qcow2 => "qcow2",
24
+ }
25
+ }
26
+
27
+ /// Create a snapshot of a stopped sandbox. `opts`: name | path (destination),
28
+ /// labels, force, record_integrity. Returns {digest, path, size_bytes}.
29
+ fn create(source_sandbox: String, opts: RHash) -> Result<RHash, Error> {
30
+ let mut b = Snapshot::builder(source_sandbox);
31
+ if let Some(name) = conv::opt_string(opts, "name")? {
32
+ b = b.destination(SnapshotDestination::Name(name));
33
+ } else if let Some(path) = conv::opt_string(opts, "path")? {
34
+ b = b.destination(SnapshotDestination::Path(PathBuf::from(path)));
35
+ } else {
36
+ return Err(error::base_error(
37
+ "snapshot create needs a destination: pass name: or path:",
38
+ ));
39
+ }
40
+ for (k, v) in conv::opt_string_map(opts, "labels")? {
41
+ b = b.label(k, v);
42
+ }
43
+ if conv::opt_bool(opts, "force")? {
44
+ b = b.force();
45
+ }
46
+ if conv::opt_bool(opts, "record_integrity")? {
47
+ b = b.record_integrity();
48
+ }
49
+
50
+ let snap = block_on(b.create()).map_err(error::to_ruby)?;
51
+ let hash = ruby().hash_new();
52
+ hash.aset("digest", snap.digest().to_string())?;
53
+ hash.aset("path", snap.path().to_string_lossy().into_owned())?;
54
+ hash.aset("size_bytes", snap.size_bytes())?;
55
+ Ok(hash)
56
+ }
57
+
58
+ /// Metadata for one snapshot by name or digest.
59
+ fn get(name_or_digest: String) -> Result<RHash, Error> {
60
+ let handle = block_on(Snapshot::get(&name_or_digest)).map_err(error::to_ruby)?;
61
+ Ok(handle_to_hash(&handle))
62
+ }
63
+
64
+ /// All snapshots as metadata hashes.
65
+ fn list() -> Result<RArray, Error> {
66
+ let handles = block_on(Snapshot::list()).map_err(error::to_ruby)?;
67
+ let arr = ruby().ary_new();
68
+ for h in &handles {
69
+ arr.push(handle_to_hash(h))?;
70
+ }
71
+ Ok(arr)
72
+ }
73
+
74
+ /// Remove a snapshot artifact by name or path.
75
+ fn remove(name_or_path: String, force: bool) -> Result<(), Error> {
76
+ block_on(Snapshot::remove(&name_or_path, force)).map_err(error::to_ruby)
77
+ }
78
+
79
+ /// Verify a snapshot's recorded upper-layer integrity. Returns
80
+ /// {digest, path, upper_status, upper_algorithm?, upper_digest?}.
81
+ fn verify(name_or_path: String) -> Result<RHash, Error> {
82
+ let snap = block_on(Snapshot::open(&name_or_path)).map_err(error::to_ruby)?;
83
+ let report = block_on(snap.verify()).map_err(error::to_ruby)?;
84
+ Ok(verify_report_to_hash(&report))
85
+ }
86
+
87
+ /// Bundle a snapshot into a `.tar.zst` (or plain `.tar`) archive. `opts`:
88
+ /// with_parents, with_image, plain_tar.
89
+ fn export(name_or_path: String, out_path: String, opts: RHash) -> Result<(), Error> {
90
+ let export_opts = ExportOpts {
91
+ with_parents: conv::opt_bool(opts, "with_parents")?,
92
+ with_image: conv::opt_bool(opts, "with_image")?,
93
+ plain_tar: conv::opt_bool(opts, "plain_tar")?,
94
+ };
95
+ block_on(Snapshot::export(
96
+ &name_or_path,
97
+ std::path::Path::new(&out_path),
98
+ export_opts,
99
+ ))
100
+ .map_err(error::to_ruby)
101
+ }
102
+
103
+ /// Unpack a snapshot archive into the snapshots dir. Returns the imported
104
+ /// snapshot's metadata hash. `dest` is an optional explicit directory.
105
+ fn import(archive_path: String, dest: Option<String>) -> Result<RHash, Error> {
106
+ let dest_path = dest.map(PathBuf::from);
107
+ let handle = block_on(Snapshot::import(
108
+ std::path::Path::new(&archive_path),
109
+ dest_path.as_deref(),
110
+ ))
111
+ .map_err(error::to_ruby)?;
112
+ Ok(handle_to_hash(&handle))
113
+ }
114
+
115
+ fn handle_to_hash(handle: &SnapshotHandle) -> RHash {
116
+ let hash = ruby().hash_new();
117
+ let _ = hash.aset("digest", handle.digest().to_string());
118
+ let _ = hash.aset("name", handle.name().map(str::to_string));
119
+ let _ = hash.aset("parent_digest", handle.parent_digest().map(str::to_string));
120
+ let _ = hash.aset("image_ref", handle.image_ref().to_string());
121
+ let _ = hash.aset("format", format_str(handle.format()));
122
+ let _ = hash.aset("size_bytes", handle.size_bytes());
123
+ let _ = hash.aset("path", handle.path().to_string_lossy().into_owned());
124
+ let _ = hash.aset(
125
+ "created_at_ms",
126
+ handle.created_at().and_utc().timestamp_millis(),
127
+ );
128
+ hash
129
+ }
130
+
131
+ fn verify_report_to_hash(report: &SnapshotVerifyReport) -> RHash {
132
+ let hash = ruby().hash_new();
133
+ let _ = hash.aset("digest", report.digest.clone());
134
+ let _ = hash.aset("path", report.path.to_string_lossy().into_owned());
135
+ match &report.upper {
136
+ UpperVerifyStatus::NotRecorded => {
137
+ let _ = hash.aset("upper_status", "not_recorded");
138
+ }
139
+ UpperVerifyStatus::Verified { algorithm, digest } => {
140
+ let _ = hash.aset("upper_status", "verified");
141
+ let _ = hash.aset("upper_algorithm", algorithm.clone());
142
+ let _ = hash.aset("upper_digest", digest.clone());
143
+ }
144
+ }
145
+ hash
146
+ }
147
+
148
+ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
149
+ let class = native.define_class("Snapshot", ruby.class_object())?;
150
+ class.define_singleton_method("create", function!(create, 2))?;
151
+ class.define_singleton_method("get", function!(get, 1))?;
152
+ class.define_singleton_method("list", function!(list, 0))?;
153
+ class.define_singleton_method("remove", function!(remove, 2))?;
154
+ class.define_singleton_method("verify", function!(verify, 1))?;
155
+ class.define_singleton_method("export", function!(export, 3))?;
156
+ class.define_singleton_method("import", function!(import, 2))?;
157
+ Ok(())
158
+ }
@@ -0,0 +1,86 @@
1
+ //! Streaming logs and metrics: `Microsandbox::Native::LogStream` and
2
+ //! `Microsandbox::Native::MetricsStream`.
3
+ //!
4
+ //! Mirrors the `ExecHandle` streaming pattern in `exec.rs`. The core
5
+ //! `log_stream`/`metrics_stream` return `impl Stream`; we box+pin each into an
6
+ //! `Arc<tokio::Mutex<…>>` and expose a synchronous `recv` that drives the next
7
+ //! item to completion with the GVL released, returning a Ruby Hash (or `nil` at
8
+ //! end of stream). The Ruby layer wraps each as an `Enumerable`.
9
+
10
+ use std::pin::Pin;
11
+ use std::sync::Arc;
12
+
13
+ use futures::Stream;
14
+ use magnus::{method, prelude::*, Error, RHash, RModule, Ruby};
15
+ use microsandbox::logs::LogEntry;
16
+ use microsandbox::sandbox::SandboxMetrics;
17
+ use microsandbox::MicrosandboxResult;
18
+ use tokio::sync::Mutex;
19
+
20
+ use crate::error;
21
+ use crate::runtime::block_on;
22
+ use crate::sandbox::{log_entry_to_hash, metrics_to_hash};
23
+
24
+ type BoxStream<T> = Pin<Box<dyn Stream<Item = MicrosandboxResult<T>> + Send>>;
25
+
26
+ #[magnus::wrap(class = "Microsandbox::Native::LogStream", free_immediately, size)]
27
+ pub struct LogStream {
28
+ inner: Arc<Mutex<BoxStream<LogEntry>>>,
29
+ }
30
+
31
+ impl LogStream {
32
+ pub fn from_stream(
33
+ stream: impl Stream<Item = MicrosandboxResult<LogEntry>> + Send + 'static,
34
+ ) -> Self {
35
+ Self {
36
+ inner: Arc::new(Mutex::new(Box::pin(stream))),
37
+ }
38
+ }
39
+
40
+ /// Next log entry as a Hash, or nil when the stream ends.
41
+ fn recv(&self) -> Result<Option<RHash>, Error> {
42
+ use futures::StreamExt;
43
+ let inner = Arc::clone(&self.inner);
44
+ match block_on(async move { inner.lock().await.next().await }) {
45
+ Some(Ok(entry)) => Ok(Some(log_entry_to_hash(&entry))),
46
+ Some(Err(e)) => Err(error::to_ruby(e)),
47
+ None => Ok(None),
48
+ }
49
+ }
50
+ }
51
+
52
+ #[magnus::wrap(class = "Microsandbox::Native::MetricsStream", free_immediately, size)]
53
+ pub struct MetricsStream {
54
+ inner: Arc<Mutex<BoxStream<SandboxMetrics>>>,
55
+ }
56
+
57
+ impl MetricsStream {
58
+ pub fn from_stream(
59
+ stream: impl Stream<Item = MicrosandboxResult<SandboxMetrics>> + Send + 'static,
60
+ ) -> Self {
61
+ Self {
62
+ inner: Arc::new(Mutex::new(Box::pin(stream))),
63
+ }
64
+ }
65
+
66
+ /// Next metrics snapshot as a Hash, or nil when the stream ends.
67
+ fn recv(&self) -> Result<Option<RHash>, Error> {
68
+ use futures::StreamExt;
69
+ let inner = Arc::clone(&self.inner);
70
+ match block_on(async move { inner.lock().await.next().await }) {
71
+ Some(Ok(metrics)) => Ok(Some(metrics_to_hash(&metrics))),
72
+ Some(Err(e)) => Err(error::to_ruby(e)),
73
+ None => Ok(None),
74
+ }
75
+ }
76
+ }
77
+
78
+ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
79
+ let logs = native.define_class("LogStream", ruby.class_object())?;
80
+ logs.define_method("recv", method!(LogStream::recv, 0))?;
81
+
82
+ let metrics = native.define_class("MetricsStream", ruby.class_object())?;
83
+ metrics.define_method("recv", method!(MetricsStream::recv, 0))?;
84
+
85
+ Ok(())
86
+ }
@@ -0,0 +1,97 @@
1
+ //! Named persistent volume management: `Microsandbox::Native::Volume`.
2
+ //!
3
+ //! Mirrors `sdk/python/src/volume.rs`. Static async CRUD over the volume store;
4
+ //! results are returned as plain Ruby Hashes/Arrays.
5
+
6
+ use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
7
+ use microsandbox::volume::VolumeHandle;
8
+
9
+ use crate::conv;
10
+ use crate::error;
11
+ use crate::runtime::{block_on, ruby};
12
+
13
+ fn handle_to_hash(h: &VolumeHandle) -> RHash {
14
+ let hash = ruby().hash_new();
15
+ let _ = hash.aset("name", h.name().to_string());
16
+ let _ = hash.aset("kind", h.kind().as_str().to_string());
17
+ let _ = hash.aset("quota_mib", h.quota_mib());
18
+ let _ = hash.aset("used_bytes", h.used_bytes());
19
+ let _ = hash.aset("capacity_bytes", h.capacity_bytes());
20
+ let _ = hash.aset("disk_format", h.disk_format().map(str::to_string));
21
+ let _ = hash.aset("disk_fstype", h.disk_fstype().map(str::to_string));
22
+ let _ = hash.aset(
23
+ "created_at_ms",
24
+ h.created_at().map(|dt| dt.timestamp_millis()),
25
+ );
26
+ let labels = ruby().hash_new();
27
+ for (k, v) in h.labels().iter() {
28
+ let _ = labels.aset(k.clone(), v.clone());
29
+ }
30
+ let _ = hash.aset("labels", labels);
31
+ hash
32
+ }
33
+
34
+ /// Create a named volume. `opts`: kind ("dir"|"disk"), size_mib, quota_mib, labels.
35
+ fn create(name: String, opts: RHash) -> Result<RHash, Error> {
36
+ let mut builder = microsandbox::Volume::builder(&name);
37
+ let kind = conv::opt_string(opts, "kind")?.unwrap_or_else(|| "dir".to_string());
38
+ let size_mib = conv::opt_u32(opts, "size_mib")?;
39
+
40
+ match kind.as_str() {
41
+ "dir" => {
42
+ builder = builder.directory();
43
+ if size_mib.is_some() {
44
+ return Err(error::base_error(
45
+ "size_mib is only supported with kind: \"disk\"",
46
+ ));
47
+ }
48
+ }
49
+ "disk" => {
50
+ builder = builder.disk();
51
+ let size = size_mib
52
+ .ok_or_else(|| error::base_error("size_mib is required with kind: \"disk\""))?;
53
+ builder = builder.size(size);
54
+ }
55
+ other => return Err(error::base_error(format!("unknown volume kind: {other:?}"))),
56
+ }
57
+
58
+ if let Some(quota) = conv::opt_u32(opts, "quota_mib")? {
59
+ builder = builder.quota(quota);
60
+ }
61
+ for (k, v) in conv::opt_string_map(opts, "labels")? {
62
+ builder = builder.label(k, v);
63
+ }
64
+
65
+ let vol = block_on(builder.create()).map_err(error::to_ruby)?;
66
+ let hash = ruby().hash_new();
67
+ hash.aset("name", vol.name().to_string())?;
68
+ hash.aset("path", vol.path().display().to_string())?;
69
+ Ok(hash)
70
+ }
71
+
72
+ fn get(name: String) -> Result<RHash, Error> {
73
+ let handle = block_on(microsandbox::Volume::get(&name)).map_err(error::to_ruby)?;
74
+ Ok(handle_to_hash(&handle))
75
+ }
76
+
77
+ fn list() -> Result<RArray, Error> {
78
+ let handles = block_on(microsandbox::Volume::list()).map_err(error::to_ruby)?;
79
+ let arr = ruby().ary_new();
80
+ for h in handles.iter() {
81
+ arr.push(handle_to_hash(h))?;
82
+ }
83
+ Ok(arr)
84
+ }
85
+
86
+ fn remove(name: String) -> Result<(), Error> {
87
+ block_on(microsandbox::Volume::remove(&name)).map_err(error::to_ruby)
88
+ }
89
+
90
+ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
91
+ let class = native.define_class("Volume", ruby.class_object())?;
92
+ class.define_singleton_method("create", function!(create, 2))?;
93
+ class.define_singleton_method("get", function!(get, 1))?;
94
+ class.define_singleton_method("list", function!(list, 0))?;
95
+ class.define_singleton_method("remove", function!(remove, 1))?;
96
+ Ok(())
97
+ }
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Base class for every error raised by the SDK. Mirrors the Python SDK's
5
+ # flat hierarchy (`sdk/python/microsandbox/errors.py`): each class carries a
6
+ # stable `#code`. The native layer raises the matching subclass based on the
7
+ # core `MicrosandboxError` variant; unmapped variants surface as `Error`.
8
+ class Error < StandardError
9
+ CODE = "microsandbox-error"
10
+
11
+ # The stable, machine-readable error code for this class.
12
+ def self.code
13
+ const_get(:CODE)
14
+ end
15
+
16
+ # The stable, machine-readable error code for this instance.
17
+ def code
18
+ self.class.code
19
+ end
20
+ end
21
+
22
+ # Defines `Microsandbox::<name>` as a subclass of `parent` carrying `code`.
23
+ def self.define_error(name, code, parent = Error)
24
+ klass = Class.new(parent)
25
+ klass.const_set(:CODE, code)
26
+ const_set(name, klass)
27
+ end
28
+ private_class_method :define_error
29
+
30
+ # Configuration / validation errors --------------------------------------
31
+ define_error(:InvalidConfigError, "invalid-config")
32
+
33
+ # Lifecycle errors --------------------------------------------------------
34
+ define_error(:SandboxNotFoundError, "sandbox-not-found")
35
+ define_error(:SandboxNotRunningError, "sandbox-not-running")
36
+ define_error(:SandboxAlreadyExistsError, "sandbox-already-exists")
37
+ define_error(:SandboxStillRunningError, "sandbox-still-running")
38
+
39
+ # Execution errors --------------------------------------------------------
40
+ define_error(:ExecTimeoutError, "exec-timeout")
41
+ define_error(:ExecFailedError, "exec-failed")
42
+
43
+ # Filesystem errors -------------------------------------------------------
44
+ define_error(:FilesystemError, "filesystem-error")
45
+ define_error(:PathNotFoundError, "path-not-found")
46
+
47
+ # Volume / image errors ---------------------------------------------------
48
+ define_error(:VolumeNotFoundError, "volume-not-found")
49
+ define_error(:VolumeAlreadyExistsError, "volume-already-exists")
50
+ define_error(:ImageNotFoundError, "image-not-found")
51
+ define_error(:ImageInUseError, "image-in-use")
52
+ define_error(:ImagePullFailedError, "image-pull-failed")
53
+
54
+ # Networking / secrets errors ---------------------------------------------
55
+ define_error(:NetworkPolicyError, "network-policy-error")
56
+ define_error(:SecretViolationError, "secret-violation")
57
+ define_error(:TlsError, "tls-error")
58
+
59
+ # I/O ---------------------------------------------------------------------
60
+ define_error(:IoError, "io-error")
61
+
62
+ # Metrics errors ----------------------------------------------------------
63
+ define_error(:MetricsDisabledError, "metrics-disabled")
64
+ define_error(:MetricsUnavailableError, "metrics-unavailable")
65
+
66
+ # Runtime compatibility ---------------------------------------------------
67
+ define_error(:UnsupportedOperationError, "unsupported-operation")
68
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # A single event from a streaming execution ({Sandbox#exec_stream}).
5
+ class ExecEvent
6
+ # @return [Symbol] :started, :stdout, :stderr, :exited, :failed, or :stdin_error
7
+ attr_reader :type
8
+ # @return [Integer, nil] pid (for :started)
9
+ attr_reader :pid
10
+ # @return [Integer, nil] exit code (:exited) or errno (:failed/:stdin_error)
11
+ attr_reader :code
12
+ # @return [String, nil] raw bytes (ASCII-8BIT) for :stdout/:stderr, or the
13
+ # message for :failed/:stdin_error
14
+ attr_reader :data
15
+
16
+ def initialize(event)
17
+ @type = event["type"].to_sym
18
+ @pid = event["pid"]
19
+ @code = event["code"]
20
+ @data = event["data"]
21
+ end
22
+
23
+ # @return [String, nil] {#data} decoded as UTF-8 (lenient)
24
+ def text
25
+ @data && @data.dup.force_encoding(Encoding::UTF_8)
26
+ end
27
+
28
+ def started? = @type == :started
29
+ def stdout? = @type == :stdout
30
+ def stderr? = @type == :stderr
31
+ def exited? = @type == :exited
32
+ def failed? = @type == :failed
33
+ def stdin_error? = @type == :stdin_error
34
+
35
+ def inspect
36
+ "#<Microsandbox::ExecEvent type=#{@type}#{@pid ? " pid=#{@pid}" : ""}" \
37
+ "#{@code ? " code=#{@code}" : ""}#{@data ? " data=#{@data.bytesize}B" : ""}>"
38
+ end
39
+ end
40
+
41
+ # The terminal status of a streamed execution, from {ExecHandle#wait}.
42
+ class ExitStatus
43
+ # @return [Integer]
44
+ attr_reader :exit_code
45
+
46
+ def initialize(data)
47
+ @exit_code = data["exit_code"]
48
+ @success = data["success"]
49
+ end
50
+
51
+ def success? = @success
52
+ def failure? = !@success
53
+ end
54
+
55
+ # A writer for a streamed process's stdin, from {ExecHandle#stdin}.
56
+ class ExecStdin
57
+ def initialize(native)
58
+ @native = native
59
+ end
60
+
61
+ # Write data to the process stdin.
62
+ # @return [self]
63
+ def write(data)
64
+ @native.write(data.to_s)
65
+ self
66
+ end
67
+
68
+ # Send EOF.
69
+ # @return [nil]
70
+ def close
71
+ @native.close
72
+ nil
73
+ end
74
+ end
75
+
76
+ # A live, streaming command execution, returned by {Sandbox#exec_stream} and
77
+ # {Sandbox#shell_stream}.
78
+ #
79
+ # Iterate it (it is {Enumerable}) to consume {ExecEvent}s as they arrive, or
80
+ # call {#collect} to drain it into an {ExecOutput}.
81
+ #
82
+ # @example
83
+ # handle = sb.exec_stream("python", ["-u", "script.py"])
84
+ # handle.each do |event|
85
+ # print event.text if event.stdout? || event.stderr?
86
+ # end
87
+ class ExecHandle
88
+ include Enumerable
89
+
90
+ def initialize(native)
91
+ @native = native
92
+ end
93
+
94
+ # @return [String] the correlation id for this execution
95
+ def id
96
+ @native.id
97
+ end
98
+
99
+ # Yield each {ExecEvent} until the stream ends. Returns an Enumerator when
100
+ # called without a block.
101
+ # @yieldparam event [ExecEvent]
102
+ # @return [self, Enumerator]
103
+ def each
104
+ return enum_for(:each) unless block_given?
105
+
106
+ while (event = @native.recv)
107
+ yield ExecEvent.new(event)
108
+ end
109
+ self
110
+ end
111
+
112
+ # Block until the process exits.
113
+ # @return [ExitStatus]
114
+ def wait
115
+ ExitStatus.new(@native.wait)
116
+ end
117
+
118
+ # Drain the stream and collect all output.
119
+ # @return [ExecOutput]
120
+ def collect
121
+ ExecOutput.new(@native.collect)
122
+ end
123
+
124
+ # Send a signal (integer) to the running process.
125
+ # @return [nil]
126
+ def signal(sig)
127
+ @native.signal(Integer(sig))
128
+ nil
129
+ end
130
+
131
+ # Kill the running process (SIGKILL).
132
+ # @return [nil]
133
+ def kill
134
+ @native.kill
135
+ nil
136
+ end
137
+
138
+ # Resize the pseudo-terminal (only meaningful when started with tty: true).
139
+ # @return [nil]
140
+ def resize(rows, cols)
141
+ @native.resize(Integer(rows), Integer(cols))
142
+ nil
143
+ end
144
+
145
+ # The stdin writer, or nil if stdin was not piped. Returned only once.
146
+ # @return [ExecStdin, nil]
147
+ def stdin
148
+ return @stdin if defined?(@stdin)
149
+
150
+ native_sink = @native.take_stdin
151
+ @stdin = native_sink && ExecStdin.new(native_sink)
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # The result of a completed `exec`/`shell` call.
5
+ #
6
+ # `stdout`/`stderr` return the captured bytes decoded as UTF-8 (lenient — the
7
+ # bytes are preserved even if they are not valid UTF-8); use `stdout_bytes`/
8
+ # `stderr_bytes` for the raw ASCII-8BIT bytes.
9
+ class ExecOutput
10
+ # @return [Integer] the process exit code
11
+ attr_reader :exit_code
12
+ # @return [String] raw stdout bytes (ASCII-8BIT)
13
+ attr_reader :stdout_bytes
14
+ # @return [String] raw stderr bytes (ASCII-8BIT)
15
+ attr_reader :stderr_bytes
16
+
17
+ # @param data [Hash] the native exec result hash
18
+ def initialize(data)
19
+ @exit_code = data["exit_code"]
20
+ @success = data["success"]
21
+ @stdout_bytes = data["stdout"]
22
+ @stderr_bytes = data["stderr"]
23
+ end
24
+
25
+ # @return [Boolean] whether the process exited with status 0
26
+ def success?
27
+ @success
28
+ end
29
+
30
+ # @return [Boolean] whether the process exited non-zero
31
+ def failure?
32
+ !@success
33
+ end
34
+
35
+ # @return [String] stdout decoded as UTF-8
36
+ def stdout
37
+ @stdout ||= @stdout_bytes.dup.force_encoding(Encoding::UTF_8)
38
+ end
39
+
40
+ # @return [String] stderr decoded as UTF-8
41
+ def stderr
42
+ @stderr ||= @stderr_bytes.dup.force_encoding(Encoding::UTF_8)
43
+ end
44
+
45
+ # @return [String] stdout decoded as UTF-8 (alias for {#stdout})
46
+ def to_s
47
+ stdout
48
+ end
49
+
50
+ def inspect
51
+ "#<Microsandbox::ExecOutput exit_code=#{@exit_code} success=#{@success} " \
52
+ "stdout=#{stdout.bytesize}B stderr=#{stderr.bytesize}B>"
53
+ end
54
+ end
55
+ end