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
@@ -0,0 +1,609 @@
1
+ //! The differential probe beneath a Mount: each path call, made on the
2
+ //! same host directory through kobako's `Directory` Mount and through
3
+ //! wasmtime-wasi's own preopen, must leave the same errno, the same
4
+ //! descriptor, and the same directory behind. A listing of the Mount must
5
+ //! answer the same errno.
6
+ //!
7
+ //! A Filesystem reports no timestamps or inode numbers, so of what a
8
+ //! stat writes only the filetype is compared; the errno and the
9
+ //! directory carry the rest of the decision.
10
+ //!
11
+ //! Where POSIX lets each host choose, wasmtime-wasi passes on the host's
12
+ //! own answer — unlinking a directory is `ISDIR` on Linux and `PERM` on
13
+ //! macOS. kobako answers one way on every host, the way Linux does, so
14
+ //! the probe holds where CI runs it.
15
+
16
+ use std::fs;
17
+ use std::path::{Path, PathBuf};
18
+ use std::sync::atomic::{AtomicUsize, Ordering};
19
+ use std::sync::{Arc, OnceLock};
20
+
21
+ use kobako_runtime::filesystem::Access;
22
+ use kobako_runtime::filesystem::Directory;
23
+ use kobako_runtime::grant::Grant;
24
+ use kobako_runtime::mount::Mount;
25
+ use kobako_runtime::profile::Profile;
26
+ use wasmtime::{Linker, Module, Store};
27
+ use wasmtime_wasi::p1::{self, WasiP1Ctx};
28
+ use wasmtime_wasi::{FsPerms, WasiCtxBuilder};
29
+
30
+ use crate::cache::shared_engine;
31
+ use crate::config::Config;
32
+ use crate::imports;
33
+ use crate::invocation::Invocation;
34
+ use crate::preview1::beneath::PATH_MAX;
35
+ use crate::trap::NO_TIMEOUT_EPOCH_DELTA;
36
+ use crate::wasi::Wasi;
37
+
38
+ /// The guest path both sides preopen the host directory at.
39
+ const AT: &str = "/data";
40
+
41
+ /// Each export calls one path import on descriptor 3 with the path the
42
+ /// host placed at 1024, and the second path at 8192 where a call takes
43
+ /// two. An opened descriptor lands at 128, its fdstat at 320, and a stat
44
+ /// at 256.
45
+ const PROBE_WAT: &str = r#"
46
+ (module
47
+ (import "wasi_snapshot_preview1" "path_open" (func $open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32)))
48
+ (import "wasi_snapshot_preview1" "path_filestat_get" (func $stat (param i32 i32 i32 i32 i32) (result i32)))
49
+ (import "wasi_snapshot_preview1" "path_create_directory" (func $mkdir (param i32 i32 i32) (result i32)))
50
+ (import "wasi_snapshot_preview1" "path_remove_directory" (func $rmdir (param i32 i32 i32) (result i32)))
51
+ (import "wasi_snapshot_preview1" "path_unlink_file" (func $unlink (param i32 i32 i32) (result i32)))
52
+ (import "wasi_snapshot_preview1" "path_rename" (func $rename (param i32 i32 i32 i32 i32 i32) (result i32)))
53
+ (import "wasi_snapshot_preview1" "fd_fdstat_get" (func $fdstat (param i32 i32) (result i32)))
54
+ (memory (export "memory") 1)
55
+ (func (export "open") (param $len i32) (param $oflags i32) (param $rights i64) (param $fdflags i32) (result i32)
56
+ (local $errno i32)
57
+ (local.set $errno (call $open (i32.const 3) (i32.const 1) (i32.const 1024) (local.get $len) (local.get $oflags)
58
+ (local.get $rights) (i64.const 0) (local.get $fdflags) (i32.const 128)))
59
+ (if (i32.eqz (local.get $errno))
60
+ (then (drop (call $fdstat (i32.load (i32.const 128)) (i32.const 320)))))
61
+ (local.get $errno))
62
+ (func (export "open_nofollow") (param $len i32) (result i32)
63
+ (call $open (i32.const 3) (i32.const 0) (i32.const 1024) (local.get $len) (i32.const 0)
64
+ (i64.const 2) (i64.const 0) (i32.const 0) (i32.const 128)))
65
+ (func (export "stat") (param $len i32) (result i32)
66
+ (call $stat (i32.const 3) (i32.const 1) (i32.const 1024) (local.get $len) (i32.const 256)))
67
+ (func (export "stat_nofollow") (param $len i32) (result i32)
68
+ (call $stat (i32.const 3) (i32.const 0) (i32.const 1024) (local.get $len) (i32.const 256)))
69
+ (func (export "mkdir") (param $len i32) (result i32)
70
+ (call $mkdir (i32.const 3) (i32.const 1024) (local.get $len)))
71
+ (func (export "rmdir") (param $len i32) (result i32)
72
+ (call $rmdir (i32.const 3) (i32.const 1024) (local.get $len)))
73
+ (func (export "unlink") (param $len i32) (result i32)
74
+ (call $unlink (i32.const 3) (i32.const 1024) (local.get $len)))
75
+ (func (export "rename") (param $len i32) (param $to i32) (result i32)
76
+ (call $rename (i32.const 3) (i32.const 1024) (local.get $len) (i32.const 3) (i32.const 8192) (local.get $to))))
77
+ "#;
78
+
79
+ const PATH_AT: usize = 1024;
80
+ const SECOND_PATH_AT: usize = 8192;
81
+ const OPENED_AT: usize = 128;
82
+ const FDSTAT_AT: usize = 320;
83
+ /// Where a stat's filetype lands, past its device and inode.
84
+ const FILETYPE_AT: usize = 256 + 16;
85
+ const FILETYPE_DIRECTORY: u8 = 3;
86
+
87
+ const OFLAGS_CREAT: i32 = 1 << 0;
88
+ const OFLAGS_DIRECTORY: i32 = 1 << 1;
89
+ const OFLAGS_EXCL: i32 = 1 << 2;
90
+ const OFLAGS_TRUNC: i32 = 1 << 3;
91
+ const FDFLAGS_NONBLOCK: i32 = 1 << 2;
92
+ const FDFLAGS_SYNC: i32 = 1 << 4;
93
+ const RIGHTS_FD_READ: i64 = 1 << 1;
94
+ const RIGHTS_FD_WRITE: i64 = 1 << 6;
95
+
96
+ /// Paths that stay beneath the root, climb above it, pass through what
97
+ /// does not exist or is no directory, or name the root itself.
98
+ const PATHS: &[&str] = &[
99
+ "a",
100
+ "l",
101
+ "missing",
102
+ "",
103
+ ".",
104
+ "a/",
105
+ "l/",
106
+ "n/",
107
+ "g/",
108
+ "missing/",
109
+ "d",
110
+ "d/f",
111
+ "d/..",
112
+ "d/../a",
113
+ "a/..",
114
+ "missing/..",
115
+ "missing/../a",
116
+ "../a",
117
+ "d/../../a",
118
+ "/a",
119
+ "n/..",
120
+ "n/../f",
121
+ "n/.",
122
+ ];
123
+
124
+ /// Paths at Linux's limits: a name of `NAME_MAX` bytes and one past it,
125
+ /// and a path that fits `PATH_MAX` with its terminating NUL and one that
126
+ /// does not.
127
+ fn paths_at_the_limits() -> Vec<String> {
128
+ let nested = |len: usize| {
129
+ let mut path = "d/".repeat(len / 2);
130
+ path.truncate(len - 1);
131
+ path.push('x');
132
+ path
133
+ };
134
+ vec![
135
+ "x".repeat(255),
136
+ "x".repeat(256),
137
+ format!("d/{}", "x".repeat(256)),
138
+ nested(4095),
139
+ nested(4096),
140
+ ]
141
+ }
142
+
143
+ /// One path call, as the probe makes it.
144
+ #[derive(Clone, Copy, Debug)]
145
+ enum Call {
146
+ Open {
147
+ oflags: i32,
148
+ rights: i64,
149
+ fdflags: i32,
150
+ },
151
+ /// Open the path to read, following no symbolic link at its end.
152
+ OpenNoFollow,
153
+ Stat,
154
+ /// Stat the path, following no symbolic link at its end.
155
+ StatNoFollow,
156
+ Mkdir,
157
+ Rmdir,
158
+ Unlink,
159
+ /// Rename the path to `b`.
160
+ RenameFrom,
161
+ /// Rename `a` to the path.
162
+ RenameTo,
163
+ }
164
+
165
+ const CALLS: &[Call] = &[
166
+ Call::Open {
167
+ oflags: 0,
168
+ rights: RIGHTS_FD_READ,
169
+ fdflags: 0,
170
+ },
171
+ Call::Open {
172
+ oflags: 0,
173
+ rights: RIGHTS_FD_WRITE,
174
+ fdflags: 0,
175
+ },
176
+ Call::Open {
177
+ oflags: OFLAGS_CREAT | OFLAGS_TRUNC,
178
+ rights: RIGHTS_FD_WRITE,
179
+ fdflags: 0,
180
+ },
181
+ Call::Open {
182
+ oflags: OFLAGS_CREAT | OFLAGS_EXCL,
183
+ rights: RIGHTS_FD_WRITE,
184
+ fdflags: 0,
185
+ },
186
+ Call::Open {
187
+ oflags: OFLAGS_DIRECTORY,
188
+ rights: RIGHTS_FD_READ,
189
+ fdflags: 0,
190
+ },
191
+ Call::Open {
192
+ oflags: OFLAGS_DIRECTORY | OFLAGS_CREAT,
193
+ rights: RIGHTS_FD_READ,
194
+ fdflags: 0,
195
+ },
196
+ Call::Open {
197
+ oflags: OFLAGS_DIRECTORY | OFLAGS_TRUNC,
198
+ rights: RIGHTS_FD_READ,
199
+ fdflags: 0,
200
+ },
201
+ Call::Open {
202
+ oflags: 0,
203
+ rights: RIGHTS_FD_READ,
204
+ fdflags: FDFLAGS_SYNC,
205
+ },
206
+ Call::Open {
207
+ oflags: 0,
208
+ rights: RIGHTS_FD_READ,
209
+ fdflags: FDFLAGS_NONBLOCK,
210
+ },
211
+ Call::OpenNoFollow,
212
+ Call::Stat,
213
+ Call::StatNoFollow,
214
+ Call::Mkdir,
215
+ Call::Rmdir,
216
+ Call::Unlink,
217
+ Call::RenameFrom,
218
+ Call::RenameTo,
219
+ ];
220
+
221
+ /// A host directory of the probe's own, holding `a`, `d/f`, `d/e/`, a
222
+ /// link `l` to `a`, and a link `n` to `d/e`, whose parent is not the
223
+ /// root, removed when it drops.
224
+ struct HostDir(PathBuf);
225
+
226
+ impl HostDir {
227
+ fn new() -> Self {
228
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
229
+ let path = std::env::temp_dir().join(format!(
230
+ "kobako-differential-{}-{}",
231
+ std::process::id(),
232
+ NEXT.fetch_add(1, Ordering::Relaxed)
233
+ ));
234
+ fs::create_dir_all(path.join("d/e")).expect("the host directory must be created");
235
+ fs::write(path.join("a"), "hello").expect("the host file must be written");
236
+ fs::write(path.join("d/f"), "inner").expect("the host file must be written");
237
+ #[cfg(unix)]
238
+ for (target, link) in [("a", "l"), ("d/e", "n"), ("nowhere", "g")] {
239
+ std::os::unix::fs::symlink(target, path.join(link))
240
+ .expect("the host link must be made");
241
+ }
242
+ Self(path)
243
+ }
244
+
245
+ /// Every entry beneath the directory, with a file's content.
246
+ fn tree(&self) -> Vec<(String, Option<Vec<u8>>)> {
247
+ fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, Option<Vec<u8>>)>) {
248
+ for entry in fs::read_dir(dir).expect("the host directory must list") {
249
+ let path = entry.expect("the host entry must read").path();
250
+ let name = path
251
+ .strip_prefix(root)
252
+ .expect("an entry lies beneath its root")
253
+ .to_string_lossy()
254
+ .into_owned();
255
+ if path.is_symlink() {
256
+ let target = fs::read_link(&path).expect("the host link must read");
257
+ out.push((
258
+ name,
259
+ Some(target.to_string_lossy().into_owned().into_bytes()),
260
+ ));
261
+ } else if path.is_dir() {
262
+ out.push((name, None));
263
+ walk(root, &path, out);
264
+ } else {
265
+ out.push((
266
+ name,
267
+ Some(fs::read(&path).expect("the host file must read")),
268
+ ));
269
+ }
270
+ }
271
+ }
272
+ let mut out = Vec::new();
273
+ walk(&self.0, &self.0, &mut out);
274
+ out.sort();
275
+ out
276
+ }
277
+ }
278
+
279
+ impl Drop for HostDir {
280
+ fn drop(&mut self) {
281
+ let _ = fs::remove_dir_all(&self.0);
282
+ }
283
+ }
284
+
285
+ /// What one call leaves behind; `None` for a call that trapped.
286
+ #[derive(Debug, PartialEq)]
287
+ struct Observation {
288
+ errno: Option<i32>,
289
+ opened: [u8; 4],
290
+ /// An opened descriptor's filetype and flags.
291
+ fdstat: [u8; 4],
292
+ /// Whether an opened descriptor's rights read and write it.
293
+ rights: u8,
294
+ filetype: u8,
295
+ tree: Vec<(String, Option<Vec<u8>>)>,
296
+ }
297
+
298
+ fn observe<T>(
299
+ mut store: Store<T>,
300
+ linker: &Linker<T>,
301
+ call: Call,
302
+ path: &str,
303
+ host: &HostDir,
304
+ ) -> Observation {
305
+ static MODULE: OnceLock<Module> = OnceLock::new();
306
+ let module = MODULE
307
+ .get_or_init(|| Module::new(store.engine(), PROBE_WAT).expect("probe module must compile"));
308
+ let instance = linker
309
+ .instantiate(&mut store, module)
310
+ .expect("probe module must instantiate");
311
+ let memory = instance
312
+ .get_memory(&mut store, "memory")
313
+ .expect("probe exports its memory");
314
+ let (first, second) = match call {
315
+ Call::RenameTo => ("a", path),
316
+ Call::RenameFrom => (path, "b"),
317
+ _ => (path, ""),
318
+ };
319
+ memory.data_mut(&mut store)[PATH_AT..PATH_AT + first.len()].copy_from_slice(first.as_bytes());
320
+ memory.data_mut(&mut store)[SECOND_PATH_AT..SECOND_PATH_AT + second.len()]
321
+ .copy_from_slice(second.as_bytes());
322
+ let len = first.len() as i32;
323
+ let errno = match call {
324
+ Call::Open {
325
+ oflags,
326
+ rights,
327
+ fdflags,
328
+ } => instance
329
+ .get_typed_func::<(i32, i32, i64, i32), i32>(&mut store, "open")
330
+ .and_then(|f| f.call(&mut store, (len, oflags, rights, fdflags))),
331
+ Call::RenameFrom | Call::RenameTo => instance
332
+ .get_typed_func::<(i32, i32), i32>(&mut store, "rename")
333
+ .and_then(|f| f.call(&mut store, (len, second.len() as i32))),
334
+ Call::OpenNoFollow
335
+ | Call::Stat
336
+ | Call::StatNoFollow
337
+ | Call::Mkdir
338
+ | Call::Rmdir
339
+ | Call::Unlink => {
340
+ let name = match call {
341
+ Call::OpenNoFollow => "open_nofollow",
342
+ Call::Stat => "stat",
343
+ Call::StatNoFollow => "stat_nofollow",
344
+ Call::Mkdir => "mkdir",
345
+ Call::Rmdir => "rmdir",
346
+ _ => "unlink",
347
+ };
348
+ instance
349
+ .get_typed_func::<i32, i32>(&mut store, name)
350
+ .and_then(|f| f.call(&mut store, len))
351
+ }
352
+ }
353
+ .ok();
354
+ let mut opened = [0; 4];
355
+ opened.copy_from_slice(&memory.data(&store)[OPENED_AT..OPENED_AT + 4]);
356
+ let data = memory.data(&store);
357
+ let mut fdstat = [0; 4];
358
+ fdstat.copy_from_slice(&data[FDSTAT_AT..FDSTAT_AT + 4]);
359
+ Observation {
360
+ errno,
361
+ opened,
362
+ fdstat,
363
+ rights: data[FDSTAT_AT + 8] & (RIGHTS_FD_READ | RIGHTS_FD_WRITE) as u8,
364
+ filetype: data[FILETYPE_AT],
365
+ tree: host.tree(),
366
+ }
367
+ }
368
+
369
+ fn through_kobako(call: Call, path: &str) -> Observation {
370
+ let host = HostDir::new();
371
+ let (store, linker) = kobako_preopen(&host);
372
+ observe(store, &linker, call, path, &host)
373
+ }
374
+
375
+ fn through_wasmtime_wasi(call: Call, path: &str) -> Observation {
376
+ let host = HostDir::new();
377
+ let (store, linker) = wasmtime_wasi_preopen(&host);
378
+ observe(store, &linker, call, path, &host)
379
+ }
380
+
381
+ /// `host` behind a kobako `Directory` Mount at `AT`.
382
+ fn kobako_preopen(host: &HostDir) -> (Store<Invocation>, Linker<Invocation>) {
383
+ let engine = shared_engine().expect("shared engine must be constructible");
384
+ let config = Config {
385
+ timeout: None,
386
+ setup_timeout: None,
387
+ memory_limit: None,
388
+ stdout_limit: None,
389
+ stderr_limit: None,
390
+ open_file_limit: None,
391
+ profile: Profile::Hermetic,
392
+ };
393
+ let directory = Directory::new(&host.0).expect("the host directory must open");
394
+ let grant = Grant::Mount(
395
+ Mount::new(AT, Arc::new(directory), Access::ReadWrite).expect("AT is a guest path"),
396
+ );
397
+ let wasi = Wasi::for_invocation(&config, &[], &[grant]).expect("WASI context must build");
398
+ let mut store = Store::new(engine, Invocation::new(None, wasi));
399
+ store.set_epoch_deadline(NO_TIMEOUT_EPOCH_DELTA);
400
+ let linker = imports::linker().expect("host imports must link");
401
+ (store, linker)
402
+ }
403
+
404
+ /// `host` behind wasmtime-wasi's own preopen at `AT`.
405
+ fn wasmtime_wasi_preopen(host: &HostDir) -> (Store<WasiP1Ctx>, Linker<WasiP1Ctx>) {
406
+ let engine = shared_engine().expect("shared engine must be constructible");
407
+ let ctx = WasiCtxBuilder::new()
408
+ .preopened_dir(&host.0, AT, FsPerms::ReadWrite)
409
+ .expect("the host directory must preopen")
410
+ .build_p1();
411
+ let mut store = Store::new(engine, ctx);
412
+ store.set_epoch_deadline(NO_TIMEOUT_EPOCH_DELTA);
413
+ let mut linker = Linker::new(engine);
414
+ p1::add_to_linker_sync(&mut linker, |ctx: &mut WasiP1Ctx| ctx).expect("preview1 must link");
415
+ (store, linker)
416
+ }
417
+
418
+ const ERRNO_BUSY: i32 = 10;
419
+ const ERRNO_INVAL: i32 = 28;
420
+ const ERRNO_ISDIR: i32 = 31;
421
+ const ERRNO_LOOP: i32 = 32;
422
+ const ERRNO_NAMETOOLONG: i32 = 37;
423
+ const ERRNO_NOENT: i32 = 44;
424
+ const ERRNO_NOTDIR: i32 = 54;
425
+ const ERRNO_NOTSUP: i32 = 58;
426
+ const ERRNO_PERM: i32 = 63;
427
+ const ERRNO_NOTCAPABLE: i32 = 76;
428
+
429
+ /// Whether `path` reaches above the root it is resolved beneath.
430
+ /// Whether `path` takes a `..`, or ends in a `.`, right after one of the
431
+ /// host directory's links.
432
+ fn steps_out_of_a_link(path: &str) -> bool {
433
+ let mut components = path.split('/');
434
+ matches!(components.next(), Some("l" | "n"))
435
+ && match components.next() {
436
+ Some("..") => true,
437
+ Some(".") => components.next().is_none(),
438
+ _ => false,
439
+ }
440
+ }
441
+
442
+ fn escapes(path: &str) -> bool {
443
+ let mut depth = 0usize;
444
+ path.starts_with('/')
445
+ || path.split('/').any(|component| match component {
446
+ "" | "." => false,
447
+ ".." => match depth.checked_sub(1) {
448
+ Some(up) => {
449
+ depth = up;
450
+ false
451
+ }
452
+ None => true,
453
+ },
454
+ _ => {
455
+ depth += 1;
456
+ false
457
+ }
458
+ })
459
+ }
460
+
461
+ /// What kobako answers where it differs from wasmtime-wasi on purpose. An
462
+ /// open asking not to block is `NOTSUP`, where wasmtime-wasi records the
463
+ /// flag and blocks anyway. A path above the root is `NOTCAPABLE`, the code preview1 names for a
464
+ /// capability the guest was never handed, where wasmtime-wasi passes on
465
+ /// cap-std's refusal as `PERM`. A path past `PATH_MAX` is `NAMETOOLONG`
466
+ /// as Linux answers the whole path, where cap-std resolves it one name
467
+ /// at a time and never hands Linux the whole of it. A stat following no
468
+ /// link of a path named with a trailing `/` answers as Linux's `lstat`,
469
+ /// which follows a link there, where cap-std's shortcut for one name
470
+ /// drops the `/` and finds the file or the link. A `rmdir` of a link
471
+ /// named with a trailing `/` is `NOTDIR` as Linux answers it, where
472
+ /// cap-std's split turns the `/` into `.` and answers `INVAL`. A `..` or
473
+ /// a final `.` after a link is `LOOP` and changes nothing, where cap-std
474
+ /// steps out of where the link points; a call refused before its path
475
+ /// resolves answers alike.
476
+ fn expected(call: Call, path: &str, mut oracle: Observation) -> Observation {
477
+ let refused = |errno| Observation {
478
+ errno: Some(errno),
479
+ opened: [0; 4],
480
+ fdstat: [0; 4],
481
+ rights: 0,
482
+ filetype: 0,
483
+ tree: HostDir::new().tree(),
484
+ };
485
+ if matches!(call, Call::Open { fdflags, .. } if fdflags & FDFLAGS_NONBLOCK != 0) {
486
+ return refused(if path.len() >= PATH_MAX {
487
+ ERRNO_NAMETOOLONG
488
+ } else {
489
+ ERRNO_NOTSUP
490
+ });
491
+ }
492
+ let refused_first = matches!(oracle.errno, Some(ERRNO_BUSY | ERRNO_INVAL | ERRNO_NOTSUP));
493
+ if steps_out_of_a_link(path) && !refused_first {
494
+ return refused(ERRNO_LOOP);
495
+ }
496
+ if escapes(path) && oracle.errno == Some(ERRNO_PERM) {
497
+ oracle.errno = Some(ERRNO_NOTCAPABLE);
498
+ }
499
+ // A trailing `/` makes Linux's `lstat` follow the link at the end,
500
+ // where cap-std leaves it, so the host's own `lstat` answers.
501
+ if matches!(call, Call::StatNoFollow) && path.ends_with('/') {
502
+ let host = HostDir::new();
503
+ match fs::symlink_metadata(host.0.join(path)) {
504
+ Ok(metadata) if metadata.is_dir() => {
505
+ if oracle.errno == Some(0) {
506
+ oracle.filetype = FILETYPE_DIRECTORY;
507
+ }
508
+ }
509
+ found => {
510
+ oracle.errno = Some(match found {
511
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => ERRNO_NOENT,
512
+ _ => ERRNO_NOTDIR,
513
+ });
514
+ oracle.filetype = 0;
515
+ }
516
+ }
517
+ }
518
+ // Linux's `unlink` and `rmdir` follow no link at the end, so the name
519
+ // itself answers a trailing `/`, where cap-std follows the link first.
520
+ if matches!(call, Call::Unlink | Call::Rmdir)
521
+ && path.ends_with('/')
522
+ && oracle.errno.is_some_and(|errno| errno != 0)
523
+ {
524
+ let host = HostDir::new();
525
+ match (
526
+ call,
527
+ fs::symlink_metadata(host.0.join(path.trim_end_matches('/'))),
528
+ ) {
529
+ (_, Ok(metadata)) if !metadata.is_dir() => oracle.errno = Some(ERRNO_NOTDIR),
530
+ (Call::Unlink, Ok(_)) => oracle.errno = Some(ERRNO_ISDIR),
531
+ (Call::Unlink, Err(_)) => oracle.errno = Some(ERRNO_NOENT),
532
+ _ => {}
533
+ }
534
+ }
535
+ if path.len() >= PATH_MAX {
536
+ oracle.errno = Some(ERRNO_NAMETOOLONG);
537
+ }
538
+ oracle
539
+ }
540
+
541
+ #[test]
542
+ #[cfg_attr(
543
+ not(target_os = "linux"),
544
+ ignore = "wasmtime-wasi passes on the host's own answer, and kobako answers as Linux does"
545
+ )]
546
+ fn every_path_call_beneath_a_mount_answers_as_a_wasmtime_wasi_preopen() {
547
+ let paths: Vec<String> = PATHS
548
+ .iter()
549
+ .map(|&path| path.to_string())
550
+ .chain(paths_at_the_limits())
551
+ .collect();
552
+ let differing: Vec<String> = CALLS
553
+ .iter()
554
+ .flat_map(|&call| paths.iter().map(move |path| (call, path.as_str())))
555
+ .filter_map(|(call, path)| {
556
+ let kobako = through_kobako(call, path);
557
+ let oracle = expected(call, path, through_wasmtime_wasi(call, path));
558
+ (kobako != oracle).then(|| format!("{call:?} {path:?}: {kobako:?} != {oracle:?}"))
559
+ })
560
+ .collect();
561
+ assert!(
562
+ differing.is_empty(),
563
+ "each path call on a host directory through a kobako Directory Mount must answer as \
564
+ wasmtime-wasi's preopen of it does:\n{}",
565
+ differing.join("\n")
566
+ );
567
+ }
568
+
569
+ // Only Linux keeps a name that is not UTF-8; macOS refuses to make one.
570
+ #[test]
571
+ #[cfg(target_os = "linux")]
572
+ fn a_listing_holding_a_name_that_is_not_utf8_answers_as_a_wasmtime_wasi_preopen() {
573
+ use std::os::unix::ffi::OsStrExt;
574
+
575
+ /// Lists descriptor 3 into the 256 bytes at 1024 from the first entry,
576
+ /// the bytes used landing at 128.
577
+ const READDIR_WAT: &str = r#"
578
+ (module
579
+ (import "wasi_snapshot_preview1" "fd_readdir" (func $readdir (param i32 i32 i32 i64 i32) (result i32)))
580
+ (memory (export "memory") 1)
581
+ (func (export "readdir") (result i32)
582
+ (call $readdir (i32.const 3) (i32.const 1024) (i32.const 256) (i64.const 0) (i32.const 128))))
583
+ "#;
584
+
585
+ /// The errno the listing answers; `None` for a call that trapped.
586
+ fn readdir_errno<T>(mut store: Store<T>, linker: &Linker<T>) -> Option<i32> {
587
+ let module = Module::new(store.engine(), READDIR_WAT).expect("probe module must compile");
588
+ let instance = linker
589
+ .instantiate(&mut store, &module)
590
+ .expect("probe module must instantiate");
591
+ instance
592
+ .get_typed_func::<(), i32>(&mut store, "readdir")
593
+ .and_then(|f| f.call(&mut store, ()))
594
+ .ok()
595
+ }
596
+
597
+ let host = HostDir::new();
598
+ fs::write(host.0.join(std::ffi::OsStr::from_bytes(b"\xff")), "")
599
+ .expect("the host file must be written");
600
+ let (store, linker) = kobako_preopen(&host);
601
+ let kobako = readdir_errno(store, &linker);
602
+ let (store, linker) = wasmtime_wasi_preopen(&host);
603
+ let oracle = readdir_errno(store, &linker);
604
+ assert_eq!(
605
+ kobako, oracle,
606
+ "fd_readdir on a host directory holding a name that is not UTF-8 through a kobako \
607
+ Directory Mount must answer as wasmtime-wasi's preopen of it does"
608
+ );
609
+ }