kobako 0.26.0 → 0.28.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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +103 -0
  4. data/Cargo.lock +117 -140
  5. data/README.md +156 -118
  6. data/ROADMAP.md +23 -22
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +38 -0
  9. data/crates/kobako-runtime/Cargo.toml +2 -2
  10. data/crates/kobako-runtime/README.md +10 -12
  11. data/crates/kobako-runtime/src/error.rs +8 -9
  12. data/crates/kobako-runtime/src/lib.rs +1 -1
  13. data/crates/kobako-runtime/src/profile.rs +1 -1
  14. data/crates/kobako-runtime/src/runtime.rs +31 -17
  15. data/crates/kobako-runtime/src/snapshot.rs +13 -10
  16. data/crates/kobako-runtime/src/yielder.rs +9 -6
  17. data/crates/kobako-transport/CHANGELOG.md +35 -0
  18. data/crates/kobako-transport/Cargo.toml +1 -1
  19. data/crates/kobako-transport/README.md +43 -27
  20. data/crates/kobako-transport/src/abi.rs +11 -14
  21. data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
  22. data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
  23. data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
  24. data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
  25. data/crates/kobako-transport/src/envelope/reply.rs +77 -53
  26. data/crates/kobako-transport/src/envelope/request.rs +295 -0
  27. data/crates/kobako-transport/src/envelope/run.rs +1 -2
  28. data/crates/kobako-transport/src/envelope/tag.rs +49 -0
  29. data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
  30. data/crates/kobako-transport/src/envelope.rs +19 -12
  31. data/crates/kobako-transport/src/lib.rs +0 -2
  32. data/crates/kobako-wasmtime/CHANGELOG.md +51 -0
  33. data/crates/kobako-wasmtime/Cargo.toml +7 -5
  34. data/crates/kobako-wasmtime/README.md +8 -10
  35. data/crates/kobako-wasmtime/src/abi.rs +5 -8
  36. data/crates/kobako-wasmtime/src/ambient.rs +2 -2
  37. data/crates/kobako-wasmtime/src/cache.rs +25 -59
  38. data/crates/kobako-wasmtime/src/capture.rs +4 -11
  39. data/crates/kobako-wasmtime/src/config.rs +11 -14
  40. data/crates/kobako-wasmtime/src/dispatch.rs +70 -80
  41. data/crates/kobako-wasmtime/src/driver.rs +133 -86
  42. data/crates/kobako-wasmtime/src/exports.rs +88 -34
  43. data/crates/kobako-wasmtime/src/guest_mem.rs +145 -138
  44. data/crates/kobako-wasmtime/src/instance_pre.rs +11 -22
  45. data/crates/kobako-wasmtime/src/invocation.rs +55 -372
  46. data/crates/kobako-wasmtime/src/io.rs +286 -0
  47. data/crates/kobako-wasmtime/src/lib.rs +3 -24
  48. data/crates/kobako-wasmtime/src/limiter.rs +253 -0
  49. data/crates/kobako-wasmtime/src/trap.rs +32 -45
  50. data/crates/kobako-wasmtime/tests/preamble_flags.rs +69 -0
  51. data/crates/kobako-wasmtime/tests/snippet_compile.rs +94 -0
  52. data/crates/kobako-wasmtime/tests/support/mod.rs +34 -0
  53. data/crates/kobako-wasmtime/tests/unsupported_call.rs +50 -0
  54. data/data/kobako.wasm +0 -0
  55. data/ext/kobako/Cargo.toml +2 -2
  56. data/ext/kobako/src/runtime/bridge.rs +38 -47
  57. data/ext/kobako/src/runtime/errors.rs +26 -32
  58. data/ext/kobako/src/runtime/gvl.rs +4 -12
  59. data/ext/kobako/src/runtime.rs +169 -212
  60. data/lib/kobako/capture.rb +1 -4
  61. data/lib/kobako/catalog/extensions.rb +21 -59
  62. data/lib/kobako/catalog/flags.rb +41 -0
  63. data/lib/kobako/catalog/handles.rb +7 -54
  64. data/lib/kobako/catalog/services.rb +7 -61
  65. data/lib/kobako/catalog/snippets.rb +42 -66
  66. data/lib/kobako/catalog.rb +2 -10
  67. data/lib/kobako/codec/decoder.rb +9 -28
  68. data/lib/kobako/codec/encoder.rb +3 -22
  69. data/lib/kobako/codec/error.rb +6 -27
  70. data/lib/kobako/codec/ext_types.rb +72 -94
  71. data/lib/kobako/codec/handle_walk.rb +65 -130
  72. data/lib/kobako/codec/nesting.rb +3 -8
  73. data/lib/kobako/codec/state.rb +4 -10
  74. data/lib/kobako/codec/utils.rb +6 -34
  75. data/lib/kobako/codec.rb +3 -22
  76. data/lib/kobako/constant_name.rb +8 -0
  77. data/lib/kobako/context.rb +51 -112
  78. data/lib/kobako/errors.rb +51 -127
  79. data/lib/kobako/execution.rb +16 -21
  80. data/lib/kobako/extension.rb +23 -40
  81. data/lib/kobako/handle.rb +7 -12
  82. data/lib/kobako/outcome.rb +51 -80
  83. data/lib/kobako/payload/arguments.rb +1 -16
  84. data/lib/kobako/payload.rb +4 -12
  85. data/lib/kobako/pool.rb +23 -53
  86. data/lib/kobako/runtime.rb +7 -21
  87. data/lib/kobako/sandbox.rb +96 -133
  88. data/lib/kobako/sandbox_options.rb +38 -84
  89. data/lib/kobako/snippet/binary.rb +1 -16
  90. data/lib/kobako/snippet/source.rb +1 -16
  91. data/lib/kobako/snippet.rb +1 -9
  92. data/lib/kobako/transport/call.rb +1 -14
  93. data/lib/kobako/transport/dispatcher.rb +112 -195
  94. data/lib/kobako/transport/error.rb +6 -20
  95. data/lib/kobako/transport/exposure.rb +5 -10
  96. data/lib/kobako/transport/reflection.rb +39 -50
  97. data/lib/kobako/transport/run.rb +17 -71
  98. data/lib/kobako/transport/yielder.rb +21 -73
  99. data/lib/kobako/transport.rb +3 -15
  100. data/lib/kobako/unresolved.rb +4 -11
  101. data/lib/kobako/usage.rb +2 -6
  102. data/lib/kobako/version.rb +1 -1
  103. data/sig/kobako/catalog/extensions.rbs +2 -2
  104. data/sig/kobako/catalog/flags.rbs +17 -0
  105. data/sig/kobako/catalog/services.rbs +0 -6
  106. data/sig/kobako/catalog/snippets.rbs +4 -4
  107. data/sig/kobako/codec/ext_types.rbs +10 -8
  108. data/sig/kobako/codec/handle_walk.rbs +9 -7
  109. data/sig/kobako/codec/utils.rbs +2 -2
  110. data/sig/kobako/codec.rbs +2 -2
  111. data/sig/kobako/constant_name.rbs +3 -0
  112. data/sig/kobako/context.rbs +2 -3
  113. data/sig/kobako/errors.rbs +6 -0
  114. data/sig/kobako/outcome.rbs +6 -8
  115. data/sig/kobako/runtime.rbs +12 -8
  116. data/sig/kobako/sandbox.rbs +4 -0
  117. data/sig/kobako/sandbox_options.rbs +2 -6
  118. data/sig/kobako/transport/dispatcher.rbs +14 -18
  119. data/sig/kobako/transport/reflection.rbs +5 -3
  120. data/sig/kobako/transport/run.rbs +1 -3
  121. data/sig/kobako/transport/yielder.rbs +3 -3
  122. data/sig/kobako/transport.rbs +7 -6
  123. metadata +16 -4
  124. data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
  125. data/crates/kobako-wasmtime/src/frames.rs +0 -273
  126. data/rust-toolchain.toml +0 -11
