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,1054 @@
1
+ //! The bundled guest's `File` over a Mount: what a script reads and
2
+ //! writes beneath a mounted path, and how it fails where nothing is
3
+ //! mounted, nothing is there, or the Mount only reads.
4
+
5
+ mod support;
6
+
7
+ use std::sync::atomic::{AtomicUsize, Ordering};
8
+ use std::sync::Arc;
9
+
10
+ use kobako_runtime::filesystem::Access;
11
+ use kobako_runtime::filesystem::{
12
+ DirEntry, File, Filesystem, FilesystemError, Memory, Metadata, OpenOptions, Rename,
13
+ };
14
+ use kobako_runtime::grant::Grant;
15
+ use kobako_runtime::mount::Mount;
16
+ use kobako_runtime::Hold;
17
+ use kobako_runtime::{Completion, Entry, Runtime};
18
+ use kobako_transport::envelope::{Flag, Outcome, Preamble};
19
+
20
+ /// What one script left: its printed output, or the class of the
21
+ /// exception it ended with. `None` when the Guest Binary is not built.
22
+ fn run(source: &str, grants: &[Grant], flags: Vec<Flag>) -> Option<Result<String, String>> {
23
+ let driver = support::bundled_driver()?;
24
+ let mut preamble = Preamble::default();
25
+ preamble.flags = flags;
26
+ let snapshot = driver
27
+ .invoke(
28
+ Entry::Eval {
29
+ source: source.as_bytes(),
30
+ },
31
+ &preamble,
32
+ None,
33
+ grants,
34
+ )
35
+ .expect("the invocation must start");
36
+ let Completion::Outcome(outcome) = snapshot.completion else {
37
+ panic!("the invocation must answer rather than trap");
38
+ };
39
+ Some(match outcome {
40
+ Outcome::Ok(_) => Ok(String::from_utf8(snapshot.stdout.bytes).expect("stdout is UTF-8")),
41
+ Outcome::Panic(panic) => Err(panic.error.name),
42
+ Outcome::Unsupported(_) => panic!("the bundled guest serves eval"),
43
+ })
44
+ }
45
+
46
+ fn mount(filesystem: Arc<dyn Filesystem>, access: Access) -> Grant {
47
+ Grant::Mount(Mount::new("/data", filesystem, access).expect("/data is a guest path"))
48
+ }
49
+
50
+ fn note_memory() -> Arc<Memory> {
51
+ Arc::new([("note.txt", "hello\nworld\n")].into_iter().collect())
52
+ }
53
+
54
+ fn contents(filesystem: &dyn Filesystem, path: &str) -> Vec<u8> {
55
+ let mut file = filesystem
56
+ .open(path, &{
57
+ let mut options = OpenOptions::default();
58
+ options.read = true;
59
+ options
60
+ })
61
+ .expect("the file must exist");
62
+ let mut buf = vec![0; 64];
63
+ let read = file.read_at(&mut buf, 0).expect("the file must read");
64
+ buf.truncate(read);
65
+ buf
66
+ }
67
+
68
+ // @behavior FL-001
69
+ #[test]
70
+ fn a_script_reads_a_file_beneath_a_mounted_path() {
71
+ let Some(answer) = run(
72
+ r#"print File.read("/data/note.txt")"#,
73
+ &[mount(note_memory(), Access::Read)],
74
+ Vec::new(),
75
+ ) else {
76
+ return;
77
+ };
78
+ assert_eq!(
79
+ answer,
80
+ Ok("hello\nworld\n".to_string()),
81
+ "File.read beneath a Mount through the bundled guest must read the file"
82
+ );
83
+ }
84
+
85
+ // @behavior FL-002
86
+ #[test]
87
+ fn a_script_writes_a_file_beneath_a_read_write_mount() {
88
+ let memory = Arc::new(Memory::new());
89
+ let Some(answer) = run(
90
+ r#"print File.open("/data/out.txt", "w") { |f| f.write("kept"); :done }"#,
91
+ &[mount(memory.clone(), Access::ReadWrite)],
92
+ Vec::new(),
93
+ ) else {
94
+ return;
95
+ };
96
+ assert_eq!(
97
+ answer,
98
+ Ok("done".to_string()),
99
+ "File.open must answer its block's value"
100
+ );
101
+ assert_eq!(
102
+ contents(memory.as_ref(), "out.txt"),
103
+ b"kept",
104
+ "a file written in File.open's block through the bundled guest must land in the Memory"
105
+ );
106
+ }
107
+
108
+ // @behavior FL-003
109
+ #[test]
110
+ fn a_script_reads_a_file_line_by_line() {
111
+ let Some(answer) = run(
112
+ r#"File.open("/data/note.txt") { |f| f.each_line { |line| print line.upcase } }; nil"#,
113
+ &[mount(note_memory(), Access::Read)],
114
+ Vec::new(),
115
+ ) else {
116
+ return;
117
+ };
118
+ assert_eq!(
119
+ answer,
120
+ Ok("HELLO\nWORLD\n".to_string()),
121
+ "IO#each_line on a File through the bundled guest must yield each line"
122
+ );
123
+ }
124
+
125
+ // @behavior FL-004
126
+ #[test]
127
+ fn opening_a_file_with_nothing_mounted_is_unimplemented() {
128
+ let Some(answer) = run(r#"File.open("/data/note.txt")"#, &[], Vec::new()) else {
129
+ return;
130
+ };
131
+ assert_eq!(
132
+ answer,
133
+ Err("NotImplementedError".to_string()),
134
+ "File.open with no Mount through the bundled guest must raise \
135
+ NotImplementedError"
136
+ );
137
+ }
138
+
139
+ // @behavior FL-005
140
+ #[test]
141
+ fn opening_a_missing_file_raises_enoent() {
142
+ let Some(answer) = run(
143
+ r#"File.read("/data/missing.txt")"#,
144
+ &[mount(note_memory(), Access::Read)],
145
+ Vec::new(),
146
+ ) else {
147
+ return;
148
+ };
149
+ assert_eq!(
150
+ answer,
151
+ Err("Errno::ENOENT".to_string()),
152
+ "File.read of a path a Mount does not hold through the bundled guest must raise \
153
+ Errno::ENOENT"
154
+ );
155
+ }
156
+
157
+ // @behavior FL-006
158
+ #[test]
159
+ fn writing_beneath_a_read_only_mount_raises_erofs() {
160
+ let Some(answer) = run(
161
+ r#"File.write("/data/note.txt", "changed")"#,
162
+ &[mount(note_memory(), Access::Read)],
163
+ Vec::new(),
164
+ ) else {
165
+ return;
166
+ };
167
+ assert_eq!(
168
+ answer,
169
+ Err("Errno::EROFS".to_string()),
170
+ "File.write beneath a read-only Mount through the bundled guest must raise Errno::EROFS"
171
+ );
172
+ }
173
+
174
+ // The write goes through an open file, so `IO#write` itself must raise,
175
+ // not only `File.write`.
176
+ // @behavior FL-012
177
+ #[test]
178
+ fn a_write_past_a_mounts_space_raises_enospc() {
179
+ let full = Arc::new(Memory::new().with_limit(Some(4)));
180
+ let Some(answer) = run(
181
+ r#"File.open("/data/note.txt", "w") { |file| file.write("five!") }"#,
182
+ &[mount(full, Access::ReadWrite)],
183
+ Vec::new(),
184
+ ) else {
185
+ return;
186
+ };
187
+ assert_eq!(
188
+ answer,
189
+ Err("Errno::ENOSPC".to_string()),
190
+ "IO#write past a Memory's limit through the bundled guest must raise Errno::ENOSPC"
191
+ );
192
+ }
193
+
194
+ // A name at NAME_MAX still lands, so the refusal is the limit and not
195
+ // the name's length alone.
196
+ // @behavior FL-013
197
+ #[test]
198
+ fn a_name_or_path_past_linuxs_limits_raises_enametoolong() {
199
+ let script = r#"
200
+ answers = [255, 256].map do |length|
201
+ begin
202
+ File.write("/data/" + "x" * length, "")
203
+ :written
204
+ rescue Errno::ENAMETOOLONG
205
+ :too_long
206
+ end
207
+ end
208
+ begin
209
+ File.read("/data/" + "d/" * 2048)
210
+ rescue Errno::ENAMETOOLONG
211
+ answers << :too_long
212
+ end
213
+ print answers.inspect
214
+ "#;
215
+ let Some(answer) = run(
216
+ script,
217
+ &[mount(Arc::new(Memory::new()), Access::ReadWrite)],
218
+ Vec::new(),
219
+ ) else {
220
+ return;
221
+ };
222
+ assert_eq!(
223
+ answer,
224
+ Ok("[:written, :too_long, :too_long]".to_string()),
225
+ "a name past 255 bytes or a path past 4095 through the bundled guest's File must raise \
226
+ Errno::ENAMETOOLONG"
227
+ );
228
+ }
229
+
230
+ // EBUSY once had no class in the guest, so the script saw an IOError.
231
+ // @behavior FL-014
232
+ #[test]
233
+ fn a_code_the_engine_answers_raises_its_own_errno() {
234
+ let Some(answer) = run(
235
+ r#"File.rename("/data", "/data/moved")"#,
236
+ &[mount(note_memory(), Access::ReadWrite)],
237
+ Vec::new(),
238
+ ) else {
239
+ return;
240
+ };
241
+ assert_eq!(
242
+ answer,
243
+ Err("Errno::EBUSY".to_string()),
244
+ "File.rename of a Mount's root through the bundled guest must raise Errno::EBUSY"
245
+ );
246
+ }
247
+
248
+ // @behavior FL-007
249
+ #[test]
250
+ fn a_file_is_an_io() {
251
+ let Some(answer) = run(r#"print(File.superclass == IO)"#, &[], Vec::new()) else {
252
+ return;
253
+ };
254
+ assert_eq!(
255
+ answer,
256
+ Ok("true".to_string()),
257
+ "File in the bundled guest must descend from IO"
258
+ );
259
+ }
260
+
261
+ // @behavior FL-008
262
+ #[test]
263
+ fn the_disable_file_flag_removes_file() {
264
+ let flag = Flag {
265
+ name: "disable_file".into(),
266
+ value: String::new(),
267
+ critical: true,
268
+ };
269
+ let Some(answer) = run(r#"print Object.const_defined?(:File)"#, &[], vec![flag]) else {
270
+ return;
271
+ };
272
+ assert_eq!(
273
+ answer,
274
+ Ok("false".to_string()),
275
+ "the disable_file flag through the bundled guest must leave no File constant"
276
+ );
277
+ }
278
+
279
+ // @behavior FL-009
280
+ #[test]
281
+ fn an_io_may_stand_for_a_file_the_guest_opened() {
282
+ let Some(answer) = run(
283
+ r#"File.open("/data/note.txt") { |f| print IO.new(f.fileno, "r").read }; nil"#,
284
+ &[mount(note_memory(), Access::Read)],
285
+ Vec::new(),
286
+ ) else {
287
+ return;
288
+ };
289
+ assert_eq!(
290
+ answer,
291
+ Ok("hello\nworld\n".to_string()),
292
+ "IO.new over a file the guest opened through the bundled guest must read that file"
293
+ );
294
+ }
295
+
296
+ // The file keeping its content shows `wx` refused before truncating,
297
+ // which a plain `w` would have done.
298
+ // @behavior FL-024
299
+ #[test]
300
+ fn an_exclusive_create_refuses_a_file_already_there() {
301
+ let memory = note_memory();
302
+ let Some(answer) = run(
303
+ r#"File.open("/data/note.txt", "wx") { |f| f.write("lost") }"#,
304
+ &[mount(memory.clone(), Access::ReadWrite)],
305
+ Vec::new(),
306
+ ) else {
307
+ return;
308
+ };
309
+ assert_eq!(
310
+ (answer, contents(memory.as_ref(), "note.txt")),
311
+ (Err("Errno::EEXIST".to_string()), b"hello\nworld\n".to_vec()),
312
+ "File.open with wx of a file already there through the bundled guest must raise \
313
+ Errno::EEXIST and leave the file as it was"
314
+ );
315
+ }
316
+
317
+ // @behavior FL-025
318
+ #[test]
319
+ fn an_exclusive_create_makes_a_file_nothing_is_at() {
320
+ let memory = Arc::new(Memory::new());
321
+ let Some(answer) = run(
322
+ r#"File.open("/data/lock", "w+x") { |f| f.write("mine") }; nil"#,
323
+ &[mount(memory.clone(), Access::ReadWrite)],
324
+ Vec::new(),
325
+ ) else {
326
+ return;
327
+ };
328
+ assert_eq!(
329
+ (answer, contents(memory.as_ref(), "lock")),
330
+ (Ok(String::new()), b"mine".to_vec()),
331
+ "File.open with w+x of a path nothing is at through the bundled guest must create the \
332
+ file with what was written"
333
+ );
334
+ }
335
+
336
+ // `bw` puts a modifier before the access, and `wbt` names both newline
337
+ // modes, so only a reader keeping CRuby's order and exclusion refuses them.
338
+ // @behavior FL-026
339
+ #[test]
340
+ fn an_access_mode_cruby_refuses_raises_argument_error() {
341
+ let script = r#"
342
+ answers = %w[xw rx r+x ax bw wbt].map do |mode|
343
+ begin
344
+ File.open("/data/note.txt", mode) { }
345
+ "opened"
346
+ rescue ArgumentError => e
347
+ e.message
348
+ end
349
+ end
350
+ print answers.join(",")
351
+ "#;
352
+ let Some(answer) = run(
353
+ script,
354
+ &[mount(note_memory(), Access::ReadWrite)],
355
+ Vec::new(),
356
+ ) else {
357
+ return;
358
+ };
359
+ assert_eq!(
360
+ answer,
361
+ Ok(
362
+ "invalid access mode xw,invalid access mode rx,invalid access mode r+x,\
363
+ invalid access mode ax,invalid access mode bw,invalid access mode wbt"
364
+ .to_string()
365
+ ),
366
+ "File.open with an access mode CRuby refuses through the bundled guest must raise \
367
+ ArgumentError naming the mode"
368
+ );
369
+ }
370
+
371
+ // @behavior FL-027
372
+ #[test]
373
+ fn an_integer_mode_opens_by_its_file_constants_flags() {
374
+ let memory = note_memory();
375
+ let Some(answer) = run(
376
+ r#"File.open("/data/note.txt", File::WRONLY | File::CREAT | File::EXCL) { }"#,
377
+ &[mount(memory.clone(), Access::ReadWrite)],
378
+ Vec::new(),
379
+ ) else {
380
+ return;
381
+ };
382
+ assert_eq!(
383
+ (answer, contents(memory.as_ref(), "note.txt")),
384
+ (Err("Errno::EEXIST".to_string()), b"hello\nworld\n".to_vec()),
385
+ "File.open with WRONLY | CREAT | EXCL of a file already there through the bundled guest \
386
+ must raise Errno::EEXIST and leave the file as it was"
387
+ );
388
+ }
389
+
390
+ // EXCL alone asks no create, so the open finds nothing rather than
391
+ // making the file an exclusive create would.
392
+ // @behavior FL-028
393
+ #[test]
394
+ fn an_exclusive_open_without_create_makes_no_file() {
395
+ let memory = Arc::new(Memory::new());
396
+ let Some(answer) = run(
397
+ r#"File.open("/data/lock", File::RDONLY | File::EXCL) { }"#,
398
+ &[mount(memory.clone(), Access::ReadWrite)],
399
+ Vec::new(),
400
+ ) else {
401
+ return;
402
+ };
403
+ assert_eq!(
404
+ (answer, memory.metadata("lock").is_err()),
405
+ (Err("Errno::ENOENT".to_string()), true),
406
+ "File.open with RDONLY | EXCL of a path nothing is at through the bundled guest must \
407
+ raise Errno::ENOENT and make no file"
408
+ );
409
+ }
410
+
411
+ // 0o4000 is CRuby's `NONBLOCK` on Linux, a flag kobako does not honor, so
412
+ // ignoring it would open differently than CRuby. `WRONLY | RDWR` names
413
+ // no access mode on Linux.
414
+ // @behavior FL-029
415
+ #[test]
416
+ fn an_integer_mode_with_a_flag_file_constants_lacks_raises_einval() {
417
+ let script = r#"
418
+ answers = [File::RDONLY | 0o4000, File::WRONLY | File::RDWR].map do |mode|
419
+ begin
420
+ File.open("/data/note.txt", mode) { }
421
+ "opened"
422
+ rescue => e
423
+ e.class.to_s
424
+ end
425
+ end
426
+ print answers.join(",")
427
+ "#;
428
+ let Some(answer) = run(
429
+ script,
430
+ &[mount(note_memory(), Access::ReadWrite)],
431
+ Vec::new(),
432
+ ) else {
433
+ return;
434
+ };
435
+ assert_eq!(
436
+ answer,
437
+ Ok("Errno::EINVAL,Errno::EINVAL".to_string()),
438
+ "File.open with a mode bit no File::Constants flag names, or with WRONLY beside RDWR, \
439
+ through the bundled guest must raise Errno::EINVAL"
440
+ );
441
+ }
442
+
443
+ // CRuby's `RDONLY` is zero, so beside `WRONLY` it asks nothing more.
444
+ // @behavior FL-031
445
+ #[test]
446
+ fn rdonly_beside_wronly_opens_to_write_only() {
447
+ let memory = note_memory();
448
+ let script = r#"
449
+ File.open("/data/note.txt", File::RDONLY | File::WRONLY) do |file|
450
+ file.write("J")
451
+ begin
452
+ file.read
453
+ rescue IOError => e
454
+ print e.class
455
+ end
456
+ end
457
+ "#;
458
+ let Some(answer) = run(
459
+ script,
460
+ &[mount(memory.clone(), Access::ReadWrite)],
461
+ Vec::new(),
462
+ ) else {
463
+ return;
464
+ };
465
+ assert_eq!(
466
+ (answer, contents(memory.as_ref(), "note.txt")),
467
+ (Ok("IOError".to_string()), b"Jello\nworld\n".to_vec()),
468
+ "File.open with RDONLY | WRONLY through the bundled guest must write and raise IOError \
469
+ on a read"
470
+ );
471
+ }
472
+
473
+ // Neither open asks to write, so only a mode that reaches the host as it
474
+ // stands, rather than one refused before it leaves the guest, changes
475
+ // the Memory.
476
+ // @behavior FL-032
477
+ #[test]
478
+ fn an_integer_mode_may_create_or_truncate_without_asking_to_write() {
479
+ let memory = note_memory();
480
+ let script = r#"
481
+ File.open("/data/lock", File::CREAT | File::EXCL) { }
482
+ File.open("/data/note.txt", File::RDONLY | File::TRUNC) { }
483
+ "#;
484
+ let Some(answer) = run(
485
+ script,
486
+ &[mount(memory.clone(), Access::ReadWrite)],
487
+ Vec::new(),
488
+ ) else {
489
+ return;
490
+ };
491
+ assert_eq!(
492
+ (
493
+ answer,
494
+ contents(memory.as_ref(), "lock"),
495
+ contents(memory.as_ref(), "note.txt")
496
+ ),
497
+ (Ok(String::new()), Vec::new(), Vec::new()),
498
+ "File.open with CREAT | EXCL of a new path and with RDONLY | TRUNC of a file through the \
499
+ bundled guest must make the new file empty and empty the file"
500
+ );
501
+ }
502
+
503
+ // @behavior FL-033
504
+ #[test]
505
+ fn a_path_holding_a_nul_byte_raises_argument_error() {
506
+ let Some(answer) = run(
507
+ r#"File.open("/data/note.txt\0x") { }"#,
508
+ &[mount(note_memory(), Access::ReadWrite)],
509
+ Vec::new(),
510
+ ) else {
511
+ return;
512
+ };
513
+ assert_eq!(
514
+ answer,
515
+ Err("ArgumentError".to_string()),
516
+ "File.open of a path holding a NUL byte through the bundled guest must raise ArgumentError"
517
+ );
518
+ }
519
+
520
+ // The values are CRuby's on Linux, so a script built from numbers it saw
521
+ // there opens the same way.
522
+ // @behavior FL-030
523
+ #[test]
524
+ fn file_constants_names_the_flags_an_open_honors() {
525
+ let script = r##"
526
+ names = File::Constants.constants.sort
527
+ print names.map { |name| "#{name}=#{File::Constants.const_get(name)}" }.join(","),
528
+ " ", IO.include?(File::Constants)
529
+ "##;
530
+ let Some(answer) = run(script, &[], Vec::new()) else {
531
+ return;
532
+ };
533
+ assert_eq!(
534
+ answer,
535
+ Ok("APPEND=1024,CREAT=64,EXCL=128,RDONLY=0,RDWR=2,TRUNC=512,WRONLY=1 true".to_string()),
536
+ "File::Constants in the bundled guest must name the seven flags an open honors at \
537
+ CRuby's Linux values, and IO must include it"
538
+ );
539
+ }
540
+
541
+ /// A Memory whose every open file is handed through `wrap` first.
542
+ struct WrapsFiles<W> {
543
+ memory: Memory,
544
+ wrap: W,
545
+ }
546
+
547
+ impl<W> WrapsFiles<W> {
548
+ fn over_note(contents: &str, wrap: W) -> Arc<Self> {
549
+ Arc::new(Self {
550
+ memory: [("note.txt", contents)].into_iter().collect(),
551
+ wrap,
552
+ })
553
+ }
554
+ }
555
+
556
+ impl<W, F> Filesystem for WrapsFiles<W>
557
+ where
558
+ W: Fn(Box<dyn File>) -> F + Send + Sync,
559
+ F: File + 'static,
560
+ {
561
+ fn open(&self, path: &str, options: &OpenOptions) -> Result<Box<dyn File>, FilesystemError> {
562
+ Ok(Box::new((self.wrap)(self.memory.open(path, options)?)))
563
+ }
564
+
565
+ fn metadata(&self, path: &str) -> Result<Metadata, FilesystemError> {
566
+ self.memory.metadata(path)
567
+ }
568
+
569
+ fn symlink_metadata(&self, path: &str) -> Result<Metadata, FilesystemError> {
570
+ self.memory.symlink_metadata(path)
571
+ }
572
+
573
+ fn stored_path(&self, path: &str) -> Result<String, FilesystemError> {
574
+ self.memory.stored_path(path)
575
+ }
576
+
577
+ fn canonicalize_beneath(&self, base: &str, path: &str) -> Result<String, FilesystemError> {
578
+ self.memory.canonicalize_beneath(base, path)
579
+ }
580
+
581
+ fn for_policy(&self) -> Option<Arc<dyn Filesystem>> {
582
+ self.memory.for_policy()
583
+ }
584
+
585
+ fn hold_directory(&self, path: &str) -> Result<Option<Hold>, FilesystemError> {
586
+ self.memory.hold_directory(path)
587
+ }
588
+
589
+ fn read_dir(&self, path: &str) -> Result<Vec<DirEntry>, FilesystemError> {
590
+ self.memory.read_dir(path)
591
+ }
592
+
593
+ fn create_dir(&self, path: &str) -> Result<(), FilesystemError> {
594
+ self.memory.create_dir(path)
595
+ }
596
+
597
+ fn remove_dir(&self, path: &str) -> Result<(), FilesystemError> {
598
+ self.memory.remove_dir(path)
599
+ }
600
+
601
+ fn remove_file(&self, path: &str) -> Result<(), FilesystemError> {
602
+ self.memory.remove_file(path)
603
+ }
604
+
605
+ fn rename(&self, from: &str, to: &str, rename: Rename) -> Result<(), FilesystemError> {
606
+ self.memory.rename(from, to, rename)
607
+ }
608
+
609
+ fn for_invocation(&self) -> Option<Arc<dyn Filesystem>> {
610
+ self.memory.for_invocation()
611
+ }
612
+ }
613
+
614
+ struct CountedFile {
615
+ file: Box<dyn File>,
616
+ reads: Arc<AtomicUsize>,
617
+ }
618
+
619
+ impl File for CountedFile {
620
+ fn read_at(&mut self, buf: &mut [u8], offset: u64) -> Result<usize, FilesystemError> {
621
+ self.reads.fetch_add(1, Ordering::Relaxed);
622
+ self.file.read_at(buf, offset)
623
+ }
624
+
625
+ fn write_at(&mut self, buf: &[u8], offset: u64) -> Result<usize, FilesystemError> {
626
+ self.file.write_at(buf, offset)
627
+ }
628
+
629
+ fn append(&mut self, buf: &[u8]) -> Result<(u64, usize), FilesystemError> {
630
+ self.file.append(buf)
631
+ }
632
+
633
+ fn metadata(&self) -> Result<Metadata, FilesystemError> {
634
+ self.file.metadata()
635
+ }
636
+
637
+ fn set_len(&mut self, len: u64) -> Result<(), FilesystemError> {
638
+ self.file.set_len(len)
639
+ }
640
+ }
641
+
642
+ /// Each read reaching the Filesystem crosses the wasm boundary, and one a
643
+ /// Host App writes in Ruby re-enters Ruby too, so a read of a length the
644
+ /// file holds asks once.
645
+ #[test]
646
+ fn a_read_of_a_length_asks_the_filesystem_once() {
647
+ let reads = Arc::new(AtomicUsize::new(0));
648
+ let counted = reads.clone();
649
+ let filesystem = WrapsFiles::over_note(&"x".repeat(6400), move |file| CountedFile {
650
+ file,
651
+ reads: counted.clone(),
652
+ });
653
+ let Some(answer) = run(
654
+ r#"File.open("/data/note.txt") { |f| print f.read(64).size }; nil"#,
655
+ &[mount(filesystem, Access::Read)],
656
+ Vec::new(),
657
+ ) else {
658
+ return;
659
+ };
660
+ assert_eq!(answer, Ok("64".to_string()));
661
+ assert_eq!(
662
+ reads.load(Ordering::Relaxed),
663
+ 1,
664
+ "File#read(64) of a longer file through the bundled guest must ask its Filesystem for one read"
665
+ );
666
+ }
667
+
668
+ /// An open file whose Filesystem refuses to release it, as a database
669
+ /// refusing the commit a close stands for.
670
+ struct RefusesClose(Box<dyn File>);
671
+
672
+ impl File for RefusesClose {
673
+ fn read_at(&mut self, buf: &mut [u8], offset: u64) -> Result<usize, FilesystemError> {
674
+ self.0.read_at(buf, offset)
675
+ }
676
+
677
+ fn write_at(&mut self, buf: &[u8], offset: u64) -> Result<usize, FilesystemError> {
678
+ self.0.write_at(buf, offset)
679
+ }
680
+
681
+ fn append(&mut self, buf: &[u8]) -> Result<(u64, usize), FilesystemError> {
682
+ self.0.append(buf)
683
+ }
684
+
685
+ fn metadata(&self) -> Result<Metadata, FilesystemError> {
686
+ self.0.metadata()
687
+ }
688
+
689
+ fn set_len(&mut self, len: u64) -> Result<(), FilesystemError> {
690
+ self.0.set_len(len)
691
+ }
692
+
693
+ fn close(self: Box<Self>) -> Result<(), FilesystemError> {
694
+ Err(FilesystemError::PermissionDenied)
695
+ }
696
+ }
697
+
698
+ /// An open file whose every read fails in a way no error code names.
699
+ struct FailsReads(Box<dyn File>);
700
+
701
+ impl File for FailsReads {
702
+ fn read_at(&mut self, _buf: &mut [u8], _offset: u64) -> Result<usize, FilesystemError> {
703
+ Err(FilesystemError::Other("the disk went away".to_string()))
704
+ }
705
+
706
+ fn write_at(&mut self, buf: &[u8], offset: u64) -> Result<usize, FilesystemError> {
707
+ self.0.write_at(buf, offset)
708
+ }
709
+
710
+ fn append(&mut self, buf: &[u8]) -> Result<(u64, usize), FilesystemError> {
711
+ self.0.append(buf)
712
+ }
713
+
714
+ fn metadata(&self) -> Result<Metadata, FilesystemError> {
715
+ self.0.metadata()
716
+ }
717
+
718
+ fn set_len(&mut self, len: u64) -> Result<(), FilesystemError> {
719
+ self.0.set_len(len)
720
+ }
721
+ }
722
+
723
+ // @behavior FL-011
724
+ #[test]
725
+ fn a_failure_no_error_code_names_raises_eio() {
726
+ let filesystem = WrapsFiles::over_note("hello", FailsReads);
727
+ let Some(answer) = run(
728
+ r#"begin; File.read("/data/note.txt"); rescue Errno::EIO; print "failed"; end; nil"#,
729
+ &[mount(filesystem, Access::Read)],
730
+ Vec::new(),
731
+ ) else {
732
+ return;
733
+ };
734
+ assert_eq!(
735
+ answer,
736
+ Ok("failed".to_string()),
737
+ "File.read of a file whose Filesystem fails with no named code through the bundled guest must raise Errno::EIO"
738
+ );
739
+ }
740
+
741
+ // @behavior FL-010
742
+ #[test]
743
+ fn a_close_the_filesystem_refuses_fails_the_scripts_close() {
744
+ let filesystem = WrapsFiles::over_note("hello", RefusesClose);
745
+ let Some(answer) = run(
746
+ r#"f = File.open("/data/note.txt"); begin; f.close; rescue Errno::EACCES; print "refused"; end; print f.closed?; nil"#,
747
+ &[mount(filesystem, Access::Read)],
748
+ Vec::new(),
749
+ ) else {
750
+ return;
751
+ };
752
+ assert_eq!(
753
+ answer,
754
+ Ok("refusedtrue".to_string()),
755
+ "File#close of a file whose Filesystem refuses its close through the bundled guest must raise Errno::EACCES and leave the file closed"
756
+ );
757
+ }
758
+
759
+ /// An open file taking at most three bytes per write, as a Filesystem a
760
+ /// Host App writes in Ruby may.
761
+ struct WritesInParts(Box<dyn File>);
762
+
763
+ impl File for WritesInParts {
764
+ fn read_at(&mut self, buf: &mut [u8], offset: u64) -> Result<usize, FilesystemError> {
765
+ self.0.read_at(buf, offset)
766
+ }
767
+
768
+ fn write_at(&mut self, buf: &[u8], offset: u64) -> Result<usize, FilesystemError> {
769
+ self.0.write_at(&buf[..buf.len().min(3)], offset)
770
+ }
771
+
772
+ fn append(&mut self, buf: &[u8]) -> Result<(u64, usize), FilesystemError> {
773
+ self.0.append(&buf[..buf.len().min(3)])
774
+ }
775
+
776
+ fn metadata(&self) -> Result<Metadata, FilesystemError> {
777
+ self.0.metadata()
778
+ }
779
+
780
+ fn set_len(&mut self, len: u64) -> Result<(), FilesystemError> {
781
+ self.0.set_len(len)
782
+ }
783
+ }
784
+
785
+ // @behavior FL-016
786
+ #[test]
787
+ fn an_errno_a_script_raises_reads_as_crubys() {
788
+ let script = r#"
789
+ out = []
790
+ begin; raise Errno::ENOENT, "foo"; rescue => e; out << e.message; end
791
+ out << Errno::ENOENT.new.message
792
+ e = SystemCallError.new("failed", 2)
793
+ out << e.class.to_s << e.errno
794
+ print out.inspect
795
+ "#;
796
+ let Some(answer) = run(script, &[mount(note_memory(), Access::Read)], Vec::new()) else {
797
+ return;
798
+ };
799
+ assert_eq!(
800
+ answer,
801
+ Ok(r#"["No such file or directory - foo", "No such file or directory", "Errno::ENOENT", 2]"#
802
+ .to_string()),
803
+ "Errno::ENOENT raised with a message, built bare, and SystemCallError.new with a known \
804
+ number through the bundled guest must read as CRuby's"
805
+ );
806
+ }
807
+
808
+ // No Errno class names 9999, so only the number the error was built with
809
+ // can answer `errno`.
810
+ // @behavior FL-019
811
+ #[test]
812
+ fn a_system_call_error_keeps_a_number_no_class_names() {
813
+ let Some(answer) = run(
814
+ r#"e = SystemCallError.new(9999); print [e.class.to_s, e.message, e.errno].inspect"#,
815
+ &[mount(note_memory(), Access::Read)],
816
+ Vec::new(),
817
+ ) else {
818
+ return;
819
+ };
820
+ assert_eq!(
821
+ answer,
822
+ Ok(r#"["SystemCallError", "Unknown error: 9999", 9999]"#.to_string()),
823
+ "SystemCallError.new with a number no Errno class names through the bundled guest must \
824
+ keep that number as its errno, as CRuby does"
825
+ );
826
+ }
827
+
828
+ // @behavior FL-022
829
+ #[test]
830
+ fn a_read_length_past_64_bits_raises_range_error() {
831
+ let script = r#"
832
+ begin
833
+ File.open("/data/note.txt") { |f| f.read(2**70) }
834
+ rescue RangeError => e
835
+ print e.class.to_s
836
+ end
837
+ "#;
838
+ let Some(answer) = run(script, &[mount(note_memory(), Access::Read)], Vec::new()) else {
839
+ return;
840
+ };
841
+ assert_eq!(
842
+ answer,
843
+ Ok("RangeError".to_string()),
844
+ "File#read with a length of 2**70 through the bundled guest must raise RangeError, as \
845
+ CRuby does"
846
+ );
847
+ }
848
+
849
+ // The C errno is an `int`, so 2**40 is already past it although it is no
850
+ // big integer.
851
+ // @behavior FL-023
852
+ #[test]
853
+ fn a_system_call_error_number_past_the_errno_range_raises_range_error() {
854
+ let script = r#"
855
+ out = [2**40, 2**70].map do |number|
856
+ SystemCallError.new("failed", number)
857
+ rescue RangeError => e
858
+ e.class.to_s
859
+ end
860
+ print out.inspect
861
+ "#;
862
+ let Some(answer) = run(script, &[mount(note_memory(), Access::Read)], Vec::new()) else {
863
+ return;
864
+ };
865
+ assert_eq!(
866
+ answer,
867
+ Ok(r#"["RangeError", "RangeError"]"#.to_string()),
868
+ "SystemCallError.new with a message and 2**40 or 2**70 through the bundled guest must \
869
+ raise RangeError, as CRuby does"
870
+ );
871
+ }
872
+
873
+ // @behavior FL-017
874
+ #[test]
875
+ fn a_read_or_write_a_stream_was_not_opened_for_raises_ioerror() {
876
+ let script = r#"
877
+ out = []
878
+ [-> { File.open("/data/note.txt", "r") { |f| f.write("x") } },
879
+ -> { File.open("/data/note.txt", "w") { |f| f.read } },
880
+ -> { $stdout.read }].each do |call|
881
+ begin; call.call; rescue IOError => e; out << e.message; end
882
+ end
883
+ print out.inspect
884
+ "#;
885
+ let Some(answer) = run(
886
+ script,
887
+ &[mount(note_memory(), Access::ReadWrite)],
888
+ Vec::new(),
889
+ ) else {
890
+ return;
891
+ };
892
+ assert_eq!(
893
+ answer,
894
+ Ok(
895
+ r#"["not opened for writing", "not opened for reading", "not opened for reading"]"#
896
+ .to_string()
897
+ ),
898
+ "a write to a file opened with r, a read of one opened with w, and $stdout.read through \
899
+ the bundled guest must raise IOError as CRuby does"
900
+ );
901
+ }
902
+
903
+ // @behavior FL-018
904
+ #[test]
905
+ fn a_path_that_is_no_valid_utf8_raises_eilseq() {
906
+ let Some(answer) = run(
907
+ r#"File.read("/data/\xff")"#,
908
+ &[mount(note_memory(), Access::Read)],
909
+ Vec::new(),
910
+ ) else {
911
+ return;
912
+ };
913
+ assert_eq!(
914
+ answer,
915
+ Err("Errno::EILSEQ".to_string()),
916
+ "File.read of a path holding the byte 0xff through the bundled guest must raise Errno::EILSEQ"
917
+ );
918
+ }
919
+
920
+ // @behavior FL-015
921
+ #[test]
922
+ fn a_write_lands_whole_when_the_filesystem_takes_it_in_parts() {
923
+ let filesystem = WrapsFiles::over_note("", WritesInParts);
924
+ let Some(answer) = run(
925
+ r#"File.open("/data/note.txt", "w") { |f| print f.write("hello world") }; nil"#,
926
+ &[mount(filesystem.clone(), Access::ReadWrite)],
927
+ Vec::new(),
928
+ ) else {
929
+ return;
930
+ };
931
+ assert_eq!(
932
+ (answer, contents(filesystem.as_ref(), "note.txt")),
933
+ (Ok("11".to_string()), b"hello world".to_vec()),
934
+ "IO#write to a file whose Filesystem takes three bytes per write through the bundled guest \
935
+ must answer every byte and land them all"
936
+ );
937
+ }
938
+
939
+ /// An open file reporting a length it does not hold, so a script meets a
940
+ /// file of several GiB without the test allocating one.
941
+ struct ReportsLen(Box<dyn File>, u64);
942
+
943
+ impl File for ReportsLen {
944
+ fn read_at(&mut self, buf: &mut [u8], offset: u64) -> Result<usize, FilesystemError> {
945
+ self.0.read_at(buf, offset)
946
+ }
947
+
948
+ fn write_at(&mut self, buf: &[u8], offset: u64) -> Result<usize, FilesystemError> {
949
+ self.0.write_at(buf, offset)
950
+ }
951
+
952
+ fn append(&mut self, buf: &[u8]) -> Result<(u64, usize), FilesystemError> {
953
+ self.0.append(buf)
954
+ }
955
+
956
+ fn metadata(&self) -> Result<Metadata, FilesystemError> {
957
+ let mut metadata = self.0.metadata()?;
958
+ metadata.len = self.1;
959
+ Ok(metadata)
960
+ }
961
+
962
+ fn set_len(&mut self, len: u64) -> Result<(), FilesystemError> {
963
+ self.0.set_len(len)
964
+ }
965
+ }
966
+
967
+ fn three_gib_note() -> Grant {
968
+ let filesystem = WrapsFiles::over_note("hello", |file| ReportsLen(file, 3 << 30));
969
+ mount(filesystem, Access::Read)
970
+ }
971
+
972
+ // @behavior FL-020
973
+ #[test]
974
+ fn a_file_past_2_gib_reports_its_whole_size() {
975
+ let Some(answer) = run(
976
+ r#"File.open("/data/note.txt") { |f| print f.size }"#,
977
+ &[three_gib_note()],
978
+ Vec::new(),
979
+ ) else {
980
+ return;
981
+ };
982
+ assert_eq!(
983
+ answer,
984
+ Ok("3221225472".to_string()),
985
+ "File#size of a 3 GiB file through the bundled guest must answer 3221225472, not a \
986
+ 32-bit ceiling"
987
+ );
988
+ }
989
+
990
+ // @behavior FL-021
991
+ #[test]
992
+ fn a_position_past_2_gib_is_reported_whole() {
993
+ let Some(answer) = run(
994
+ r#"File.open("/data/note.txt") { |f| f.seek(2147483649); print f.pos }"#,
995
+ &[three_gib_note()],
996
+ Vec::new(),
997
+ ) else {
998
+ return;
999
+ };
1000
+ assert_eq!(
1001
+ answer,
1002
+ Ok("2147483649".to_string()),
1003
+ "IO#pos after seeking a 3 GiB file to 2 GiB and one byte through the bundled guest \
1004
+ must answer 2147483649"
1005
+ );
1006
+ }
1007
+
1008
+ // @behavior FL-036
1009
+ #[test]
1010
+ fn setting_a_files_position_moves_where_the_next_read_starts() {
1011
+ let Some(answer) = run(
1012
+ r#"File.open("/data/note.txt") { |f| f.pos = 2; print f.read }"#,
1013
+ &[mount(note_memory(), Access::Read)],
1014
+ Vec::new(),
1015
+ ) else {
1016
+ return;
1017
+ };
1018
+ assert_eq!(
1019
+ answer,
1020
+ Ok("llo\nworld\n".to_string()),
1021
+ "IO#pos= 2 over a file of hello and world through the bundled guest must leave the \
1022
+ next read at llo"
1023
+ );
1024
+ }
1025
+
1026
+ // @behavior FL-037
1027
+ // Each seek is refused alone, so one that moved or answered another class
1028
+ // would print its own name in place of `Errno::EINVAL`.
1029
+ #[test]
1030
+ fn a_seek_before_the_start_or_by_an_unknown_whence_raises_einval() {
1031
+ let Some(answer) = run(
1032
+ r#"File.open("/data/note.txt") do |f|
1033
+ [-> { f.pos = -1 }, -> { f.seek(-1) }, -> { f.seek(1, 9) }].each do |seek|
1034
+ begin
1035
+ seek.call
1036
+ print "moved "
1037
+ rescue => e
1038
+ print e.class, " "
1039
+ end
1040
+ end
1041
+ end
1042
+ nil"#,
1043
+ &[mount(note_memory(), Access::Read)],
1044
+ Vec::new(),
1045
+ ) else {
1046
+ return;
1047
+ };
1048
+ assert_eq!(
1049
+ answer,
1050
+ Ok("Errno::EINVAL Errno::EINVAL Errno::EINVAL ".to_string()),
1051
+ "IO#pos= -1, IO#seek(-1), and IO#seek(1, 9) through the bundled guest must each raise \
1052
+ Errno::EINVAL"
1053
+ );
1054
+ }