kobako 0.28.0 → 0.30.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 (218) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +377 -0
  4. data/Cargo.lock +181 -154
  5. data/README.md +99 -459
  6. data/ROADMAP.md +7 -1
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +354 -0
  9. data/crates/kobako-runtime/Cargo.toml +38 -7
  10. data/crates/kobako-runtime/README.md +17 -8
  11. data/crates/kobako-runtime/src/dispatch.rs +9 -1
  12. data/crates/kobako-runtime/src/endpoint/buffer.rs +192 -0
  13. data/crates/kobako-runtime/src/endpoint/capture.rs +115 -0
  14. data/crates/kobako-runtime/src/endpoint/dispatch.rs +253 -0
  15. data/crates/kobako-runtime/src/endpoint/invoke.rs +194 -0
  16. data/crates/kobako-runtime/src/endpoint/memory_cap.rs +238 -0
  17. data/crates/kobako-runtime/src/endpoint.rs +166 -0
  18. data/crates/kobako-runtime/src/filesystem/bind/fid.rs +82 -0
  19. data/crates/kobako-runtime/src/filesystem/bind/lookup.rs +236 -0
  20. data/crates/kobako-runtime/src/filesystem/bind/walk.rs +249 -0
  21. data/crates/kobako-runtime/src/filesystem/bind.rs +2665 -0
  22. data/crates/kobako-runtime/src/filesystem/overlay/file.rs +89 -0
  23. data/crates/kobako-runtime/src/filesystem/overlay/inode.rs +106 -0
  24. data/crates/kobako-runtime/src/filesystem/overlay/path.rs +285 -0
  25. data/crates/kobako-runtime/src/filesystem/overlay/stack/copy_up.rs +135 -0
  26. data/crates/kobako-runtime/src/filesystem/overlay/stack/dir.rs +263 -0
  27. data/crates/kobako-runtime/src/filesystem/overlay/stack/namei.rs +135 -0
  28. data/crates/kobako-runtime/src/filesystem/overlay/stack/readdir.rs +57 -0
  29. data/crates/kobako-runtime/src/filesystem/overlay/stack.rs +301 -0
  30. data/crates/kobako-runtime/src/filesystem/overlay.rs +3469 -0
  31. data/crates/kobako-runtime/src/filesystem/path.rs +21 -0
  32. data/crates/kobako-runtime/src/filesystem/pipe.rs +221 -0
  33. data/crates/kobako-runtime/src/filesystem/rdir.rs +101 -0
  34. data/crates/kobako-runtime/src/filesystem/tmpfs/tmpfile.rs +32 -0
  35. data/crates/kobako-runtime/src/filesystem/tmpfs/whiteout.rs +85 -0
  36. data/crates/kobako-runtime/src/filesystem/tmpfs/xattr.rs +22 -0
  37. data/crates/kobako-runtime/src/filesystem/tmpfs.rs +1985 -0
  38. data/crates/kobako-runtime/src/filesystem/wrap.rs +224 -0
  39. data/crates/kobako-runtime/src/filesystem.rs +26 -0
  40. data/crates/kobako-runtime/src/grant.rs +20 -0
  41. data/crates/kobako-runtime/src/hashtable.rs +44 -0
  42. data/crates/kobako-runtime/src/lib.rs +16 -1
  43. data/crates/kobako-runtime/src/locking/seqlock.rs +66 -0
  44. data/crates/kobako-runtime/src/locking.rs +218 -0
  45. data/crates/kobako-runtime/src/mount.rs +105 -0
  46. data/crates/kobako-runtime/src/profile.rs +140 -4
  47. data/crates/kobako-runtime/src/runtime.rs +12 -0
  48. data/crates/kobako-runtime/src/sched.rs +210 -0
  49. data/crates/kobako-runtime/src/security.rs +696 -0
  50. data/crates/kobako-runtime/src/snapshot.rs +20 -5
  51. data/crates/kobako-runtime/src/vfs/attr.rs +162 -0
  52. data/crates/kobako-runtime/src/vfs/cred.rs +334 -0
  53. data/crates/kobako-runtime/src/vfs/dentry.rs +1412 -0
  54. data/crates/kobako-runtime/src/vfs/errno.rs +153 -0
  55. data/crates/kobako-runtime/src/vfs/file.rs +1915 -0
  56. data/crates/kobako-runtime/src/vfs/inode.rs +429 -0
  57. data/crates/kobako-runtime/src/vfs/kern_path.rs +217 -0
  58. data/crates/kobako-runtime/src/vfs/mount.rs +59 -0
  59. data/crates/kobako-runtime/src/vfs/namei.rs +5708 -0
  60. data/crates/kobako-runtime/src/vfs/openat2.rs +116 -0
  61. data/crates/kobako-runtime/src/vfs/path.rs +64 -0
  62. data/crates/kobako-runtime/src/vfs/readdir.rs +163 -0
  63. data/crates/kobako-runtime/src/vfs/stat.rs +157 -0
  64. data/crates/kobako-runtime/src/vfs/super_block.rs +122 -0
  65. data/crates/kobako-runtime/src/vfs/utimes.rs +289 -0
  66. data/crates/kobako-runtime/src/vfs.rs +60 -0
  67. data/crates/kobako-runtime/src/yielder.rs +11 -6
  68. data/crates/kobako-runtime/tests/linux_baseline/calls.rs +171 -0
  69. data/crates/kobako-runtime/tests/linux_baseline/concurrent.rs +276 -0
  70. data/crates/kobako-runtime/tests/linux_baseline/kobako.rs +323 -0
  71. data/crates/kobako-runtime/tests/linux_baseline/linux-baseline-expected.txt +16 -0
  72. data/crates/kobako-runtime/tests/linux_baseline/linux.rs +449 -0
  73. data/crates/kobako-runtime/tests/linux_baseline/main.rs +611 -0
  74. data/crates/kobako-runtime/tests/linux_baseline/permissions.rs +236 -0
  75. data/crates/kobako-transport/CHANGELOG.md +23 -0
  76. data/crates/kobako-transport/Cargo.toml +3 -3
  77. data/crates/kobako-transport/README.md +6 -5
  78. data/crates/kobako-transport/src/abi.rs +1 -1
  79. data/crates/kobako-transport/src/envelope/bytes.rs +33 -25
  80. data/crates/kobako-transport/src/envelope/call.rs +5 -5
  81. data/crates/kobako-transport/src/envelope/compile_reply.rs +2 -2
  82. data/crates/kobako-transport/src/envelope/error_record.rs +3 -3
  83. data/crates/kobako-transport/src/envelope/fault.rs +4 -3
  84. data/crates/kobako-transport/src/envelope/outcome.rs +3 -3
  85. data/crates/kobako-transport/src/envelope/preamble.rs +24 -24
  86. data/crates/kobako-transport/src/envelope/reply.rs +34 -16
  87. data/crates/kobako-transport/src/envelope/request.rs +19 -9
  88. data/crates/kobako-transport/src/envelope/run.rs +1 -1
  89. data/crates/kobako-transport/src/envelope/unsupported.rs +2 -2
  90. data/crates/kobako-transport/src/envelope.rs +2 -2
  91. data/crates/kobako-wasmtime/CHANGELOG.md +265 -0
  92. data/crates/kobako-wasmtime/Cargo.toml +25 -19
  93. data/crates/kobako-wasmtime/README.md +13 -7
  94. data/crates/kobako-wasmtime/src/abi.rs +75 -49
  95. data/crates/kobako-wasmtime/src/ambient.rs +64 -6
  96. data/crates/kobako-wasmtime/src/cache.rs +188 -77
  97. data/crates/kobako-wasmtime/src/config.rs +77 -5
  98. data/crates/kobako-wasmtime/src/descriptors.rs +200 -0
  99. data/crates/kobako-wasmtime/src/driver.rs +70 -135
  100. data/crates/kobako-wasmtime/src/exports.rs +5 -16
  101. data/crates/kobako-wasmtime/src/guest.rs +38 -0
  102. data/crates/kobako-wasmtime/src/imports.rs +52 -0
  103. data/crates/kobako-wasmtime/src/instance_pre.rs +18 -46
  104. data/crates/kobako-wasmtime/src/invocation.rs +43 -74
  105. data/crates/kobako-wasmtime/src/lib.rs +5 -4
  106. data/crates/kobako-wasmtime/src/limiter.rs +6 -230
  107. data/crates/kobako-wasmtime/src/preview1/differential/mount.rs +632 -0
  108. data/crates/kobako-wasmtime/src/preview1/differential.rs +486 -0
  109. data/crates/kobako-wasmtime/src/preview1/errno.rs +135 -0
  110. data/crates/kobako-wasmtime/src/preview1/fd.rs +1420 -0
  111. data/crates/kobako-wasmtime/src/preview1/memory.rs +172 -0
  112. data/crates/kobako-wasmtime/src/preview1/mount_view/wrap.rs +211 -0
  113. data/crates/kobako-wasmtime/src/preview1/mount_view.rs +3766 -0
  114. data/crates/kobako-wasmtime/src/preview1/path.rs +302 -0
  115. data/crates/kobako-wasmtime/src/preview1/poll.rs +381 -0
  116. data/crates/kobako-wasmtime/src/preview1/sock.rs +13 -0
  117. data/crates/kobako-wasmtime/src/preview1/testsuite-expected.txt +6 -0
  118. data/crates/kobako-wasmtime/src/preview1/testsuite.rs +157 -0
  119. data/crates/kobako-wasmtime/src/preview1.rs +512 -0
  120. data/crates/kobako-wasmtime/src/trap.rs +45 -25
  121. data/crates/kobako-wasmtime/src/wasi.rs +333 -0
  122. data/crates/kobako-wasmtime/tests/integration/compile_posture.rs +43 -0
  123. data/crates/kobako-wasmtime/tests/integration/guest_file.rs +1085 -0
  124. data/crates/kobako-wasmtime/tests/integration/main.rs +29 -0
  125. data/crates/kobako-wasmtime/tests/{preamble_flags.rs → integration/preamble_flags.rs} +4 -3
  126. data/crates/kobako-wasmtime/tests/{snippet_compile.rs → integration/snippet_compile.rs} +5 -4
  127. data/crates/kobako-wasmtime/tests/{support → integration/support}/mod.rs +9 -13
  128. data/crates/kobako-wasmtime/tests/integration/unanswered_call.rs +86 -0
  129. data/crates/kobako-wasmtime/tests/{unsupported_call.rs → integration/unsupported_call.rs} +2 -1
  130. data/crates/kobako-wasmtime/witx/typenames.witx +750 -0
  131. data/crates/kobako-wasmtime/witx/wasi_snapshot_preview1.witx +441 -0
  132. data/data/kobako.wasm +0 -0
  133. data/ext/kobako/Cargo.toml +4 -2
  134. data/ext/kobako/src/runtime/bridge.rs +154 -81
  135. data/ext/kobako/src/runtime/errors.rs +1 -1
  136. data/ext/kobako/src/runtime/escape.rs +67 -0
  137. data/ext/kobako/src/runtime/filesystem/grants.rs +107 -0
  138. data/ext/kobako/src/runtime/filesystem/ruby.rs +708 -0
  139. data/ext/kobako/src/runtime/filesystem.rs +208 -0
  140. data/ext/kobako/src/runtime/gvl.rs +49 -8
  141. data/ext/kobako/src/runtime/pinned_value.rs +31 -0
  142. data/ext/kobako/src/runtime/snapshot.rs +127 -0
  143. data/ext/kobako/src/runtime.rs +125 -199
  144. data/lib/kobako/catalog/extensions.rb +5 -5
  145. data/lib/kobako/catalog/flags.rb +2 -14
  146. data/lib/kobako/catalog/grants.rb +30 -0
  147. data/lib/kobako/catalog/handles.rb +6 -6
  148. data/lib/kobako/catalog/mount.rb +19 -0
  149. data/lib/kobako/catalog/services.rb +13 -2
  150. data/lib/kobako/catalog/snippets.rb +11 -15
  151. data/lib/kobako/catalog.rb +11 -0
  152. data/lib/kobako/codec/decoder.rb +10 -9
  153. data/lib/kobako/codec/ext_types.rb +4 -6
  154. data/lib/kobako/codec/handle_walk.rb +4 -6
  155. data/lib/kobako/codec/nesting.rb +7 -7
  156. data/lib/kobako/codec/state.rb +19 -3
  157. data/lib/kobako/codec/utils.rb +1 -1
  158. data/lib/kobako/codec/value_budget.rb +86 -0
  159. data/lib/kobako/codec.rb +7 -0
  160. data/lib/kobako/context.rb +40 -13
  161. data/lib/kobako/errors.rb +15 -14
  162. data/lib/kobako/filesystem.rb +69 -0
  163. data/lib/kobako/payload/arguments.rb +2 -2
  164. data/lib/kobako/pool.rb +6 -6
  165. data/lib/kobako/sandbox.rb +55 -27
  166. data/lib/kobako/sandbox_options.rb +39 -13
  167. data/lib/kobako/snippet/{binary.rb → bytecode.rb} +2 -2
  168. data/lib/kobako/snippet/source.rb +1 -1
  169. data/lib/kobako/snippet.rb +1 -1
  170. data/lib/kobako/transport/block_failure.rb +35 -0
  171. data/lib/kobako/transport/dispatcher.rb +33 -29
  172. data/lib/kobako/transport/exposure.rb +7 -9
  173. data/lib/kobako/transport/reflection.rb +79 -23
  174. data/lib/kobako/transport/run.rb +5 -5
  175. data/lib/kobako/transport/yielder.rb +9 -31
  176. data/lib/kobako/transport.rb +1 -0
  177. data/lib/kobako/version.rb +1 -1
  178. data/lib/kobako.rb +35 -0
  179. data/release-please-config.json +1 -0
  180. data/sig/kobako/catalog/extensions.rbs +3 -3
  181. data/sig/kobako/catalog/flags.rbs +0 -4
  182. data/sig/kobako/catalog/grants.rbs +13 -0
  183. data/sig/kobako/catalog/handles.rbs +3 -3
  184. data/sig/kobako/catalog/mount.rbs +24 -0
  185. data/sig/kobako/catalog/services.rbs +5 -1
  186. data/sig/kobako/catalog/snippets.rbs +4 -4
  187. data/sig/kobako/catalog.rbs +1 -0
  188. data/sig/kobako/codec/decoder.rbs +1 -1
  189. data/sig/kobako/codec/handle_walk.rbs +1 -1
  190. data/sig/kobako/codec/nesting.rbs +2 -2
  191. data/sig/kobako/codec/state.rbs +7 -1
  192. data/sig/kobako/codec/utils.rbs +1 -1
  193. data/sig/kobako/codec/value_budget.rbs +23 -0
  194. data/sig/kobako/codec.rbs +3 -2
  195. data/sig/kobako/context.rbs +14 -6
  196. data/sig/kobako/errors.rbs +4 -4
  197. data/sig/kobako/filesystem.rbs +93 -0
  198. data/sig/kobako/guest_yielder.rbs +9 -0
  199. data/sig/kobako/payload/arguments.rbs +1 -1
  200. data/sig/kobako/pool.rbs +3 -3
  201. data/sig/kobako/runtime.rbs +21 -49
  202. data/sig/kobako/sandbox.rbs +9 -5
  203. data/sig/kobako/sandbox_options.rbs +25 -3
  204. data/sig/kobako/snapshot.rbs +20 -0
  205. data/sig/kobako/snippet/bytecode.rbs +12 -0
  206. data/sig/kobako/transport/block_failure.rbs +13 -0
  207. data/sig/kobako/transport/dispatcher.rbs +4 -4
  208. data/sig/kobako/transport/exposure.rbs +2 -2
  209. data/sig/kobako/transport/reflection.rbs +14 -1
  210. data/sig/kobako/transport/run.rbs +3 -3
  211. data/sig/kobako/transport/yielder.rbs +3 -7
  212. data/sig/kobako/transport.rbs +3 -3
  213. metadata +116 -15
  214. data/crates/kobako-wasmtime/src/capture.rs +0 -96
  215. data/crates/kobako-wasmtime/src/dispatch.rs +0 -107
  216. data/crates/kobako-wasmtime/src/guest_mem.rs +0 -265
  217. data/crates/kobako-wasmtime/src/io.rs +0 -286
  218. data/sig/kobako/snippet/binary.rbs +0 -12