@@ -4,37 +4,17 @@ require_relative "../unresolved"
4
4
 
5
5
  module Kobako
6
6
  module Catalog
7
- # Kobako::Catalog::Extensions — per-Sandbox registry of installed
8
- # Extensions. Composes each Extension onto the sibling registries at
9
- # install time (its +source+ into +Catalog::Snippets+, its +backend+
10
- # path into +Catalog::Services+), asserts declared dependencies are
11
- # present when the Sandbox seals, and resolves each callable-backed
12
- # path to a fresh object for every invocation — returning that map to the
13
- # driving +Context+ rather than mutating the shared registry, so
14
- # concurrent invocations stay shared-nothing.
15
- #
16
- # Sealing and the reject-after-install guard are governed by the owning
17
- # Sandbox through +Catalog::Services#sealed?+, the shared seal signal;
18
- # this registry only records entries and enforces the Extension-shape
19
- # and dependency rules.
20
- class Extensions
7
+ # The installed Extensions, composed onto the Service and snippet
8
+ # registries. Providers resolve afresh for each invocation and are handed
9
+ # back rather than stored, so concurrent invocations share nothing.
10
+ class Extensions # :nodoc:
21
11
  def initialize
22
12
  @entries = [] # : Array[untyped]
23
13
  @asserted = false
24
14
  end
25
15
 
26
- # Install +extension+: validate its shape, register its +source+ as a
27
- # preloaded snippet, and — when it carries a +backend+ — reserve the
28
- # backend path in +services+. A static +object:+ binds directly; a
29
- # per-invocation +provider:+ or a fillable backend reserves the path with
30
- # +Kobako::Unresolved+ (the per-invocation result is layered onto the
31
- # Context, a fillable stays Unresolved until filled). Raises
32
- # +ArgumentError+ for the two shapes +#validate!+ owns — +source+ not a
33
- # String, or a present +backend+ that omits +path+ / +object+ /
34
- # +provider+. The Extension readers are duck-typed, so an object missing
35
- # them surfaces the underlying +NoMethodError+, and a malformed +name+ or
36
- # +backend.path+ surfaces through the +snippets+ / +services+
37
- # registration it routes to.
16
+ # The Extension readers are duck-typed, so a malformed +name+ or
17
+ # +backend.path+ surfaces through the registry it routes to.
38
18
  def install(extension, snippets:, services:)
