kobako 0.28.0 → 0.29.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +170 -0
  4. data/Cargo.lock +111 -82
  5. data/README.md +154 -36
  6. data/ROADMAP.md +5 -0
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +86 -0
  9. data/crates/kobako-runtime/Cargo.toml +27 -6
  10. data/crates/kobako-runtime/README.md +13 -8
  11. data/crates/kobako-runtime/src/dispatch.rs +8 -0
  12. data/crates/kobako-runtime/src/filesystem/directory/spelling.rs +194 -0
  13. data/crates/kobako-runtime/src/filesystem/directory.rs +1082 -0
  14. data/crates/kobako-runtime/src/filesystem/memory.rs +1100 -0
  15. data/crates/kobako-runtime/src/filesystem/overlay/exclusive.rs +88 -0
  16. data/crates/kobako-runtime/src/filesystem/overlay/stack.rs +808 -0
  17. data/crates/kobako-runtime/src/filesystem/overlay.rs +1955 -0
  18. data/crates/kobako-runtime/src/filesystem/path.rs +60 -0
  19. data/crates/kobako-runtime/src/filesystem/temporary.rs +167 -0
  20. data/crates/kobako-runtime/src/filesystem.rs +392 -0
  21. data/crates/kobako-runtime/src/grant.rs +20 -0
  22. data/crates/kobako-runtime/src/lib.rs +11 -1
  23. data/crates/kobako-runtime/src/mount.rs +178 -0
  24. data/crates/kobako-runtime/src/profile.rs +4 -4
  25. data/crates/kobako-runtime/src/runtime.rs +12 -0
  26. data/crates/kobako-runtime/src/snapshot.rs +20 -5
  27. data/crates/kobako-runtime/src/yielder.rs +11 -6
  28. data/crates/kobako-transport/CHANGELOG.md +17 -0
  29. data/crates/kobako-transport/Cargo.toml +1 -1
  30. data/crates/kobako-transport/README.md +1 -1
  31. data/crates/kobako-transport/src/abi.rs +1 -1
  32. data/crates/kobako-transport/src/envelope/fault.rs +2 -1
  33. data/crates/kobako-transport/src/envelope/preamble.rs +12 -12
  34. data/crates/kobako-transport/src/envelope/reply.rs +32 -14
  35. data/crates/kobako-transport/src/envelope/request.rs +18 -8
  36. data/crates/kobako-wasmtime/CHANGELOG.md +119 -0
  37. data/crates/kobako-wasmtime/Cargo.toml +21 -16
  38. data/crates/kobako-wasmtime/README.md +5 -3
  39. data/crates/kobako-wasmtime/src/abi.rs +66 -32
  40. data/crates/kobako-wasmtime/src/ambient.rs +63 -4
  41. data/crates/kobako-wasmtime/src/cache.rs +185 -74
  42. data/crates/kobako-wasmtime/src/config.rs +58 -1
  43. data/crates/kobako-wasmtime/src/descriptors.rs +167 -0
  44. data/crates/kobako-wasmtime/src/dispatch.rs +47 -29
  45. data/crates/kobako-wasmtime/src/driver.rs +46 -44
  46. data/crates/kobako-wasmtime/src/guest_mem.rs +13 -7
  47. data/crates/kobako-wasmtime/src/imports.rs +46 -0
  48. data/crates/kobako-wasmtime/src/instance_pre.rs +17 -45
  49. data/crates/kobako-wasmtime/src/invocation.rs +35 -46
  50. data/crates/kobako-wasmtime/src/io.rs +5 -202
  51. data/crates/kobako-wasmtime/src/lib.rs +4 -0
  52. data/crates/kobako-wasmtime/src/preview1/args.rs +60 -0
  53. data/crates/kobako-wasmtime/src/preview1/beneath.rs +188 -0
  54. data/crates/kobako-wasmtime/src/preview1/differential/mount.rs +609 -0
  55. data/crates/kobako-wasmtime/src/preview1/differential.rs +372 -0
  56. data/crates/kobako-wasmtime/src/preview1/errno.rs +267 -0
  57. data/crates/kobako-wasmtime/src/preview1/fd.rs +672 -0
  58. data/crates/kobako-wasmtime/src/preview1/memory.rs +205 -0
  59. data/crates/kobako-wasmtime/src/preview1/mount_view.rs +2499 -0
  60. data/crates/kobako-wasmtime/src/preview1/path.rs +421 -0
  61. data/crates/kobako-wasmtime/src/preview1/poll.rs +333 -0
  62. data/crates/kobako-wasmtime/src/preview1/sock.rs +30 -0
  63. data/crates/kobako-wasmtime/src/preview1.rs +461 -0
  64. data/crates/kobako-wasmtime/src/trap.rs +39 -19
  65. data/crates/kobako-wasmtime/src/wasi.rs +317 -0
  66. data/crates/kobako-wasmtime/tests/compile_posture.rs +43 -0
  67. data/crates/kobako-wasmtime/tests/guest_file.rs +1054 -0
  68. data/crates/kobako-wasmtime/tests/preamble_flags.rs +3 -2
  69. data/crates/kobako-wasmtime/tests/snippet_compile.rs +4 -3
  70. data/crates/kobako-wasmtime/tests/support/mod.rs +9 -13
  71. data/crates/kobako-wasmtime/tests/unanswered_call.rs +86 -0
  72. data/crates/kobako-wasmtime/tests/unsupported_call.rs +1 -0
  73. data/data/kobako.wasm +0 -0
  74. data/ext/kobako/Cargo.toml +4 -2
  75. data/ext/kobako/src/runtime/bridge.rs +154 -81
  76. data/ext/kobako/src/runtime/escape.rs +53 -0
  77. data/ext/kobako/src/runtime/filesystem/grants.rs +91 -0
  78. data/ext/kobako/src/runtime/filesystem/ruby.rs +400 -0
  79. data/ext/kobako/src/runtime/filesystem.rs +236 -0
  80. data/ext/kobako/src/runtime/gvl.rs +7 -1
  81. data/ext/kobako/src/runtime/pinned_value.rs +31 -0
  82. data/ext/kobako/src/runtime/snapshot.rs +127 -0
  83. data/ext/kobako/src/runtime.rs +120 -199
  84. data/lib/kobako/catalog/flags.rb +2 -14
  85. data/lib/kobako/catalog/grants.rb +30 -0
  86. data/lib/kobako/catalog/mount.rb +27 -0
  87. data/lib/kobako/catalog/services.rb +11 -0
  88. data/lib/kobako/catalog/snippets.rb +5 -9
  89. data/lib/kobako/catalog.rb +11 -0
  90. data/lib/kobako/codec/decoder.rb +1 -0
  91. data/lib/kobako/codec/ext_types.rb +1 -1
  92. data/lib/kobako/codec/state.rb +19 -3
  93. data/lib/kobako/codec/value_budget.rb +86 -0
  94. data/lib/kobako/codec.rb +7 -0
  95. data/lib/kobako/context.rb +38 -11
  96. data/lib/kobako/errors.rb +15 -14
  97. data/lib/kobako/filesystem.rb +38 -0
  98. data/lib/kobako/pool.rb +2 -2
  99. data/lib/kobako/sandbox.rb +40 -12
  100. data/lib/kobako/sandbox_options.rb +39 -12
  101. data/lib/kobako/snippet/{binary.rb → bytecode.rb} +1 -1
  102. data/lib/kobako/snippet.rb +1 -1
  103. data/lib/kobako/transport/block_failure.rb +35 -0
  104. data/lib/kobako/transport/dispatcher.rb +30 -26
  105. data/lib/kobako/transport/exposure.rb +5 -7
  106. data/lib/kobako/transport/reflection.rb +79 -23
  107. data/lib/kobako/transport/yielder.rb +8 -30
  108. data/lib/kobako/transport.rb +1 -0
  109. data/lib/kobako/version.rb +1 -1
  110. data/lib/kobako.rb +1 -0
  111. data/sig/kobako/catalog/flags.rbs +0 -4
  112. data/sig/kobako/catalog/grants.rbs +13 -0
  113. data/sig/kobako/catalog/mount.rbs +28 -0
  114. data/sig/kobako/catalog/services.rbs +4 -0
  115. data/sig/kobako/catalog/snippets.rbs +1 -1
  116. data/sig/kobako/catalog.rbs +1 -0
  117. data/sig/kobako/codec/state.rbs +7 -1
  118. data/sig/kobako/codec/value_budget.rbs +23 -0
  119. data/sig/kobako/codec.rbs +2 -0
  120. data/sig/kobako/context.rbs +14 -6
  121. data/sig/kobako/errors.rbs +4 -4
  122. data/sig/kobako/filesystem.rbs +88 -0
  123. data/sig/kobako/guest_yielder.rbs +9 -0
  124. data/sig/kobako/pool.rbs +1 -1
  125. data/sig/kobako/runtime.rbs +21 -49
  126. data/sig/kobako/sandbox.rbs +7 -1
  127. data/sig/kobako/sandbox_options.rbs +24 -2
  128. data/sig/kobako/snapshot.rbs +20 -0
  129. data/sig/kobako/snippet/bytecode.rbs +12 -0
  130. data/sig/kobako/transport/block_failure.rbs +13 -0
  131. data/sig/kobako/transport/dispatcher.rbs +3 -3
  132. data/sig/kobako/transport/exposure.rbs +1 -1
  133. data/sig/kobako/transport/reflection.rbs +14 -1
  134. data/sig/kobako/transport/yielder.rbs +3 -7
  135. data/sig/kobako/transport.rbs +2 -2
  136. metadata +60 -7
  137. data/sig/kobako/snippet/binary.rbs +0 -12