@@ -1,14 +1,14 @@
1
- //! Denial of guest ambient authority at the WASI layer — the one grant
2
- //! that separates the hermetic rung from permissive;
3
- //! `io::install_wasi` wires these sources on `Hermetic` only.
1
+ //! The guest's time and entropy at the WASI layer — the one ambient
2
+ //! authority that separates the hermetic rung from permissive. `Wasi::new`
3
+ //! wires the frozen sources on `Hermetic` and the live clock on
4
+ //! `Permissive`; `Clocks` is what the guest's waits pass on under either.
4
5
  //!
5
6
  //! `wasmtime-wasi`'s `WasiCtxBuilder` defaults the guest's `wasi:clocks` to
6
7
  //! the host wall / monotonic clock and `wasi:random` to a fresh per-context
7
8
  //! seed. No allowlisted mrbgem reaches these preview1 imports today
8
9
  //! (`build_config/wasi.rb`), but a future libc-backed gem would silently
9
10
  //! obtain real time and host entropy — a covert timing channel and a
10
- //! nondeterminism source the hermetic posture deliberately excludes
11
- //! (docs/guides/security.md).
11
+ //! nondeterminism source the hermetic posture deliberately excludes.
12
12
  //! Pinning the clocks to the Unix epoch and the RNG to a constant stream