39
19
  validate!(extension)
40
20
  snippets.register(code: extension.source, name: extension.name)
@@ -44,27 +24,24 @@ module Kobako
44
24
  self
45
25
  end
46
26
 
47
- # Assert every installed Extension's +depends_on+ names a fellow
48
- # installed Extension. Runs once, when the Sandbox first seals
49
- # successfully; an unmet dependency raises +ArgumentError+ naming the
50
- # gap, before the guest runs. The asserted flag flips only on success,
51
- # so a seal that failed re-checks on the next attempt rather than
52
- # silently passing a broken Sandbox. Idempotent across later invocations.
53
- def seal!
27
+ # The asserted flag flips only on success, so a check that failed
28
+ # re-runs on the next invocation rather than silently passing a broken
29
+ # Sandbox.
30
+ def assert_dependencies!
54
31
  return self if @asserted
55
32
 
56
- assert_dependencies!
33
+ extension, dependency = unmet_dependency
34
+ if extension
35
+ raise ArgumentError,
36
+ "Extension #{extension.name.inspect} depends on #{dependency.inspect}, which is not installed"
37
+ end
38
+
57
39
  @asserted = true
58
40
  self
59
41
  end
60
42
 
61
- # Resolve each +provider:+-backed path to this invocation's object and
62
- # return the +path => object+ map the driving +Context+ layers over the
63
- # static base bindings. Distinct providers yield distinct objects; one
64
- # provider shared by several Extensions is invoked once and its result
65
- # shared, so provider identity is resource identity. Static and fillable
66
- # backends are omitted — they stay the object bound at install in the
67
- # base registry (+Kobako::Unresolved+ for a fillable).
43
+ # One provider shared by several Extensions is invoked once and its
44
+ # result shared, so provider identity is resource identity.
68
45
  def resolve
69
46
  resolved = {} # : Hash[String, untyped]
70
47
  by_provider = {} # : Hash[untyped, untyped]
@@ -75,15 +52,8 @@ module Kobako
75
52
 
76
53
  private
77
54
 
78
- # The object a backend binds at install: a static +object:+ directly, or
79
- # +Kobako::Unresolved+ as the placeholder for a per-invocation +provider:+
80
- # (layered onto the Context) or a fillable (neither keyword).
81
55
  def install_object(backend) = backend.object.nil? ? Kobako::Unresolved : backend.object
82
56
 
83
- # Resolve one Extension's +provider:+-backed path against the shared
84
- # per-invocation identity cache (keyed by provider identity) and record
85
- # it in +resolved+. A static or fillable backend is skipped — its object
86
- # stays as bound at install in the base registry.
87
57
  def resolve_backend(extension, resolved, by_provider)
88
58
  backend = extension.backend
89
59
  return unless backend
@@ -94,9 +64,6 @@ module Kobako
94
64
  resolved[backend.path.to_s] = by_provider.fetch(provider) { by_provider[provider] = provider.call }
95
65
  end
96
66
 
97
- # Enforce the Extension-shape checks +#preload+ / +#bind+ do not: a
98
- # mandatory String +source+ (the install/bind boundary) and a
99
- # +backend+ that, when present, exposes +path+ / +object+ / +provider+.
100
67
  def validate!(extension)
101
68
  source = extension.source
102
69
  raise ArgumentError, "Extension #source must be a String, got #{source.class}" unless source.is_a?(String)
@@ -108,21 +75,16 @@ module Kobako
108
75
  raise ArgumentError, "Extension #backend must expose #path, #object, and #provider"
109
76
  end
110
77
 
111
- def assert_dependencies!
78
+ def unmet_dependency
112
79
  names = @entries.map { |extension| symbolize(extension.name) }
113
80
  @entries.each do |extension|
114
81
  (extension.depends_on || []).each do |dependency|
115
- next if names.include?(symbolize(dependency))
116
-
117
- raise ArgumentError,
118
- "Extension #{extension.name.inspect} depends on #{dependency.inspect}, which is not installed"
82
+ return [extension, dependency] unless names.include?(symbolize(dependency))
119
83
  end
120
84
  end
85
+ nil
121
86
  end
122
87
 
123
- # Match names and dependencies by Symbol so the Symbol-or-String forms
124
- # of a constant token are interchangeable; a value that is neither
125
- # falls through unchanged to the not-installed path.
126
88
  def symbolize(name) = name.is_a?(String) ? name.to_sym : name
127
89
  end
