microsandbox-rb 0.15.0 → 0.17.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.
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Host-side source for secret material (runtime v0.6.17). The only kind is
5
+ # `env`: the secret is read from an environment variable of the **host**
6
+ # process (the one running this gem) when the sandbox is created — the
7
+ # variable's *name* travels to the runtime, never its value.
8
+ #
9
+ # Used for the password of an authenticated SOCKS5 {OutboundProxy}. Mirrors
10
+ # the Python SDK's frozen `SecretSource` dataclass.
11
+ #
12
+ # @example
13
+ # Microsandbox::SecretSource.env("PROXY_PASSWORD")
14
+ class SecretSource
15
+ KINDS = %w[env].freeze
16
+
17
+ # @return [String] the source kind (always `"env"`)
18
+ attr_reader :kind
19
+ # @return [String] the host environment variable name
20
+ attr_reader :var
21
+
22
+ # Resolve the secret from a host environment variable.
23
+ # @param variable [String, Symbol] the variable name (non-empty)
24
+ # @return [SecretSource]
25
+ def self.env(variable)
26
+ new("env", variable)
27
+ end
28
+
29
+ # Coerce a user-facing value into a {SecretSource}: an instance passes
30
+ # through, a Hash must be `{ env: "VAR" }` or the wire form
31
+ # `{ kind: "env", var: "VAR" }`.
32
+ #
33
+ # A rejected value is NEVER rendered into the error: a caller who reaches
34
+ # for `{ value: "hunter2" }` or `{ store: ... }` by analogy with other
35
+ # secret APIs has just handed us a real password, and the exception
36
+ # message is the one place it must not resurface (logs, bug reports).
37
+ # Errors describe the expected shape and, at most, the offending class.
38
+ # @api private
39
+ def self.coerce(value, context = "password")
40
+ case value
41
+ when SecretSource then value
42
+ when Hash
43
+ env = fetch_key(value, :env)
44
+ return new("env", env) unless env.nil?
45
+
46
+ kind = fetch_key(value, :kind)
47
+ var = fetch_key(value, :var)
48
+ if kind.nil? && var.nil?
49
+ raise ArgumentError,
50
+ "#{context}: expects { env: \"VAR\" } naming a host environment variable " \
51
+ "(got a Hash without env:; plaintext password values are not accepted)"
52
+ end
53
+ new(kind, var)
54
+ else
55
+ raise ArgumentError,
56
+ "#{context}: expects a Microsandbox::SecretSource (SecretSource.env(\"VAR\")) " \
57
+ "or a Hash { env: \"VAR\" } (got #{value.class}; plaintext password values " \
58
+ "are not accepted)"
59
+ end
60
+ end
61
+
62
+ # @api private
63
+ def self.fetch_key(hash, key)
64
+ hash.key?(key) ? hash[key] : hash[key.to_s]
65
+ end
66
+ private_class_method :fetch_key
67
+
68
+ # @api private — use {.env}.
69
+ #
70
+ # Retained Strings are private frozen copies: the caller's originals are
71
+ # neither frozen nor aliased, so mutating them later (or a String obtained
72
+ # from {#var}/{#to_h}) cannot change this object's state. Only a
73
+ # String/Symbol kind or var is described in an error, and only by the
74
+ # allowed-shape wording — a nested Hash/other object is named by class so
75
+ # a misplaced secret is never rendered.
76
+ def initialize(kind, var)
77
+ unless kind.is_a?(String) || kind.is_a?(Symbol)
78
+ raise ArgumentError,
79
+ "secret source kind must be \"env\" (got #{kind.class})"
80
+ end
81
+ # An unsupported kind is reported by class only, never by value: a
82
+ # caller who mis-keys a password into `kind:` must not see it echoed.
83
+ unless KINDS.include?(kind.to_s)
84
+ raise ArgumentError,
85
+ "secret source kind must be \"env\" (only environment-backed secret sources " \
86
+ "are supported; got an unsupported #{kind.class})"
87
+ end
88
+ kind = kind.to_s
89
+ unless var.is_a?(String) || var.is_a?(Symbol)
90
+ raise ArgumentError,
91
+ "secret source environment variable must be a String name (got #{var.class})"
92
+ end
93
+ var = var.to_s
94
+ raise ArgumentError, "secret source environment variable must not be empty" if var.empty?
95
+ @kind = kind.dup.freeze
96
+ @var = var.dup.freeze
97
+ freeze
98
+ end
99
+
100
+ # Wire form for the native layer (Python's `_to_dict`).
101
+ # @return [Hash{String => String}]
102
+ def to_h
103
+ {"kind" => @kind, "var" => @var}
104
+ end
105
+
106
+ def ==(other)
107
+ other.is_a?(SecretSource) && other.kind == kind && other.var == var
108
+ end
109
+ alias_method :eql?, :==
110
+
111
+ def hash
112
+ [SecretSource, @kind, @var].hash
113
+ end
114
+
115
+ def inspect
116
+ "#<Microsandbox::SecretSource env=#{@var}>"
117
+ end
118
+ end
119
+
120
+ # An outbound proxy for a sandbox's egress traffic (runtime v0.6.17, upstream
121
+ # #1234 / #1507), passed to {Sandbox.create} via `proxy:`. The runtime's
122
+ # host-side network stack dials the proxy — so the address is resolved from
123
+ # the **host**, and `127.0.0.1` names the host's loopback, not the guest's.
124
+ #
125
+ # - {socks4} — SOCKS4 for TCP, with an optional `user_id:` sent in the
126
+ # handshake.
127
+ # - {socks5} — SOCKS5 for TCP and non-DNS UDP; chain {#credentials} for
128
+ # username/password authentication, the password coming from a host
129
+ # {SecretSource} (only its variable name reaches the runtime).
130
+ #
131
+ # The proxy applies uniformly to TLS-intercepted and bypassed/plain TCP; the
132
+ # egress policy (`network:`) still decides which destinations may be reached.
133
+ # Not accepted by the cloud backend (`UnsupportedError`).
134
+ #
135
+ # Instances are immutable: {#credentials} returns a new proxy. `proxy:` also
136
+ # accepts the equivalent plain Hash (see {.coerce}).
137
+ #
138
+ # @example
139
+ # Sandbox.create("worker", image: "python",
140
+ # proxy: Microsandbox::OutboundProxy.socks5("127.0.0.1:1080"))
141
+ # Sandbox.create("worker", image: "python",
142
+ # proxy: Microsandbox::OutboundProxy.socks5("10.0.0.5:1080")
143
+ # .credentials("sandbox", Microsandbox::SecretSource.env("PROXY_PASSWORD")))
144
+ # Sandbox.create("worker", image: "python",
145
+ # proxy: { protocol: :socks4, address: "127.0.0.1:1080", user_id: "ci" })
146
+ #
147
+ # Mirrors the Python SDK's frozen `OutboundProxy` dataclass (`socks4` /
148
+ # `socks5` / `credentials`).
149
+ class OutboundProxy
150
+ PROTOCOLS = %w[socks4 socks5].freeze
151
+
152
+ # @return [String] `"socks4"` or `"socks5"`
153
+ attr_reader :protocol
154
+ # @return [String] the proxy's `IP:port` address, as seen from the host
155
+ attr_reader :address
156
+ # @return [String, nil] SOCKS4 user ID
157
+ attr_reader :user_id
158
+ # @return [String, nil] SOCKS5 username
159
+ attr_reader :username
160
+ # @return [SecretSource, nil] SOCKS5 password source
161
+ attr_reader :password
162
+
163
+ # A SOCKS4 outbound proxy.
164
+ # @param address [String] `IP:port` of the proxy, resolved from the host
165
+ # @param user_id [String, nil] optional user ID sent in the SOCKS4 handshake
166
+ # @return [OutboundProxy]
167
+ def self.socks4(address, user_id: nil)
168
+ new(protocol: "socks4", address: address, user_id: user_id)
169
+ end
170
+
171
+ # A SOCKS5 outbound proxy (unauthenticated; chain {#credentials}).
172
+ # @param address [String] `IP:port` of the proxy, resolved from the host
173
+ # @return [OutboundProxy]
174
+ def self.socks5(address)
175
+ new(protocol: "socks5", address: address)
176
+ end
177
+
178
+ # Coerce a user-facing `proxy:` value into the normalized wire Hash:
179
+ # an {OutboundProxy}, or a Hash `{ protocol: :socks4|:socks5, address:,
180
+ # user_id:, credentials: { username:, password: SecretSource | { env: } } }`.
181
+ # @api private
182
+ # @return [Hash{String => untyped}]
183
+ def self.coerce(value)
184
+ case value
185
+ # No re-validation for an existing instance, and none is needed: the
186
+ # constructor is the only writer, the object is frozen (no ivar can be
187
+ # reassigned), and every retained String is a private frozen copy, so
188
+ # {#to_h} is a pure function of already-validated state. Re-running the
189
+ # checks would only re-examine data the constructor itself produced.
190
+ when OutboundProxy then value.to_h
191
+ when Hash then from_hash(value).to_h
192
+ else
193
+ raise ArgumentError,
194
+ "proxy: expects a Microsandbox::OutboundProxy (OutboundProxy.socks4/socks5) " \
195
+ "or a Hash { protocol:, address:, ... } (got #{value.class})"
196
+ end
197
+ end
198
+
199
+ # @api private
200
+ def self.from_hash(hash)
201
+ protocol = fetch_key(hash, :protocol)
202
+ raise ArgumentError, "proxy: requires protocol: (:socks4 or :socks5)" if protocol.nil?
203
+ address = fetch_key(hash, :address)
204
+ raise ArgumentError, "proxy: requires address:" if address.nil?
205
+ user_id = fetch_key(hash, :user_id)
206
+ credentials = fetch_key(hash, :credentials)
207
+ username = nil
208
+ password = nil
209
+ unless credentials.nil?
210
+ unless credentials.is_a?(Hash)
211
+ raise ArgumentError,
212
+ "proxy credentials: must be a Hash { username:, password: } (got #{credentials.class})"
213
+ end
214
+ username = fetch_key(credentials, :username)
215
+ password = fetch_key(credentials, :password)
216
+ if username.nil? || password.nil?
217
+ raise ArgumentError, "proxy credentials: requires both username: and password:"
218
+ end
219
+ password = SecretSource.coerce(password, "proxy credentials password")
220
+ end
221
+ new(protocol: protocol, address: address, user_id: user_id,
222
+ username: username, password: password)
223
+ end
224
+
225
+ # @api private
226
+ def self.fetch_key(hash, key)
227
+ hash.key?(key) ? hash[key] : hash[key.to_s]
228
+ end
229
+ private_class_method :fetch_key
230
+
231
+ # @api private — use {.socks4} / {.socks5}.
232
+ def initialize(protocol:, address:, user_id: nil, username: nil, password: nil)
233
+ unless protocol.is_a?(String) || protocol.is_a?(Symbol)
234
+ raise ArgumentError,
235
+ "unsupported outbound proxy protocol (got #{protocol.class}; expected :socks4 or :socks5)"
236
+ end
237
+ protocol = protocol.to_s.downcase
238
+ unless PROTOCOLS.include?(protocol)
239
+ raise ArgumentError,
240
+ "unsupported outbound proxy protocol (expected :socks4 or :socks5)"
241
+ end
242
+ unless address.is_a?(String)
243
+ raise ArgumentError,
244
+ "proxy address must be a non-empty \"IP:port\" String (got #{address.class})"
245
+ end
246
+ if address.empty?
247
+ raise ArgumentError, "proxy address must be a non-empty \"IP:port\" String (got \"\")"
248
+ end
249
+ # Same rules as the Python SDK's OutboundProxy.__post_init__; the address
250
+ # itself is parsed by the core (which reports e.g. "invalid SOCKS5 proxy
251
+ # address" as an InvalidConfigError at create time).
252
+ # Identifiers are documented as Strings (RBS: String?). A misplaced
253
+ # container (a credentials Hash, an Array) must not be stringified into
254
+ # the wire hash / #inspect; reject it by class, never rendering it.
255
+ unless user_id.nil? || user_id.is_a?(String) || user_id.is_a?(Symbol)
256
+ raise ArgumentError, "SOCKS4 user_id must be a String (got #{user_id.class})"
257
+ end
258
+ unless username.nil? || username.is_a?(String) || username.is_a?(Symbol)
259
+ raise ArgumentError, "SOCKS5 username must be a String (got #{username.class})"
260
+ end
261
+ if protocol != "socks4" && !user_id.nil?
262
+ raise ArgumentError, "user_id is only supported for SOCKS4 proxies"
263
+ end
264
+ if protocol != "socks5" && !(username.nil? && password.nil?)
265
+ raise ArgumentError, "credentials are only supported for SOCKS5 proxies"
266
+ end
267
+ if username.nil? != password.nil?
268
+ raise ArgumentError, "SOCKS5 username and password must be provided together"
269
+ end
270
+ unless password.nil? || password.is_a?(SecretSource)
271
+ raise ArgumentError,
272
+ "SOCKS5 password must be a Microsandbox::SecretSource (SecretSource.env(\"VAR\")), " \
273
+ "got #{password.class}"
274
+ end
275
+ # Private frozen copies (see SecretSource#initialize): the caller keeps
276
+ # its own, unfrozen Strings; readers and {#to_h} hand out these frozen
277
+ # ones, so neither side can mutate stored state after construction.
278
+ @protocol = protocol.dup.freeze
279
+ @address = address.dup.freeze
280
+ @user_id = user_id&.to_s&.dup&.freeze
281
+ @username = username&.to_s&.dup&.freeze
282
+ @password = password
283
+ freeze
284
+ end
285
+
286
+ # Username/password authentication for a SOCKS5 proxy. Returns a **new**
287
+ # proxy; the receiver is unchanged.
288
+ # @param username [String]
289
+ # @param password [SecretSource] host-side password source
290
+ # ({SecretSource.env}); its variable *name* is what reaches the runtime
291
+ # @return [OutboundProxy]
292
+ # @raise [ArgumentError] on a SOCKS4 proxy
293
+ def credentials(username, password)
294
+ raise ArgumentError, "credentials are only supported for SOCKS5 proxies" unless socks5?
295
+ self.class.new(protocol: @protocol, address: @address,
296
+ username: username, password: SecretSource.coerce(password, "proxy credentials password"))
297
+ end
298
+
299
+ def socks4? = @protocol == "socks4"
300
+
301
+ def socks5? = @protocol == "socks5"
302
+
303
+ # Wire form for the native layer (Python's `_to_dict`): `user_id` only when
304
+ # set, `credentials` only when both halves are set.
305
+ # @return [Hash{String => untyped}]
306
+ def to_h
307
+ h = {"protocol" => @protocol, "address" => @address}
308
+ h["user_id"] = @user_id unless @user_id.nil?
309
+ if @username && @password
310
+ h["credentials"] = {"username" => @username, "password" => @password.to_h}
311
+ end
312
+ h
313
+ end
314
+
315
+ def ==(other)
316
+ other.is_a?(OutboundProxy) && other.to_h == to_h
317
+ end
318
+ alias_method :eql?, :==
319
+
320
+ def hash
321
+ [OutboundProxy, to_h].hash
322
+ end
323
+
324
+ # Never renders a password value — there is none; only the env var name.
325
+ def inspect
326
+ parts = ["#{@protocol} #{@address}"]
327
+ parts << "user_id=#{@user_id}" if @user_id
328
+ parts << "username=#{@username} password=env:#{@password.var}" if @username
329
+ "#<Microsandbox::OutboundProxy #{parts.join(" ")}>"
330
+ end
331
+ end
332
+ end