13
13
  //! makes that denial a property of the host, not merely of the gem allowlist.
14
14
  //!
@@ -16,7 +16,7 @@
16
16
  //! wasmtime epoch interruption against a host `Instant`, never the guest's
17
17
  //! frozen `wasi:clocks/monotonic-clock`.
18
18
 
19
- use std::time::Duration;
19
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
20
 
21
21
  use wasmtime_wasi::random::Deterministic;
22
22
  use wasmtime_wasi::{HostMonotonicClock, HostWallClock};
@@ -53,6 +53,64 @@ pub(crate) fn deterministic_rng() -> Deterministic {
53
53
  Deterministic::new(vec![0])
54
54
  }
55
55
 
56
+ /// The host's monotonic clock read from a fixed origin. The guest's
57
+ /// `clock_time_get` and a wait it subscribes to share it, so both agree on
58
+ /// what an instant means.
59
+ #[derive(Clone, Copy)]
60
+ pub(crate) struct LiveMonotonicClock {
61
+ origin: Instant,
62
+ }
63
+
64
+ impl LiveMonotonicClock {
65
+ pub(crate) fn new() -> Self {
66
+ Self {
67
+ origin: Instant::now(),
68
+ }
69
+ }
70
+ }
71
+
72
+ impl HostMonotonicClock for LiveMonotonicClock {
73
+ fn resolution(&self) -> u64 {
74
+ 1
75
+ }
76
+
77
+ fn now(&self) -> u64 {
78
+ u64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(u64::MAX)
79
+ }
80
+ }
81
+
82
+ /// The clocks the guest observes. A wait it subscribes to passes on the
83
+ /// same clocks: frozen ones never advance, so the wait ends at once.
84
+ #[derive(Clone, Copy)]
85
+ pub(crate) enum Clocks {
86
+ Frozen,
87
+ Live(LiveMonotonicClock),
88
+ }
89
+
90
+ impl Clocks {
91
+ pub(crate) fn monotonic_now(&self) -> u64 {
92
+ match self {
93
+ Self::Frozen => FrozenMonotonicClock.now(),
94
+ Self::Live(clock) => clock.now(),
95
+ }
96
+ }
97
+
98
+ pub(crate) fn wall_now(&self) -> u64 {
99
+ let since_epoch = match self {
100
+ Self::Frozen => FrozenWallClock.now(),
101
+ Self::Live(_) => SystemTime::now()
102
+ .duration_since(UNIX_EPOCH)
103
+ .unwrap_or(Duration::ZERO),
104
+ };
105
+ u64::try_from(since_epoch.as_nanos()).unwrap_or(u64::MAX)
106
+ }
107
+
108
+ /// Whether a wait on these clocks takes host time.
109
+ pub(crate) fn advances(&self) -> bool {
110
+ matches!(self, Self::Live(_))
111
+ }
112
+ }
113
+
56
114
  #[cfg(test)]