128
90
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Catalog
5
+ class Flags # :nodoc:
6
+ def initialize
7
+ @entries = {} # : Hash[String, [String, bool]]
8
+ end
9
+
10
+ # Each entry is the name, the value, and whether the flag is critical.
11
+ def entries
12
+ @entries.map { |name, (value, critical)| [name, value, critical] }
13
+ end
14
+
15
+ def register(name, value, advisory:)
16
+ unless name.is_a?(Symbol) || name.is_a?(String)
17
+ raise ArgumentError, "flag name must be a Symbol or String, got #{name.class}"
18
+ end
19
+ raise ArgumentError, "flag value must be a String, got #{value.class}" unless value.is_a?(String)
20
+
21
+ key = utf8!(name.to_s, "flag name")
22
+ raise ArgumentError, "flag #{key.inspect} already set" if @entries.key?(key)
23
+
24
+ @entries[key] = [utf8!(value, "flag value"), !advisory]
25
+ nil
26
+ end
27
+
28
+ private
29
+
30
+ # The native side carries a flag only as UTF-8 text, so text read as
31
+ # bytes is re-tagged, and bytes that are not UTF-8 are refused here
32
+ # rather than failing every invocation.
33
+ def utf8!(text, what)
34
+ utf8 = text.dup.force_encoding(Encoding::UTF_8)
35
+ raise ArgumentError, "#{what} must be UTF-8 text" unless utf8.valid_encoding?
36
+
37
+ utf8
38
+ end
39
+ end
40
+ end
41
+ end
@@ -7,30 +7,11 @@ require_relative "../transport/exposure"
7
7
 
8
8
  module Kobako
9
9
  module Catalog
10
- # Host-side mapping from opaque integer Handle IDs to Ruby objects.
11
- # Each invocation's +Kobako::Context+ mints its own table and hands it to
12
- # the dispatcher, so guest→host dispatch resolves Handle targets and
13
- # arguments against the same table that host→guest wire encoding allocates
14
- # into for that run.
15
- #
16
- # Lifecycle invariants:
17
- #
18
- # - Handle IDs are allocated by a monotonically increasing counter
19
- # scoped to a single invocation. The first ID issued in an
20
- # invocation is 1; ID 0 is reserved as the invalid sentinel and is
21
- # never returned by +#alloc+.
22
- #
23
- # - A fresh table backs each invocation, so a Handle issued in one
24
- # invocation resolves in no other — uniformly regardless of
25
- # allocation source (Service return or host-injected argument).
26
- #
27
- # - The cap is +0x7fff_ffff+ (2³¹ − 1). Allocation beyond the cap
28
- # raises immediately — no silent truncation, no wrap, no ID reuse.
29
- class Handles
30
- # Build a fresh, empty table. +next_id+ is an internal seam that
31
- # sets the starting value of the monotonic counter (defaults to 1);
32
- # tests pass a value near +Kobako::Handle::MAX_ID+ to exercise
33
- # the cap-exhaustion path without 2³¹ allocations.
10
+ # One invocation's Handle table, mapping opaque ids to host objects. Each
11
+ # invocation mints its own, so a Handle resolves only where it was issued.
12
+ class Handles # :nodoc:
13
+ # +next_id+ lets tests start near +Kobako::Handle::MAX_ID+ to reach
14
+ # exhaustion without 2³¹ allocations.
34
15
  def initialize(next_id: 1)
35
16
  @entries = {} # : Hash[Integer, Kobako::Transport::Exposure]
36
17
  surfaces = {} # : Hash[Module, Set[Symbol]]
@@ -38,19 +19,6 @@ module Kobako
38
19
  @next_id = next_id
39
20
  end
40
21
 
41
- # Bind +object+ in the table and return a +Kobako::Handle+ token
42
- # for it. +object+ is any host-side Ruby object to bind. Returns a
43
- # freshly-allocated +Kobako::Handle+ whose +#id+ falls in
44
- # +[Kobako::Handle::MIN_ID, Kobako::Handle::MAX_ID]+. Raises
45
- # +Kobako::HandleExhaustedError+ if the next ID would exceed the
46
- # cap. The cap is anchored on +Kobako::Handle+ — the wire codec
47
- # and the allocator share the same invariant.
48
- #
49
- # Returning a Handle (rather than a bare Integer id) keeps the
50
- # allocator's output a domain entity. An id is the Handle's only
51
- # content, so the same internal +Kobako::Handle.restore+ constructor
52
- # serves both this allocator and the codec's wire-decode path.
53
- #
54
22
  # The entry records the object's Exposure as it stands at mint, so a
55
23
  # call made through this Handle is authorized against the reference
56
24
  # the guest was given.
@@ -63,27 +31,17 @@ module Kobako
63
31
  Kobako::Handle.restore(id)
64
32
  end
65
33
 
66
- # Resolve a Handle ID to its bound object — the very object +#alloc+
67
- # received, so a Handle crossing back restores to it. +id+ is a Handle
68
- # ID previously returned by +#alloc+. Raises +Kobako::SandboxError+ if
69
- # +id+ is not currently bound.
70
34
  def fetch(id)
