kobako 0.28.0 → 0.30.0

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