57
115
  mod tests {
58
116
  //! Pin the frozen-clock contract: the guest must observe no real wall
@@ -10,8 +10,8 @@
10
10
  //!
11
11
  //! Across processes, the Cranelift compile cost is amortised by a
12
12
  //! best-effort `.cwasm` disk cache keyed by the SHA-256 of the Guest
13
- //! Binary bytes; every cache failure falls
14
- //! back to in-process compilation.
13
+ //! Binary bytes and the engine that compiles them; every cache failure
14
+ //! falls back to in-process compilation.
15
15
  //!
16
16
  //! Concurrency: the Mutex is held only for the HashMap lookup and insert,
17
17
  //! never across a compile, so threads building Drivers at once do not
@@ -21,10 +21,11 @@
21
21
  use std::collections::HashMap;
22
22
  use std::fmt::Write as _;
23
23
  use std::fs;
24
+ use std::hash::{DefaultHasher, Hash, Hasher};
24
25
  use std::path::{Path, PathBuf};
25
26
  use std::sync::{Mutex, OnceLock};
26
27
  use std::thread;
27
- use std::time::{Duration, SystemTime};
28
+ use std::time::Duration;
28
29
 
29
30
  use sha2::{Digest, Sha256};
30
31
  use wasmtime::{Config as WtConfig, Engine as WtEngine, Module as WtModule};
@@ -47,7 +48,7 @@ const EPOCH_TICK: Duration = Duration::from_millis(10);
47
48
  /// new-style exception handling (`-mllvm -wasm-use-legacy-eh=false`) for
48
49
  /// its setjmp path. Epoch interruption backs the wall-clock cap, driven by
49
50
  /// one process-wide ticker thread.
50
- pub(crate) fn shared_engine() -> Result<&'static WtEngine, SetupError> {
51
+ pub(crate) fn load_engine() -> Result<&'static WtEngine, SetupError> {
51
52
  if let Some(engine) = SHARED_ENGINE.get() {
52
53
  return Ok(engine);
53
54
  }
@@ -76,7 +77,7 @@ fn spawn_epoch_ticker(engine: WtEngine) {
76
77
 
77
78
  /// A missing file answers `SetupError::ModuleNotBuilt`, the common state of
78
79
  /// a fresh clone before `rake compile`.
79
- pub(crate) fn cached_module(path: &Path) -> Result<WtModule, SetupError> {
80
+ pub(crate) fn load_module(path: &Path) -> Result<WtModule, SetupError> {
80
81
  let cache = MODULE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
81
82
 
82
83
  if let Some(module) = cache
@@ -101,8 +102,8 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, SetupError> {
101
102
  path.display()
102
103
  ))
103
104
  })?;
104
- let engine = shared_engine()?;
105
- let artifact = artifact_path(&bytes);
105
+ let engine = load_engine()?;
106
+ let artifact = artifact_path(engine, &bytes);
106
107
  let module = match artifact.as_deref().and_then(|p| load_artifact(engine, p)) {
107
108
  Some(module) => module,
108
109
  None => {
@@ -124,70 +125,120 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, SetupError> {
124
125
  /// Retention window for unused cache entries. A hit refreshes the
125
126
  /// artifact's mtime, so only entries no process has loaded for the
126
127
  /// whole window are removed by `prune_stale`.
128
+ #[cfg(unix)]
127
129
  const ARTIFACT_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
128
130
 
129
131
  /// Content addressing makes a rebuilt Guest Binary a new entry rather than
130
- /// an invalidation problem; the crate-version segment keeps two installed
131
- /// kobako-wasmtime versions, each pinning its own wasmtime, from thrashing
132
- /// one key. wasmtime itself rejects an artifact from an incompatible
133
- /// version or Config at deserialize time.
134
- fn artifact_path(wasm_bytes: &[u8]) -> Option<PathBuf> {
132
+ /// an invalidation problem. The engine segment names the wasmtime release
133
+ /// and Config that compiled the artifact, because wasmtime's own
134
+ /// deserialize check compares only the major version: without it, an
135
+ /// artifact from a release with a since-fixed miscompile would load
136
+ /// under the patched one.
137
+ fn artifact_path(engine: &WtEngine, wasm_bytes: &[u8]) -> Option<PathBuf> {
135
138
  let base = std::env::var_os("XDG_CACHE_HOME")
136
139
  .map(PathBuf::from)
137
140
  .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache")))?;
138
141
  let digest = Sha256::digest(wasm_bytes);
139
- let mut name = String::with_capacity(80);
142
+ let mut name = String::with_capacity(96);
140
143
  for byte in digest {
141
144
  let _ = write!(name, "{:02x}", byte);
142
145
  }
143
- let _ = write!(name, "-{}.cwasm", env!("CARGO_PKG_VERSION"));
146
+ let _ = write!(name, "-{:016x}.cwasm", engine_key(engine));
144
147
  Some(base.join("kobako").join(name))
145
148
  }
146
149
 
150
+ /// wasmtime's hash of everything an artifact's compatibility depends on,
151
+ /// its full release version included. `DefaultHasher::new` is unseeded,
152
+ /// so every process of one build derives the same key.
153
+ fn engine_key(engine: &WtEngine) -> u64 {
154
+ let mut hasher = DefaultHasher::new();
155
+ engine.precompile_compatibility_hash().hash(&mut hasher);
156
+ hasher.finish()
157
+ }
158
+
147
159
  /// A hit refreshes the file's mtime, so the retention window measures time
148
160
  /// since last use rather than since creation.
161
+ #[cfg(unix)]
149
162
  fn load_artifact(engine: &WtEngine, artifact: &Path) -> Option<WtModule> {
150
- if !artifact.exists() || !artifact.parent().is_some_and(dir_is_private) {
151
- return None;
152
- }
153
- // SAFETY: `Module::deserialize_file` trusts the artifact bytes.
154
- // `dir_is_private` just verified the cache directory is owned by
155
- // the current user and writable by no one else, so only files this
156
- // module wrote are loaded, addressed by the content hash of the
157
- // Guest Binary being constructed — the artifact carries exactly
158
- // the trust of `data/kobako.wasm`.
159
- let module = unsafe { WtModule::deserialize_file(engine, artifact) }.ok()?;
160
- let _ = fs::File::options()
161
- .append(true)
162
- .open(artifact)
163
- .and_then(|f| f.set_modified(SystemTime::now()));
164
- Some(module)
163
+ use rustix::fs::{openat, Mode, OFlags};
164
+ let dir = open_private_dir(artifact.parent()?)?;
165
+ let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
166
+ let file = fs::File::from(openat(&dir, artifact.file_name()?, flags, Mode::empty()).ok()?);
167
+ let _ = file.set_modified(std::time::SystemTime::now());
168
+ // SAFETY: `Module::deserialize_open_file` trusts the artifact bytes
169
+ // and needs them unchanged while the Module lives. The file was
170
+ // opened through the directory `open_private_dir` checked, which only
171
+ // the current user can write, so it is an artifact this module
172
+ // stored, addressed by the content hash of the Guest Binary being
173
+ // constructed — it carries exactly that binary's trust.
174
+ unsafe { WtModule::deserialize_open_file(engine, file) }.ok()
165
175
  }
166
176
 
167
177
  /// The temp-file and rename pair keeps concurrent processes from observing
168
- /// a partial write. Failures are swallowed because the cache is only an
169
- /// optimisation, and pruning on write keeps the directory bounded across
170
- /// rebuilds.
178
+ /// a partial write, and syncing the bytes before the rename keeps a crash
179
+ /// from leaving the artifact's name over blocks never written, which the
180
+ /// unsafe deserialize would trust. Failures are swallowed because the
181
+ /// cache is only an optimisation, and pruning on write keeps the
182
+ /// directory bounded across rebuilds.
183
+ #[cfg(unix)]
171
184
  fn store_artifact(module: &WtModule, artifact: &Path) {
185
+ use rustix::fs::{fsync, renameat};
186
+ use std::io::Write as _;
187
+ let (Some(dir), Some(name)) = (artifact.parent(), artifact.file_name()) else {
188
+ return;
189
+ };
172
190
  let Ok(bytes) = module.serialize() else {
173
191
  return;
174
192
  };
175
- let Some(dir) = artifact.parent() else { return };
176
- if create_cache_dir(dir).is_err() || !dir_is_private(dir) {
193
+ if create_cache_dir(dir).is_err() {
177
194
  return;
178
195
  }
179
- let tmp = artifact.with_extension(format!("tmp{}", std::process::id()));
180
- if fs::write(&tmp, bytes).is_err() {
196
+ let Some(dir) = open_private_dir(dir) else {
197
+ return;
198
+ };
199
+ let Some((tmp, mut file)) = create_temp(&dir, name) else {
200
+ return;
201
+ };
202
+ if file
203
+ .write_all(&bytes)
204
+ .and_then(|()| file.sync_all())
205
+ .is_err()
206
+ {
181
207
  return;
182
208
  }
183
- if fs::rename(&tmp, artifact).is_ok() {
184
- prune_stale(dir, artifact);
209
+ if renameat(&dir, &tmp, &dir, name).is_ok() {
210
+ let _ = fsync(&dir);
211
+ prune_stale(&dir, name);
185
212
  }
186
213
  }
187
214
 
188
- /// Create the cache directory owner-only (`0700`) on Unix so no other
189
- /// local user can plant an artifact the unsafe deserialize would
190
- /// trust; elsewhere fall back to default permissions.
215
+ /// A temp file no other writer shares: two threads of one process storing
216
+ /// the same artifact at once would otherwise write into one file, and a
217
+ /// reader could load the interleaved bytes the unsafe deserialize trusts.
218
+ #[cfg(unix)]
219
+ fn create_temp(dir: &rustix::fd::OwnedFd, name: &std::ffi::OsStr) -> Option<(PathBuf, fs::File)> {
220
+ use rustix::fs::{openat, Mode, OFlags};
221
+ use std::sync::atomic::{AtomicU64, Ordering};
222
+ static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
223
+ let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
224
+ let tmp = Path::new(name).with_extension(format!("tmp{}.{serial}", std::process::id()));
225
+ let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC;
226
+ let fd = openat(dir, &tmp, flags, Mode::RUSR | Mode::WUSR).ok()?;
227
+ Some((tmp, fs::File::from(fd)))
228
+ }
229
+
230
+ /// The disk cache trusts its directory's owner, which only Unix reports,
231
+ /// so elsewhere every process compiles the Guest Binary itself.
232
+ #[cfg(not(unix))]
233
+ fn load_artifact(_engine: &WtEngine, _artifact: &Path) -> Option<WtModule> {
234
+ None
235
+ }
236
+
237
+ #[cfg(not(unix))]
238
+ fn store_artifact(_module: &WtModule, _artifact: &Path) {}
239
+
240
+ /// Create the cache directory owner-only (`0700`) so no other local user
241
+ /// can plant an artifact the unsafe deserialize would trust.
191
242
  #[cfg(unix)]
192
243
  fn create_cache_dir(dir: &Path) -> std::io::Result<()> {
193
244
  use std::os::unix::fs::DirBuilderExt;
@@ -197,57 +248,117 @@ fn create_cache_dir(dir: &Path) -> std::io::Result<()> {
197
248
  .create(dir)
198
249
  }
199
250
 
200
- #[cfg(not(unix))]
201
- fn create_cache_dir(dir: &Path) -> std::io::Result<()> {
202
- fs::create_dir_all(dir)
203
- }
204
-
205
- /// Returns whether the cache directory upholds the trust the unsafe
206
- /// deserialize relies on: owned by the current effective user and
207
- /// writable by no one else. A pre-existing directory another user owns
208
- /// or can write to — e.g. under a shared `XDG_CACHE_HOME` — fails here
209
- /// and both disk-cache tiers are skipped.
251
+ /// The cache directory as one descriptor, once it upholds the trust the
252
+ /// unsafe deserialize relies on: a directory rather than a link in its
253
+ /// place, owned by the current effective user and writable by no one
254
+ /// else. Every later step goes through the descriptor, so a path swapped
255
+ /// after the check reaches nothing. One that fails — another user's,
256
+ /// under a shared `XDG_CACHE_HOME`, say — skips both disk-cache tiers.
210
257
  #[cfg(unix)]
211
- fn dir_is_private(dir: &Path) -> bool {
212
- use std::os::unix::fs::MetadataExt;
213
- let Ok(meta) = fs::metadata(dir) else {
214
- return false;
215
- };
216
- // SAFETY: `geteuid` reads process state and has no preconditions.
217
- meta.uid() == unsafe { libc::geteuid() } && meta.mode() & 0o022 == 0
218
- }
219
-
220
- #[cfg(not(unix))]
221
- fn dir_is_private(_dir: &Path) -> bool {
222
- true
258
+ fn open_private_dir(dir: &Path) -> Option<rustix::fd::OwnedFd> {
259
+ use rustix::fs::{fstat, open, Mode, OFlags};
260
+ let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
261
+ let fd = open(dir, flags, Mode::empty()).ok()?;
262
+ let stat = fstat(&fd).ok()?;
263
+ let owned = stat.st_uid == rustix::process::geteuid().as_raw();
264
+ (owned && stat.st_mode & 0o022 == 0).then_some(fd)
223
265
  }
224
266
 
225
267
  /// Live temp files are seconds old and never qualify; foreign file names
226
268
  /// are left untouched.
227
- fn prune_stale(dir: &Path, keep: &Path) {
228
- let Ok(entries) = fs::read_dir(dir) else {
269
+ #[cfg(unix)]
270
+ fn prune_stale(dir: &rustix::fd::OwnedFd, keep: &std::ffi::OsStr) {
271
+ use rustix::fs::{statat, unlinkat, AtFlags, Dir};
272
+ use std::os::unix::ffi::OsStrExt;
273
+ use std::time::{SystemTime, UNIX_EPOCH};
274
+ let Ok(mut entries) = Dir::read_from(dir) else {
229
275
  return;
230
276
  };
231
- for entry in entries.flatten() {
232
- let path = entry.path();
233
- if path == keep || !cache_entry_name(&path) {
277
+ while let Some(Ok(entry)) = entries.read() {
278
+ let name = entry.file_name();
279
+ if name.to_bytes() == keep.as_bytes() || !name.to_str().is_ok_and(is_cache_entry_name) {
234
280
  continue;
235
281
  }
236
- let stale = entry
237
- .metadata()
238
- .and_then(|meta| meta.modified())
282
+ let stale = statat(dir, name, AtFlags::SYMLINK_NOFOLLOW)
239
283
  .ok()
240
- .and_then(|mtime| mtime.elapsed().ok())
284
+ .and_then(|stat| u64::try_from(stat.st_mtime).ok())
285
+ .and_then(|secs| {
286
+ SystemTime::now()
287
+ .duration_since(UNIX_EPOCH + Duration::from_secs(secs))
288
+ .ok()
289
+ })
241
290
  .is_some_and(|age| age > ARTIFACT_TTL);
242
291
  if stale {
243
- let _ = fs::remove_file(&path);
292
+ let _ = unlinkat(dir, name, AtFlags::empty());
244
293
  }
245
294
  }
246
295
  }
247
296
 
248
- fn cache_entry_name(path: &Path) -> bool {
249
- let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
250
- return false;
251
- };
297
+ #[cfg(unix)]
298
+ fn is_cache_entry_name(name: &str) -> bool {
252
299
  name.ends_with(".cwasm") || name.contains(".tmp")
253
300
  }
301
+
302
+ #[cfg(test)]
303
+ mod tests {
304
+ use super::*;
305
+ use wasmtime::ModuleVersionStrategy;
306
+
307
+ fn engine_with(version: &str) -> WtEngine {
308
+ let mut config = WtConfig::new();
309
+ config
310
+ .module_version(ModuleVersionStrategy::Custom(version.into()))
311
+ .expect("a short version string is accepted");
312
+ WtEngine::new(&config).expect("engine")
313
+ }
314
+
315
+ fn entry_name(engine: &WtEngine) -> PathBuf {
316
+ let path = artifact_path(engine, b"\0asm").expect("a cache root");
317
+ PathBuf::from(path.file_name().expect("an entry name"))
318
+ }
319
+
320
+ // A custom module version stands in for another wasmtime release:
321
+ // wasmtime's compatibility hash covers its full version the same way.
322
+ // @behavior RT-108
323
+ #[test]
324
+ fn an_artifact_from_another_engine_release_is_never_the_entry_read() {
325
+ assert_ne!(
326
+ entry_name(&engine_with("49.0.3")),
327
+ entry_name(&engine_with("49.0.4")),
328
+ "a Guest Binary cached by one engine release must not be the entry another release reads"
329
+ );
330
+ }
331
+
332
+ // Two writers in one process take temp files in turn; each must get a
333
+ // file of its own rather than reopen the other's.
334
+ // @behavior RT-113
335
+ #[cfg(unix)]
336
+ #[test]
337
+ fn two_writers_of_one_artifact_never_share_a_temp_file() {
338
+ let root = std::env::temp_dir().join(format!("kobako-temp-{}", std::process::id()));
339
+ create_cache_dir(&root).expect("a scratch directory");
340
+ let dir = open_private_dir(&root).expect("a private directory");
341
+ let name = std::ffi::OsStr::new("entry.cwasm");
342
+ let first = create_temp(&dir, name).map(|(path, _)| path);
343
+ let second = create_temp(&dir, name).map(|(path, _)| path);
344
+ fs::remove_dir_all(&root).expect("the scratch directory removed");
345
+ let (first, second) = (
346
+ first.expect("a temp file"),
347
+ second.expect("a second temp file"),
348
+ );
349
+ assert_ne!(
350
+ first, second,
351
+ "two stores of one artifact in one process must each write a temp file of their own"
352
+ );
353
+ }
354
+
355
+ // @behavior RT-108
356
+ #[test]
357
+ fn engines_of_one_release_and_config_share_the_entry() {
358
+ assert_eq!(
359
+ entry_name(&engine_with("49.0.4")),
360
+ entry_name(&engine_with("49.0.4")),
361
+ "two engines of one release and Config must read the entry either of them cached"
362
+ );
363
+ }
364
+ }
@@ -1,4 +1,4 @@
1
- //! Per-`Driver` execution configuration.
1
+ //! The caps a `Driver` applies to every invocation.
2
2
  //!
3
3
  //! Every cap a frontend forwards into `Driver::new`. A plain value carrier
4
4
  //! owned by the `Driver` — distinct from the process-wide engine/module
@@ -6,21 +6,28 @@
6
6
  //! per-invocation `crate::invocation::Invocation` (which the wasm engine
7
7
  //! mutates from inside a run).
8
8
  //!
9
- //! Field-for-field the same shape as the SDK's `kobako::Options`, and
10
- //! deliberately not shared with it: this struct is free to name a wasmtime
11
- //! type, and `Options` is not — an engine-free host must still be able to
12
- //! build the SDK.
9
+ //! Its caps are field-for-field the SDK's `kobako::Options`, and deliberately
10
+ //! not shared with it: this struct is free to name a wasmtime type, and
11
+ //! `Options` is not — an engine-free host must still be able to build the SDK.
12
+ //! The security modules are the Driver's own, as Linux's LSMs are the
13
+ //! kernel's: whoever builds the Driver hands them here.
13
14
 
15
+ use std::sync::Arc;
14
16
  use std::time::Duration;
15
17
 
16
18
  use kobako_runtime::profile::Profile;
19
+ use kobako_runtime::vfs::SecurityOperations;
17
20
 
18
21
  /// The caps and requested isolation profile for one `Driver`. `None` on
19
22
  /// any cap field disables that cap.
23
+ #[non_exhaustive]
20
24
  pub struct Config {
21
25
  /// Wall-clock cap for one guest `#eval` / `#run`; the invocation traps
22
26
  /// once it passes.
23
27
  pub timeout: Option<Duration>,
28
+ /// Wall-clock cap for setting the Guest Binary up the first time its
29
+ /// path is loaded; past it, construction fails.
30
+ pub setup_timeout: Option<Duration>,
24
31
  /// Guest linear-memory cap, in bytes, charged to what one invocation
25
32
  /// grows beyond the image it starts from.
26
33
  pub memory_limit: Option<usize>,
@@ -29,9 +36,74 @@ pub struct Config {
29
36
  pub stdout_limit: Option<usize>,
30
37
  /// Byte cap for guest stderr capture, the same as `stdout_limit`.
31
38
  pub stderr_limit: Option<usize>,
39
+ /// Descriptors one invocation may hold open, the standard ones and
40
+ /// each Mount's preopen included; an open past it answers `MFILE`.
41
+ /// Only the guest's descriptors count, as `RLIMIT_NOFILE` counts a
42
+ /// process's: the host descriptors a Filesystem holds behind one are
43
+ /// its own, up to two for a file and one for each Layer holding a
44
+ /// directory, Directory Layers alone holding any.
45
+ pub open_file_limit: Option<usize>,
32
46
  /// Isolation posture the frontend requested. `Hermetic` freezes
33
47
  /// ambient time and entropy, `Permissive` leaves the live WASI
34
48
  /// sources, and `Driver::profile` declares it back as the built
35
49
  /// posture.
36
50
  pub profile: Profile,
51
+ /// Security modules every invocation is judged by, each asked in order
52
+ /// after kobako's own rules and before any Mount's Policy, as Linux
53
+ /// stacks the LSMs it was booted with.
54
+ pub security: Vec<Arc<dyn SecurityOperations>>,
55
+ }
56
+
57
+ impl Default for Config {
58
+ /// The caps every frontend defaults to: a 60-second deadline, 10
59
+ /// seconds to set the Guest Binary up, 4 MiB for memory, 1 MiB for
60
+ /// each output channel, 1024 open files, and the hermetic floor.
61
+ fn default() -> Self {
62
+ Config {
63
+ timeout: Some(Duration::from_secs(60)),
64
+ setup_timeout: Some(Duration::from_secs(10)),
65
+ memory_limit: Some(4 << 20),
66
+ stdout_limit: Some(1 << 20),
67
+ stderr_limit: Some(1 << 20),
68
+ open_file_limit: Some(1024),
69
+ profile: Profile::Hermetic,
70
+ security: Vec::new(),
71
+ }
72
+ }
73
+ }
74
+
75
+ #[cfg(test)]
76
+ mod tests {
77
+ use super::*;
78
+
79
+ // Literal values, so a drift from the defaults the SDK's Options and
80
+ // the Ruby frontend apply fails here.
81
+ #[test]
82
+ fn a_default_config_carries_every_frontends_default_caps() {
83
+ let config = Config::default();
84
+ assert_eq!(
85
+ (
86
+ config.timeout,
87
+ config.setup_timeout,
88
+ config.memory_limit,
89
+ config.stdout_limit,
90
+ config.stderr_limit,
91
+ config.open_file_limit,
92
+ config.profile,
93
+ config.security.len()
94
+ ),
95
+ (
96
+ Some(Duration::from_secs(60)),
97
+ Some(Duration::from_secs(10)),
98
+ Some(4 * 1024 * 1024),
99
+ Some(1024 * 1024),
100
+ Some(1024 * 1024),
101
+ Some(1024),
102
+ Profile::Hermetic,
103
+ 0
104
+ ),
105
+ "Config::default must carry the caps every frontend defaults to, and no security \
106
+ module beyond kobako's own"
107
+ );
108
+ }
37
109
  }