71
35
  exposure(id).object
72
36
  end
73
37
 
74
- # The Exposure +id+ was minted with, which authorizes a call the guest
75
- # makes through that Handle. Raises +Kobako::SandboxError+ if +id+ is
76
- # not currently bound.
77
38
  def exposure(id)
78
39
  require_bound!(id)
79
40
  @entries[id]
80
41
  end
81
42
 
82
- # Number of currently-bound entries. Used by tests of the Dispatcher
83
- # and Codec::HandleWalk#deep_wrap to observe whether each path allocates
84
- # exactly the Handle entries it should — the +Handles+ table itself never
85
- # consults its own size, but the surrounding code's allocation
86
- # contract is part of the observable boundary.
43
+ # The table never consults its own size; tests read it to pin how many
44
+ # Handles each path allocates.
87
45
  def size
88
46
  @entries.size
89
47
  end
@@ -113,8 +71,6 @@ module Kobako
113
71
  end
114
72
  end
115
73
 
116
- # Guard #alloc against issuing an ID past the cap. Returns +nil+
117
- # on success; raises +Kobako::HandleExhaustedError+ at exhaustion.
118
74
  def ensure_capacity!
119
75
  cap = Kobako::Handle::MAX_ID
120
76
  return unless @next_id > cap
@@ -124,9 +80,6 @@ module Kobako
124
80
  "in a single invocation (limit #{cap})"
125
81
  end
126
82
 
127
- # Single source of truth for the "unknown Handle id" raise used by
128
- # #fetch. Returns +nil+ on success; raises +Kobako::SandboxError+
129
- # when +id+ is not currently bound.
130
83
  def require_bound!(id)
131
84
  return if @entries.key?(id)
132
85
 
@@ -1,50 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../constant_name"
3
4
  require_relative "../errors"
4
5
  require_relative "../transport/exposure"
5
6
 
6
7
  module Kobako
7
8
  module Catalog
8
- # Kobako::Catalog::Services — per-Sandbox registry of Service
9
- # bindings keyed by their constant-path name. Holds the flat
10
- # path→Exposure table and the declared path set every invocation
11
- # announces on Frame 1.
12
- #
13
- # Public API:
14
- #
15
- # services = Kobako::Catalog::Services.new
16
- # services.bind("MyService::KV", kv_object) # => services (chainable)
17
- # services.paths # => ["MyService::KV"]
18
- # services.lookup("MyService::KV").object # => kv_object
19
- #
20
- # Per-dispatch routing is +Kobako::Transport::Dispatcher+'s
21
- # responsibility — the Dispatcher resolves a path against the invocation's
22
- # +Kobako::Context+ (which layers per-invocation providers and +ctx.bind+
23
- # overrides over these base bindings) and the +Catalog::Handles+, both
24
- # passed in the per-invocation dispatch Proc the Context hands to
25
- # +Runtime#eval+ / +#run+.
26
- class Services
27
- # Ruby constant-name pattern each +::+-separated bind-path segment
28
- # must match.
29
- NAME_PATTERN = /\A[A-Z]\w*\z/
30
-
31
- # Build a fresh registry.
9
+ class Services # :nodoc:
32
10
  def initialize
33
11
  @bindings = {} # : Hash[String, Kobako::Transport::Exposure]
34
- @sealed = false
35
12
  end
36
13
 
37
- # Bind +object+ as the Service reachable at +path+ — a +Symbol+ or
38
- # +String+ of one or more +::+-separated constant-form segments
39
- # (+"MyService::KV"+ or a top-level +"File"+). Returns +self+ for
40
- # chaining. Raises +ArgumentError+ when a segment is malformed, when
41
- # +path+ collides with an existing binding (a name is a bound Service
42
- # or a grouping prefix, never both), or when the owning Sandbox has
43
- # been sealed by its first invocation. The binding records +object+'s
44
- # Exposure as it stands now.
14
+ # The binding records +object+'s Exposure as it stands now.
45
15
  def bind(path, object)
46
- raise ArgumentError, "cannot bind after first Sandbox invocation" if @sealed
47
-
48
16
  path_str = validate_path!(path)
49
17
  raise ArgumentError, "Service path #{path_str} conflicts with an existing binding" if collision?(path_str)
50
18
 
@@ -52,14 +20,10 @@ module Kobako
52
20
  self
53
21
  end
54
22
 
55
- # Returns +true+ iff a Service is bound at +path+ — the declared (Frame 1)
56
- # key set a per-invocation +ctx.bind+ override may target.
57
23
  def bound?(path)
58
24
  @bindings.key?(path.to_s)
59
25
  end
60
26
 
61
- # Resolve a +target+ constant path to the bound Service's Exposure.
62
- # Raises +KeyError+ when no Service is bound at +target+.
63
27
  def lookup(target)
64
28
  target_str = target.to_s
65
29
  raise KeyError, "no service bound at #{target_str.inspect}" unless @bindings.key?(target_str)
