microsandbox-rb 0.5.8 → 0.5.10

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,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Factory for network-policy **rule destinations**. A destination is what an
5
+ # egress rule reaches (or, for an ingress rule, the connecting peer). Use the
6
+ # explicit constructors for unambiguous typing, or pass a plain String to
7
+ # {Rule.allow}/{Rule.deny} for shorthand classification (see {Rule}).
8
+ #
9
+ # @example
10
+ # Microsandbox::Destination.cidr("10.0.0.0/8")
11
+ # Microsandbox::Destination.domain("api.openai.com")
12
+ # Microsandbox::Destination.group(:public)
13
+ #
14
+ # Mirrors the `Destination` factory in the official Python/Node/Go SDKs.
15
+ module Destination
16
+ module_function
17
+
18
+ # Match any destination.
19
+ def any = {"destination_kind" => "any"}
20
+
21
+ # A single IP address (stored as a /32 or /128).
22
+ def ip(value) = {"destination_kind" => "ip", "destination" => value.to_s}
23
+
24
+ # An IP network in CIDR notation (e.g. "10.0.0.0/8").
25
+ def cidr(value) = {"destination_kind" => "cidr", "destination" => value.to_s}
26
+
27
+ # An exact domain name (matched against the resolved-hostname cache / SNI).
28
+ def domain(value) = {"destination_kind" => "domain", "destination" => value.to_s}
29
+
30
+ # A domain suffix — matches the apex and any subdomain (e.g. ".internal").
31
+ def domain_suffix(value) = {"destination_kind" => "domain_suffix", "destination" => value.to_s}
32
+
33
+ # A predefined group: :public, :loopback, :private, :link_local, :metadata,
34
+ # :multicast, or :host.
35
+ def group(value) = {"destination_kind" => "group", "destination" => value.to_s.tr("_", "-")}
36
+ end
37
+
38
+ # Factory for a single network-policy **rule**. A rule pairs an action
39
+ # (allow/deny) with a direction, a destination, and optional protocol/port
40
+ # filters; rules are evaluated first-match-wins per direction.
41
+ #
42
+ # @example
43
+ # Microsandbox::Rule.allow(destination: "1.1.1.1", protocol: :tcp, port: "443")
44
+ # Microsandbox::Rule.deny(destination: Microsandbox::Destination.group(:metadata))
45
+ # Microsandbox::Rule.allow(direction: :ingress, destination: "10.0.0.0/8", port: "8000-9000")
46
+ #
47
+ # `destination:` accepts a {Destination} Hash, a shorthand String
48
+ # ("*", "public", "1.1.1.1", "10.0.0.0/8", ".internal", "api.example.com"),
49
+ # or nil (any). Mirrors the `Rule` factory in the official SDKs.
50
+ module Rule
51
+ module_function
52
+
53
+ # Build an allow rule. See {Rule} for argument semantics.
54
+ # @return [Hash]
55
+ def allow(destination: nil, direction: :egress, protocol: nil, protocols: nil, port: nil, ports: nil)
56
+ build("allow", destination, direction, protocol, protocols, port, ports)
57
+ end
58
+
59
+ # Build a deny rule.
60
+ # @return [Hash]
61
+ def deny(destination: nil, direction: :egress, protocol: nil, protocols: nil, port: nil, ports: nil)
62
+ build("deny", destination, direction, protocol, protocols, port, ports)
63
+ end
64
+
65
+ # @api private
66
+ def build(action, destination, direction, protocol, protocols, port, ports)
67
+ rule = {"action" => action, "direction" => direction.to_s}
68
+ rule.merge!(normalize_destination(destination))
69
+ protos = (Array(protocols) + Array(protocol)).compact.map(&:to_s)
70
+ rule["protocols"] = protos unless protos.empty?
71
+ prts = (Array(ports) + Array(port)).compact.map(&:to_s)
72
+ rule["ports"] = prts unless prts.empty?
73
+ rule
74
+ end
75
+
76
+ # @api private
77
+ def normalize_destination(dest)
78
+ case dest
79
+ when nil then {}
80
+ when Hash then dest.each_with_object({}) { |(k, v), a| a[k.to_s] = v }
81
+ when String, Symbol then {"destination" => dest.to_s}
82
+ else raise ArgumentError, "invalid rule destination: #{dest.inspect}"
83
+ end
84
+ end
85
+ end
86
+
87
+ # A sandbox network policy: a preset, or a custom set of allow/deny {Rule}s
88
+ # with per-direction default actions and bulk domain denials.
89
+ #
90
+ # Pass to {Sandbox.create} via `network:` — either a {NetworkPolicy}, a preset
91
+ # name (String/Symbol), or a plain Hash with the same keys as {custom}.
92
+ #
93
+ # @example presets
94
+ # Sandbox.create("b", image: "alpine", network: NetworkPolicy.public_only)
95
+ # Sandbox.create("b", image: "alpine", network: :none)
96
+ #
97
+ # @example custom
98
+ # policy = Microsandbox::NetworkPolicy.custom(
99
+ # default_egress: :deny,
100
+ # rules: [
101
+ # Microsandbox::Rule.allow(destination: "api.openai.com", protocol: :tcp, port: "443"),
102
+ # ],
103
+ # deny_domain_suffixes: [".ads.example"],
104
+ # )
105
+ # Sandbox.create("b", image: "alpine", network: policy)
106
+ #
107
+ # Mirrors `NetworkPolicy` / `Network` in the official Python/Node/Go SDKs.
108
+ class NetworkPolicy
109
+ # Canonical preset names keyed by every accepted alias.
110
+ PRESET_ALIASES = {
111
+ "none" => "none", "disabled" => "none", "disable" => "none", "airgapped" => "none",
112
+ "public" => "public_only", "public_only" => "public_only", "public-only" => "public_only",
113
+ "default" => "public_only",
114
+ "all" => "allow_all", "allow_all" => "allow_all", "allow-all" => "allow_all",
115
+ "non_local" => "non_local", "non-local" => "non_local", "nonlocal" => "non_local"
116
+ }.freeze
117
+
118
+ class << self
119
+ # @return [NetworkPolicy] allow only public internet (the default)
120
+ def public_only = preset("public_only")
121
+
122
+ # @return [NetworkPolicy] block all network access
123
+ def none = preset("none")
124
+
125
+ # @return [NetworkPolicy] permit all traffic
126
+ def allow_all = preset("allow_all")
127
+
128
+ # @return [NetworkPolicy] allow public internet plus private/LAN egress
129
+ def non_local = preset("non_local")
130
+
131
+ # @return [NetworkPolicy] a bare preset policy
132
+ def preset(name)
133
+ new("preset" => canonical_preset(name))
134
+ end
135
+
136
+ # Build a custom policy — an ordered rule list with per-direction default
137
+ # actions. A custom policy stands on its own (no preset); to start from a
138
+ # preset, use the preset factories (optionally with `deny_domains:` via the
139
+ # Hash form passed to {Sandbox.create}). `preset:` and custom rules/defaults
140
+ # are mutually exclusive, mirroring the official SDKs.
141
+ #
142
+ # @param default_egress [:deny, :allow, nil] fall-through for unmatched
143
+ # outbound traffic (default :deny)
144
+ # @param default_ingress [:deny, :allow, nil] fall-through for unmatched
145
+ # inbound traffic (default :allow)
146
+ # @param rules [Array<Hash>] ordered {Rule}s (first match wins per direction)
147
+ # @param deny_domains [Array<String>] exact domains to deny egress to
148
+ # (prepended, so they outrank later allow rules)
149
+ # @param deny_domain_suffixes [Array<String>] domain suffixes to deny
150
+ # @return [NetworkPolicy]
151
+ def custom(default_egress: :deny, default_ingress: :allow, rules: [],
152
+ deny_domains: [], deny_domain_suffixes: [])
153
+ h = {}
154
+ h["default_egress"] = action_str(default_egress) unless default_egress.nil?
155
+ h["default_ingress"] = action_str(default_ingress) unless default_ingress.nil?
156
+ h["rules"] = Array(rules).map { |r| normalize_rule(r) }
157
+ add_deny_lists(h, deny_domains, deny_domain_suffixes)
158
+ new(h)
159
+ end
160
+
161
+ # Coerce a user-facing `network:` value into a normalized wire Hash.
162
+ # @api private
163
+ def coerce(network)
164
+ case network
165
+ when NetworkPolicy then network.to_h
166
+ when String, Symbol then {"preset" => canonical_preset(network)}
167
+ when Hash then from_hash(network)
168
+ else
169
+ raise ArgumentError,
170
+ "network: expects a preset name, a Microsandbox::NetworkPolicy, or a Hash " \
171
+ "(got #{network.class})"
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ # A `network:` Hash is either a preset (`preset:` + optional deny lists) or
178
+ # a custom policy (`default_egress:`/`default_ingress:`/`rules:` + optional
179
+ # deny lists). The two are mutually exclusive: a preset already defines its
180
+ # rules and defaults, so layering custom rules/defaults on top would silently
181
+ # override them (and diverge from the official SDKs, where preset and custom
182
+ # are separate paths). A bare preset (only `preset:`, no deny lists) is
183
+ # routed to the preset path by {Sandbox.create}, so its own defaults apply.
184
+ def from_hash(hash)
185
+ sym = hash.transform_keys(&:to_sym)
186
+ if sym.key?(:preset)
187
+ if sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
188
+ raise ArgumentError,
189
+ "network preset: cannot be combined with rules:/default_egress:/" \
190
+ "default_ingress: (the preset already defines its rules and defaults); " \
191
+ "only deny_domains:/deny_domain_suffixes: may be layered on a preset"
192
+ end
193
+ h = {"preset" => canonical_preset(sym[:preset])}
194
+ add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
195
+ h
196
+ elsif sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
197
+ # An explicit custom policy: the caller chose the rule list and/or the
198
+ # fall-through defaults (which default to :deny / :allow).
199
+ custom(
200
+ default_egress: sym.fetch(:default_egress, :deny),
201
+ default_ingress: sym.fetch(:default_ingress, :allow),
202
+ rules: sym[:rules] || [],
203
+ deny_domains: sym[:deny_domains] || [],
204
+ deny_domain_suffixes: sym[:deny_domain_suffixes] || []
205
+ ).to_h
206
+ else
207
+ # Deny-list-only shorthand (`network: { deny_domains: [...] }`): keep the
208
+ # rest of the network reachable and just block the listed domains, using
209
+ # permissive defaults — mirrors the official SDKs' "full network minus
210
+ # blocked domains" semantics. An empty Hash is a no-op (leaves the
211
+ # default policy in place).
212
+ dd = Array(sym[:deny_domains]).map(&:to_s)
213
+ ds = Array(sym[:deny_domain_suffixes]).map(&:to_s)
214
+ return {} if dd.empty? && ds.empty?
215
+
216
+ h = {"default_egress" => "allow", "default_ingress" => "allow"}
217
+ add_deny_lists(h, dd, ds)
218
+ h
219
+ end
220
+ end
221
+
222
+ # Append `deny_domains`/`deny_domain_suffixes` to a wire Hash, omitting
223
+ # empty lists. Returns the Hash.
224
+ def add_deny_lists(h, deny_domains, deny_domain_suffixes)
225
+ dd = Array(deny_domains).map(&:to_s)
226
+ h["deny_domains"] = dd unless dd.empty?
227
+ ds = Array(deny_domain_suffixes).map(&:to_s)
228
+ h["deny_domain_suffixes"] = ds unless ds.empty?
229
+ h
230
+ end
231
+
232
+ def canonical_preset(name)
233
+ key = name.to_s.downcase
234
+ PRESET_ALIASES[key] ||
235
+ raise(ArgumentError,
236
+ "unknown network preset #{name.inspect} " \
237
+ "(expected one of public_only/none/allow_all/non_local)")
238
+ end
239
+
240
+ def action_str(action)
241
+ case action.to_s.downcase
242
+ when "allow" then "allow"
243
+ when "deny" then "deny"
244
+ else raise ArgumentError, "network action must be :allow or :deny (got #{action.inspect})"
245
+ end
246
+ end
247
+
248
+ # Canonicalize a rule Hash (from the {Rule} factory or hand-written) into
249
+ # the wire shape the native parser reads. Accepts singular `protocol`/`port`
250
+ # (the spelling the Go/Python `PolicyRule` use) as well as the plural
251
+ # `protocols`/`ports`, and a `destination` that is a shorthand String or a
252
+ # {Destination} Hash — so a hand-written `{ action:, destination:, protocol:,
253
+ # port: }` rule behaves identically to a factory-built one (without this, a
254
+ # singular `protocol`/`port` was silently dropped, widening the rule).
255
+ def normalize_rule(rule)
256
+ unless rule.is_a?(Hash)
257
+ raise ArgumentError, "rule must be a Hash (use Microsandbox::Rule.allow/deny): #{rule.inspect}"
258
+ end
259
+ sym = rule.transform_keys { |k| k.to_s.to_sym }
260
+ out = {}
261
+ out["action"] = sym[:action].to_s if sym[:action]
262
+ out["direction"] = sym[:direction].to_s if sym[:direction]
263
+ normalize_rule_destination(sym, out)
264
+ protos = (Array(sym[:protocols]) + Array(sym[:protocol])).compact.map(&:to_s)
265
+ out["protocols"] = protos unless protos.empty?
266
+ ports = (Array(sym[:ports]) + Array(sym[:port])).compact.map(&:to_s)
267
+ out["ports"] = ports unless ports.empty?
268
+ out
269
+ end
270
+
271
+ # Resolve a rule's destination (explicit kind+value, a {Destination} Hash,
272
+ # or a shorthand String) onto the wire `out` Hash.
273
+ def normalize_rule_destination(sym, out)
274
+ if sym.key?(:destination_kind)
275
+ out["destination_kind"] = sym[:destination_kind].to_s
276
+ out["destination"] = sym[:destination].to_s unless sym[:destination].nil?
277
+ elsif sym[:destination].is_a?(Hash)
278
+ dest = sym[:destination].transform_keys(&:to_s)
279
+ out["destination_kind"] = dest["destination_kind"].to_s if dest["destination_kind"]
280
+ out["destination"] = dest["destination"].to_s if dest.key?("destination")
281
+ elsif !sym[:destination].nil?
282
+ out["destination"] = sym[:destination].to_s
283
+ end
284
+ end
285
+ end
286
+
287
+ def initialize(wire)
288
+ @wire = wire
289
+ end
290
+
291
+ # @return [Hash] the normalized wire representation
292
+ def to_h
293
+ @wire
294
+ end
295
+
296
+ def inspect
297
+ "#<Microsandbox::NetworkPolicy #{@wire.inspect}>"
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Factory for **rootfs patches** — modifications applied to a sandbox's root
5
+ # filesystem *before* the microVM boots. Pass them to {Sandbox.create} via the
6
+ # `patches:` keyword:
7
+ #
8
+ # @example
9
+ # Microsandbox::Sandbox.create("box", image: "alpine",
10
+ # patches: [
11
+ # Microsandbox::Patch.text("/etc/app.conf", "key = value\n", mode: 0o644),
12
+ # Microsandbox::Patch.mkdir("/opt/app"),
13
+ # Microsandbox::Patch.copy_file("./cert.pem", "/etc/ssl/app.pem"),
14
+ # Microsandbox::Patch.symlink("/etc/app.conf", "/etc/app.link"),
15
+ # Microsandbox::Patch.remove("/etc/motd"),
16
+ # ]) do |sb|
17
+ # # ...
18
+ # end
19
+ #
20
+ # Patches apply to OverlayFS (OCI) and bind rootfs; they are **not** compatible
21
+ # with disk-image roots. Each factory returns a plain Hash, so a patch list is
22
+ # just an Array of Hashes — you may also build them by hand. Mirrors the
23
+ # `Patch` factory in the official Python/Node/Go SDKs.
24
+ module Patch
25
+ module_function
26
+
27
+ # Write UTF-8 text to a file, creating it (or replacing it when +replace+).
28
+ # @param path [String] absolute guest path
29
+ # @param content [String] text to write
30
+ # @param mode [Integer, nil] file mode (e.g. 0o644)
31
+ # @param replace [Boolean] allow shadowing a path already in the rootfs
32
+ # @return [Hash]
33
+ def text(path, content, mode: nil, replace: false)
34
+ h = {"kind" => "text", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false}
35
+ h["mode"] = Integer(mode) unless mode.nil?
36
+ h
37
+ end
38
+
39
+ # Write raw bytes to a file (binary-safe; content may contain NUL).
40
+ # @param path [String] absolute guest path
41
+ # @param content [String] raw bytes to write
42
+ # @param mode [Integer, nil] file mode
43
+ # @param replace [Boolean]
44
+ # @return [Hash]
45
+ def file(path, content, mode: nil, replace: false)
46
+ h = {"kind" => "file", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false}
47
+ h["mode"] = Integer(mode) unless mode.nil?
48
+ h
49
+ end
50
+
51
+ # Append text to an existing file. For OCI roots, a file living only in a
52
+ # lower image layer is copied up first, then appended.
53
+ # @return [Hash]
54
+ def append(path, content)
55
+ {"kind" => "append", "path" => path.to_s, "content" => content.to_s}
56
+ end
57
+
58
+ # Copy a host file into the rootfs.
59
+ # @param src [String] host path
60
+ # @param dst [String] absolute guest destination
61
+ # @param mode [Integer, nil] file mode (preserves source mode when nil)
62
+ # @param replace [Boolean]
63
+ # @return [Hash]
64
+ def copy_file(src, dst, mode: nil, replace: false)
65
+ h = {"kind" => "copy_file", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false}
66
+ h["mode"] = Integer(mode) unless mode.nil?
67
+ h
68
+ end
69
+
70
+ # Copy a host directory (recursively) into the rootfs.
71
+ # @return [Hash]
72
+ def copy_dir(src, dst, replace: false)
73
+ {"kind" => "copy_dir", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false}
74
+ end
75
+
76
+ # Create a symlink at +link+ pointing to +target+.
77
+ # @return [Hash]
78
+ def symlink(target, link, replace: false)
79
+ {"kind" => "symlink", "target" => target.to_s, "link" => link.to_s, "replace" => replace ? true : false}
80
+ end
81
+
82
+ # Create a directory (idempotent — no error if it already exists).
83
+ # @param path [String] absolute guest path
84
+ # @param mode [Integer, nil] directory mode (e.g. 0o755)
85
+ # @return [Hash]
86
+ def mkdir(path, mode: nil)
87
+ h = {"kind" => "mkdir", "path" => path.to_s}
88
+ h["mode"] = Integer(mode) unless mode.nil?
89
+ h
90
+ end
91
+
92
+ # Remove a file or directory (idempotent — no error if absent).
93
+ # @return [Hash]
94
+ def remove(path)
95
+ {"kind" => "remove", "path" => path.to_s}
96
+ end
97
+ end
98
+ end