@@ -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};
@@ -102,7 +103,7 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, SetupError> {
102
103
  ))
103
104
  })?;
104
105
  let engine = shared_engine()?;
105
- let artifact = artifact_path(&bytes);
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(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 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
@@ -17,10 +17,14 @@ use kobako_runtime::profile::Profile;
17
17
 
18
18
  /// The caps and requested isolation profile for one `Driver`. `None` on
19
19
  /// any cap field disables that cap.
20
+ #[non_exhaustive]
20
21
  pub struct Config {
21
22
  /// Wall-clock cap for one guest `#eval` / `#run`; the invocation traps
22
23
  /// once it passes.
23
24
  pub timeout: Option<Duration>,
25
+ /// Wall-clock cap for setting the Guest Binary up the first time its
26
+ /// path is loaded; past it, construction fails.
27
+ pub setup_timeout: Option<Duration>,
24
28
  /// Guest linear-memory cap, in bytes, charged to what one invocation
25
29
  /// grows beyond the image it starts from.
26
30
  pub memory_limit: Option<usize>,
@@ -29,9 +33,62 @@ pub struct Config {
29
33
  pub stdout_limit: Option<usize>,
30
34
  /// Byte cap for guest stderr capture, the same as `stdout_limit`.
31
35
  pub stderr_limit: Option<usize>,
36
+ /// Descriptors one invocation may hold open, the standard ones and
37
+ /// each Mount's preopen included; an open past it answers `MFILE`.
38
+ pub open_file_limit: Option<usize>,
32
39
  /// Isolation posture the frontend requested. `Hermetic` freezes
33
40
  /// ambient time and entropy, `Permissive` leaves the live WASI
34
41
  /// sources, and `Driver::profile` declares it back as the built
35
42
  /// posture.
36
43
  pub profile: Profile,
37
44
  }
45
+
46
+ impl Default for Config {
47
+ /// The caps every frontend defaults to: a 60-second deadline, 10
48
+ /// seconds to set the Guest Binary up, 4 MiB for memory, 1 MiB for
49
+ /// each output channel, 1024 open files, and the hermetic floor.
50
+ fn default() -> Self {
51
+ Config {
52
+ timeout: Some(Duration::from_secs(60)),
53
+ setup_timeout: Some(Duration::from_secs(10)),
54
+ memory_limit: Some(4 << 20),
55
+ stdout_limit: Some(1 << 20),
56
+ stderr_limit: Some(1 << 20),
57
+ open_file_limit: Some(1024),
58
+ profile: Profile::Hermetic,
59
+ }
60
+ }
61
+ }
62
+
63
+ #[cfg(test)]
64
+ mod tests {
65
+ use super::*;
66
+
67
+ // Literal values, so a drift from the defaults the SDK's Options and
68
+ // the Ruby frontend apply fails here.
69
+ #[test]
70
+ fn a_default_config_carries_every_frontends_default_caps() {
71
+ let config = Config::default();
72
+ assert_eq!(
73
+ (
74
+ config.timeout,
75
+ config.setup_timeout,
76
+ config.memory_limit,
77
+ config.stdout_limit,
78
+ config.stderr_limit,
79
+ config.open_file_limit,
80
+ config.profile
81
+ ),
82
+ (
83
+ Some(Duration::from_secs(60)),
84
+ Some(Duration::from_secs(10)),
85
+ Some(4 * 1024 * 1024),
86
+ Some(1024 * 1024),
87
+ Some(1024 * 1024),
88
+ Some(1024),
89
+ Profile::Hermetic
90
+ ),
91
+ "Config::default must carry the caps every frontend defaults to"
92
+ );
93
+ }
94
+ }
@@ -0,0 +1,167 @@
1
+ //! The guest's descriptor table: what each preview1 file descriptor
2
+ //! stands for within one invocation.
3
+ //!
4
+ //! kobako answers every preview1 call that reads this table, so the guest
5
+ //! sees one table and no call falls through to a second one that
6
+ //! disagrees. Numbering follows wasmtime-wasi's: a closed number is reused
7
+ //! before a fresh one is taken.
8
+
9
+ use std::collections::{BTreeMap, BTreeSet};
10
+ use std::sync::Arc;
11
+
12
+ use kobako_runtime::filesystem::{DirEntry, File, Hold, Metadata};
13
+ use kobako_runtime::mount::Mount;
14
+ use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
15
+
16
+ /// What one descriptor stands for.
17
+ pub(crate) enum Descriptor {
18
+ /// Descriptor 0, carrying the Preamble.
19
+ Stdin(MemoryInputPipe),
20
+ /// Descriptor 1 or 2, a capture pipe the guest writes a Channel into.
21
+ Channel(MemoryOutputPipe),
22
+ /// A directory beneath a Mount; a preopen is the Mount's own root.
23
+ Directory(OpenDirectory),
24
+ File(OpenFile),
25
+ }
26
+
27
+ /// What a directory is, apart from where it stands: the device and inode
28
+ /// numbers its Filesystem names it by.
29
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
30
+ pub(crate) struct Identity {
31
+ dev: u64,
32
+ ino: u64,
33
+ }
34
+
35
+ impl Identity {
36
+ /// The identity `metadata` names, or `None` where its inode of zero
37
+ /// reports none.
38
+ pub(crate) fn of(metadata: &Metadata) -> Option<Self> {
39
+ (metadata.ino != 0).then_some(Self {
40
+ dev: metadata.dev,
41
+ ino: metadata.ino,
42
+ })
43
+ }
44
+ }
45
+
46
+ pub(crate) struct OpenDirectory {
47
+ pub(crate) mount: Arc<Mount>,
48
+ /// The directory's path relative to the mount's root.
49
+ pub(crate) path: String,
50
+ /// The identity the directory had when it was opened, so one made in
51
+ /// its place is not taken for it; without one it follows its path.
52
+ pub(crate) identity: Option<Identity>,
53
+ /// What keeps that identity from passing to a directory made in this
54
+ /// one's place while the descriptor is open.
55
+ pub(crate) _hold: Option<Hold>,
56
+ pub(crate) preopen: bool,
57
+ /// The entries a listing began with, held until it reaches its end, so
58
+ /// each later call reads on without listing the directory again.
59
+ pub(crate) listing: Option<Vec<DirEntry>>,
60
+ }
61
+
62
+ pub(crate) struct OpenFile {
63
+ pub(crate) file: Box<dyn File>,
64
+ pub(crate) position: u64,
65
+ pub(crate) append: bool,
66
+ pub(crate) readable: bool,
67
+ pub(crate) writable: bool,
68
+ /// The Mount it was opened beneath, whose permission to write answers a
69
+ /// wait to write and a resize, as wasmtime-wasi answers both by its
70
+ /// preopen's permissions.
71
+ pub(crate) mount: Arc<Mount>,
72
+ }
73
+
74
+ pub(crate) struct Descriptors {
75
+ used: BTreeMap<u32, Descriptor>,
76
+ free: BTreeSet<u32>,
77
+ limit: Option<usize>,
78
+ }
79
+
80
+ impl Descriptors {
81
+ /// The table every invocation starts from: stdin, then the two
82
+ /// Channels, then one preopen per Mount from descriptor 3. It holds at
83
+ /// most `limit` descriptors, those included, as a process's
84
+ /// `RLIMIT_NOFILE` counts every one it holds.
85
+ pub(crate) fn new(
86
+ stdin: MemoryInputPipe,
87
+ stdout: MemoryOutputPipe,
88
+ stderr: MemoryOutputPipe,
89
+ mounts: Vec<Mount>,
90
+ limit: Option<usize>,
91
+ ) -> Self {
92
+ let mut used = BTreeMap::from([
93
+ (0, Descriptor::Stdin(stdin)),
94
+ (1, Descriptor::Channel(stdout)),
95
+ (2, Descriptor::Channel(stderr)),
96
+ ]);
97
+ for (fd, mount) in (3..).zip(mounts) {
98
+ // A root the Filesystem cannot stat or hold reports no
99
+ // identity, and the descriptor follows its path alone.
100
+ let filesystem = mount.filesystem();
101
+ let identity = filesystem
102
+ .metadata("")
103
+ .ok()
104
+ .and_then(|root| Identity::of(&root));
105
+ let hold = filesystem.hold_directory("").ok().flatten();
106
+ used.insert(
107
+ fd,
108
+ Descriptor::Directory(OpenDirectory {
109
+ mount: Arc::new(mount),
110
+ path: String::new(),
111
+ identity,
112
+ _hold: hold,
113
+ preopen: true,
114
+ listing: None,
115
+ }),
116
+ );
117
+ }
118
+ Self {
119
+ used,
120
+ free: BTreeSet::new(),
121
+ limit,
122
+ }
123
+ }
124
+
125
+ pub(crate) fn get(&self, fd: u32) -> Option<&Descriptor> {
126
+ self.used.get(&fd)
127
+ }
128
+
129
+ pub(crate) fn get_mut(&mut self, fd: u32) -> Option<&mut Descriptor> {
130
+ self.used.get_mut(&fd)
131
+ }
132
+
133
+ /// Take `fd` out of the table, freeing its number for reuse.
134
+ pub(crate) fn remove(&mut self, fd: u32) -> Option<Descriptor> {
135
+ let descriptor = self.used.remove(&fd)?;
136
+ self.free.insert(fd);
137
+ Some(descriptor)
138
+ }
139
+
140
+ /// Place `descriptor` at `fd`, a number the caller has just freed.
141
+ pub(crate) fn place(&mut self, fd: u32, descriptor: Descriptor) {
142
+ self.free.remove(&fd);
143
+ self.used.insert(fd, descriptor);
144
+ }
145
+
146
+ /// Whether the table holds as many descriptors as it may.
147
+ pub(crate) fn is_full(&self) -> bool {
148
+ self.limit.is_some_and(|limit| self.used.len() >= limit)
149
+ }
150
+
151
+ /// Add `descriptor` under the highest freed number, or the one past
152
+ /// the highest in use; `None` when the table is full.
153
+ pub(crate) fn push(&mut self, descriptor: Descriptor) -> Option<u32> {
154
+ if self.is_full() {
155
+ return None;
156
+ }
157
+ let fd = match self.free.pop_last() {
158
+ Some(fd) => fd,
159
+ None => match self.used.last_key_value() {
160
+ Some((last, _)) => last.checked_add(1)?,
161
+ None => 0,
162
+ },
163
+ };
164
+ self.used.insert(fd, descriptor);
165
+ Some(fd)
166
+ }
167
+ }