@@ -67,42 +31,24 @@ module Kobako
67
31
  @bindings[target_str]
68
32
  end
69
33
 
70
- # The bound constant paths in bind order — +["MyService::KV",
71
- # "File"]+ — which +Runtime#eval+ / +#run+ frame into the Frame 1
72
- # preamble. The registry holds the bindings; the wire layout is the
73
- # native side's.
34
+ # The registry holds the bindings; the wire layout is the native
35
+ # side's.
74
36
  def paths
75
37
  @bindings.keys
76
38
  end
77
39
 
78
- # Mark the registry as sealed so registration can never alter the
79
- # declared path set afterwards. Called by +Sandbox+ on the first
80
- # invocation; afterwards #bind raises ArgumentError. Idempotent;
81
- # returns +self+.
82
- def seal!
83
- @sealed = true
84
- self
85
- end
86
-
87
- # Returns +true+ when #seal! has been called, +false+ otherwise.
88
- def sealed?
89
- @sealed
90
- end
91
-
92
40
  private
93
41
 
94
42
  def validate_path!(path)
95
43
  path_str = path.to_s
96
44
  segments = path_str.split("::", -1)
97
- return path_str if !segments.empty? && segments.all? { |seg| NAME_PATTERN.match?(seg) }
45
+ return path_str if !segments.empty? && segments.all? { |seg| CONSTANT_NAME.match?(seg) }
98
46
 
99
47
  raise ArgumentError,
100
48
  "bind path must be constant-form segments joined by '::' (got #{path.inspect})"
101
49
  end
102
50
 
103
- # A path collides when it equals, is a prefix of, or extends an
104
- # existing binding on the +::+ segment boundary — the guardrail that
105
- # keeps a name from being both a bound Service and a grouping prefix.
51
+ # Keeps a name from being both a bound Service and a grouping prefix.
106
52
  def collision?(path)
107
53
  @bindings.each_key.any? do |existing|
108
54
  existing == path ||
@@ -1,62 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../constant_name"
3
4
  require_relative "../snippet"
4
5
 
5
6
  module Kobako
6
7
  module Catalog
7
- # Kobako::Catalog::Snippets — per-Sandbox insertion-ordered registry
8
- # of preloaded snippets.
9
- #
10
- # Entries replay against the fresh +mrb_state+ before per-invocation
11
- # source / entrypoint resolution. Each +Snippet::Source+ entry's +name+
12
- # is its canonical identity — the filename baked into the loaded IREP's
13
- # +debug_info+ that surfaces in every backtrace frame originating from
14
- # the snippet as +(snippet:Name):line+. Duplicate names within the
15
- # +code:+ form would produce ambiguous attribution and are rejected at
16
- # registration time.
17
- # +Snippet::Binary+ entries carry no host-side name — their canonical
18
- # name lives in the bytecode's +debug_info+ and is read by the guest at
19
- # load time; the host does not extract it.
20
- #
21
- # Sealing is governed by the owning Sandbox — the registry itself
22
- # is append-only and exposes no mutation API beyond +#register+; the
23
- # Sandbox guards +#register+ behind the seal check before delegating.
24
- class Snippets
25
- # Ruby constant-name pattern enforced on snippet names.
26
- NAME_PATTERN = /\A[A-Z]\w*\z/
27
-
8
+ class Snippets # :nodoc:
28
9
  def initialize
29
10
  @entries = [] # : Array[Kobako::Snippet::Source | Kobako::Snippet::Binary]
11
+ @compiled = {} # : Hash[Kobako::Snippet::Source, String]
12
+ @compile_tried = false
30
13
  end
31
14
 
32
- # The registered snippets in insertion order, each projected as the
33
- # +[kind, name, body]+ triple +Runtime#eval+ / +#run+ frame into
34
- # Frame 3 — +kind+ names the form as a Symbol, +name+ is +nil+ for
35
- # the bytecode form. The entry value objects stay pure carriers, so
36
- # this collection-tier method reads their attributes externally
37
- # rather than asking each entry to project itself.
15
+ # Projected here so the entry value objects stay pure carriers.
38
16
  def entries
39
17
  @entries.map { |entry| entry_tuple(entry) }
40
18
  end
41
19
 
42
- # Register one preloaded snippet in either of two forms.
43
- #
44
- # * Source form +register(code: src, name: Name)+ — +src+ is the
45
- # mruby source as a String; the bytes are re-encoded as UTF-8
46
- # and detached from the caller's reference. +name+ is a Symbol
47
- # or String matching +NAME_PATTERN+. Returns the Symbol form
48
- # of +name+.
49
- # * Binary form +register(binary: bytes)+ — +bytes+ is
50
- # precompiled RITE bytecode as a String, duplicated and forced
51
- # to ASCII-8BIT so msgpack-ruby ships it as +bin+. Returns
52
- # +nil+ — bytecode entries are anonymous on the host side; any
53
- # structural validation is deferred to the guest at first replay.
54
- #
55
- # The two forms are mutually exclusive: shape validation lives
56
- # here so callers (chiefly +Kobako::Sandbox#preload+) collapse to
57
- # a single delegation. Raises +ArgumentError+ on mixed forms,
58
- # missing keywords, wrong types, malformed +name+, or
59
- # duplicate +code:+ +name+.
20
+ # Has the block compile the source snippets, once, so every invocation
21
+ # after replays their bytecode instead of compiling them again. The
22
+ # block takes the #entries and answers one bytecode per source, or
23
+ # +nil+; when nothing comes back the source keeps replaying, and the
24
+ # attempt is not repeated.
25
+ def compile
26
+ return if @compile_tried
27
+
28
+ @compile_tried = true
29
+ sources = @entries.grep(Snippet::Source)
30
+ return if sources.empty?
31
+
32
+ bytecodes = yield(entries) or return
33
+ @compiled = sources.zip(bytecodes).to_h.compact
34
+ nil
35
+ end
36
+
37
+ # Bytecode stays unchecked on the host; the guest validates it at
38
+ # first replay.
60
39
  def register(code: nil, name: nil, binary: nil)
61
40
  if binary
62
41
  raise ArgumentError, "cannot combine binary: with code: / name:" if code || name
@@ -69,26 +48,25 @@ module Kobako
69
48
 
70
49
  private
71
50
 
72
- # Source-form register path. Delegates argument-shape checks to
73
- # +ensure_source_args!+ (which returns the narrowed +[code, name]+
74
- # pair), normalises +name+ to a Symbol, rejects duplicates,
75
- # and appends the Source entry.
51
+ # The native side frames a source body only as UTF-8 text, so source
52
+ # read as bytes is re-tagged, and bytes that are not UTF-8 are refused
53
+ # here rather than failing every invocation.
76
54
  def register_source!(code, name)
77
55
  code, name = ensure_source_args!(code, name)
56
+ body = code.dup.force_encoding(Encoding::UTF_8)
57
+ raise ArgumentError, "code must be UTF-8 text" unless body.valid_encoding?
58
+
78
59
  name_sym = normalize_name(name)
79
60
  if @entries.any? { |e| e.is_a?(Snippet::Source) && e.name == name_sym }
80
61
  raise ArgumentError, "snippet #{name_sym.inspect} already preloaded"
81
62
  end
82
63
 
83
- @entries << Snippet::Source.new(name: name_sym, body: code.dup.force_encoding(Encoding::UTF_8))
84
- name_sym
64
+ @entries << Snippet::Source.new(name: name_sym, body: body)
65
+ nil
85
66
  end
86
67
 
87
- # Shape-only validation for the +code:+ + +name:+ pair. Returns
88
- # the pair with +nil+ narrowed away so callers can treat both as
89
- # present. The +code:+ type check runs before the +name:+
90
- # presence check so callers passing +code: nil+ explicitly see
91
- # the type error rather than the "missing keyword" error.
68
+ # The +code:+ type check runs first so an explicit +code: nil+ reads
69
+ # as a type error rather than a missing keyword.
92
70
  def ensure_source_args!(code, name)
93
71
  raise ArgumentError, "missing keyword: code: + name:, or binary:" if code.nil? && name.nil?
94
72
  raise ArgumentError, "code must be a String, got #{code.class}" unless code.is_a?(String)
@@ -97,9 +75,8 @@ module Kobako
97
75
  [code, name]
98
76
  end
99
77
 
100
- # Binary-form register path. Validates the +binary:+ payload type
101
- # and appends the Binary entry. The bytes are duplicated and forced
102
- # to ASCII-8BIT so msgpack-ruby picks the +bin+ family on the wire.
78
+ # Kept as bytes whatever encoding the caller tagged them with, since
79
+ # the guest reads them as bytecode rather than text.
103
80
  def register_binary!(bytes)
104
81
  raise ArgumentError, "binary must be a String, got #{bytes.class}" unless bytes.is_a?(String)
105
82
 
@@ -107,14 +84,13 @@ module Kobako
107
84
  nil
108
85
  end
109
86
 
110
- # Project one entry as its +[kind, name, body]+ triple. Source
111
- # entries contribute their host-side +name+; Binary entries carry
112
- # +nil+ because the canonical name lives in the bytecode's embedded
113
- # +debug_info+ and is read by the guest at load time.
87
+ # A bytecode entry carries no name: its canonical name lives in the
88
+ # bytecode's +debug_info+ and is read by the guest at load time.
114
89
  def entry_tuple(entry)
115
90
  case entry
116
91
  when Snippet::Source
117
- [Snippet::Source::KIND, entry.name.to_s, entry.body]
92
+ compiled = @compiled[entry]
93
+ compiled ? [Snippet::Binary::KIND, nil, compiled] : [Snippet::Source::KIND, entry.name.to_s, entry.body]
118
94
  else
119
95
  [Snippet::Binary::KIND, nil, entry.body]
120
96
  end
@@ -126,9 +102,9 @@ module Kobako
126
102
  end
127
103
 
128
104
  name_str = name.to_s
129
- unless NAME_PATTERN.match?(name_str)
105
+ unless CONSTANT_NAME.match?(name_str)
130
106
  raise ArgumentError,
131
- "snippet name must match #{NAME_PATTERN.inspect} (got #{name.inspect})"
107
+ "snippet name must match #{CONSTANT_NAME.inspect} (got #{name.inspect})"
132
108
  end
133
109
 
134
110
  name_str.to_sym
@@ -3,18 +3,10 @@
3
3
  require_relative "catalog/handles"
4
4
  require_relative "catalog/services"
5
5
  require_relative "catalog/snippets"
6
+ require_relative "catalog/flags"
6
7
  require_relative "catalog/extensions"
7
8
 
8
9
  module Kobako
9
- # Kobako::Catalog — Sandbox-level configuration and per-invocation
10
- # allocation tables. Houses the host-side registries the Sandbox owns:
11
- # +Catalog::Services+ (path→Service binding registry),
12
- # +Catalog::Snippets+ (preloaded source / bytecode entries),
13
- # +Catalog::Extensions+ (installed Extensions, composed onto Services /
14
- # Snippets), and +Catalog::Handles+ (per-invocation Handle ID allocator).
15
- #
16
- # See {SPEC.md Refinement → Internal Concepts}[link:../../SPEC.md] for
17
- # how Catalog fits alongside Transport and Runtime.
18
- module Catalog
10
+ module Catalog # :nodoc:
19
11
  end
20
12
  end
@@ -8,36 +8,17 @@ require_relative "utils"
8
8
 
9
9
  module Kobako
10
10
  module Codec
11
- # Module-level entry point for the host side of the kobako wire
12
- # ({docs/wire/payload-msgpack.md}[link:../../../docs/wire/payload-msgpack.md] § Type Mapping).
13
- #
14
- # Translates msgpack gem exceptions into the kobako error taxonomy
15
- # (TruncatedInputError, InvalidTypeError, InvalidEncodingError, UnsupportedTypeError) so
16
- # callers can pattern-match on the SPEC's wire-violation categories
17
- # without leaking the gem's internal exception classes.
18
- #
19
- # Public API is a single function — +.decode+. The decoder is
20
- # stateless; the +MessagePack::Unpacker+ instance is built per call
21
- # because callers always decode exactly one wire value at a time.
22
- module Decoder
23
- # Decode +bytes+ into one Ruby value and validate transitively
24
- # against the SPEC type mapping. Raises TruncatedInputError, InvalidTypeError,
25
- # or InvalidEncodingError on wire violations.
26
- #
27
- # When a block is given, the decoded value is yielded and the block's
28
- # result is returned — wire Value Objects use this to build themselves
29
- # from the decoded payload. The block runs inside this method's
30
- # rescue, so a Value Object's +ArgumentError+ invariant failure
31
- # surfaces as InvalidTypeError without a separate Utils.with_boundary
32
- # wrapper at the call site.
11
+ module Decoder # :nodoc:
12
+ # The block runs inside this method's rescue, so a Value Object built
13
+ # from the decoded payload reports a broken invariant as
14
+ # InvalidTypeError without its own Utils.with_boundary.
33
15
  def self.decode(bytes)
34
16
  value = FACTORY.load(bytes.b)
35
17
  validate_utf8!(value)
36
18
  block_given? ? yield(value) : value
37
19
  # msgpack gem raises the format/type errors below; +ArgumentError+
38
- # comes from our ext-type validators (Handle id range, Exception type
39
- # whitelist) and from a yielded block's Value Object invariants — both
40
- # are wire violations, so both map to InvalidTypeError.
20
+ # covers a yielded block's Value Object invariants — a wire violation
21
+ # too, so it maps to InvalidTypeError.
41
22
  rescue ::MessagePack::UnknownExtTypeError, ::MessagePack::MalformedFormatError,
42
23
  ::MessagePack::StackError, ::ArgumentError => e
43
24
  raise InvalidTypeError, e.message
@@ -48,9 +29,9 @@ module Kobako
48
29
  raise TruncatedInputError, e.message
49
30
  end
50
31
 
51
- # SPEC pins +str+ family payloads to UTF-8
52
- # ({docs/wire/payload-msgpack.md}[link:../../../docs/wire/payload-msgpack.md] § str/bin
53
- # Encoding Rules). The msgpack gem returns UTF-8-tagged Strings for
32
+ # +str+ family payloads must be UTF-8
33
+ # (docs/wire/payload-msgpack.md § Text and Bytes). The msgpack gem
34
+ # returns UTF-8-tagged Strings for
54
35
  # str family but does not validate the bytes; +bin+ family decodes
55
36
  # to ASCII-8BIT. Walk the tree once and reject invalid UTF-8 in any
56
37
  # str-typed leaf via Utils.assert_utf8!.