kobako 0.29.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 (201) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +217 -10
  4. data/Cargo.lock +139 -141
  5. data/README.md +96 -574
  6. data/ROADMAP.md +2 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +269 -1
  8. data/crates/kobako-runtime/Cargo.toml +25 -15
  9. data/crates/kobako-runtime/README.md +7 -3
  10. data/crates/kobako-runtime/src/dispatch.rs +1 -1
  11. data/crates/kobako-runtime/src/endpoint/buffer.rs +192 -0
  12. data/crates/kobako-runtime/src/endpoint/capture.rs +115 -0
  13. data/crates/kobako-runtime/src/endpoint/dispatch.rs +253 -0
  14. data/crates/kobako-runtime/src/endpoint/invoke.rs +194 -0
  15. data/crates/kobako-runtime/src/endpoint/memory_cap.rs +238 -0
  16. data/crates/kobako-runtime/src/endpoint.rs +166 -0
  17. data/crates/kobako-runtime/src/filesystem/bind/fid.rs +82 -0
  18. data/crates/kobako-runtime/src/filesystem/{directory/spelling.rs → bind/lookup.rs} +91 -49
  19. data/crates/kobako-runtime/src/filesystem/bind/walk.rs +249 -0
  20. data/crates/kobako-runtime/src/filesystem/bind.rs +2665 -0
  21. data/crates/kobako-runtime/src/filesystem/overlay/file.rs +89 -0
  22. data/crates/kobako-runtime/src/filesystem/overlay/inode.rs +106 -0
  23. data/crates/kobako-runtime/src/filesystem/overlay/path.rs +285 -0
  24. data/crates/kobako-runtime/src/filesystem/overlay/stack/copy_up.rs +135 -0
  25. data/crates/kobako-runtime/src/filesystem/overlay/stack/dir.rs +263 -0
  26. data/crates/kobako-runtime/src/filesystem/overlay/stack/namei.rs +135 -0
  27. data/crates/kobako-runtime/src/filesystem/overlay/stack/readdir.rs +57 -0
  28. data/crates/kobako-runtime/src/filesystem/overlay/stack.rs +227 -734
  29. data/crates/kobako-runtime/src/filesystem/overlay.rs +2649 -1135
  30. data/crates/kobako-runtime/src/filesystem/path.rs +2 -41
  31. data/crates/kobako-runtime/src/filesystem/pipe.rs +221 -0
  32. data/crates/kobako-runtime/src/filesystem/rdir.rs +101 -0
  33. data/crates/kobako-runtime/src/filesystem/tmpfs/tmpfile.rs +32 -0
  34. data/crates/kobako-runtime/src/filesystem/tmpfs/whiteout.rs +85 -0
  35. data/crates/kobako-runtime/src/filesystem/tmpfs/xattr.rs +22 -0
  36. data/crates/kobako-runtime/src/filesystem/tmpfs.rs +1985 -0
  37. data/crates/kobako-runtime/src/filesystem/wrap.rs +224 -0
  38. data/crates/kobako-runtime/src/filesystem.rs +21 -387
  39. data/crates/kobako-runtime/src/hashtable.rs +44 -0
  40. data/crates/kobako-runtime/src/lib.rs +11 -6
  41. data/crates/kobako-runtime/src/locking/seqlock.rs +66 -0
  42. data/crates/kobako-runtime/src/locking.rs +218 -0
  43. data/crates/kobako-runtime/src/mount.rs +31 -104
  44. data/crates/kobako-runtime/src/profile.rs +137 -1
  45. data/crates/kobako-runtime/src/sched.rs +210 -0
  46. data/crates/kobako-runtime/src/security.rs +696 -0
  47. data/crates/kobako-runtime/src/vfs/attr.rs +162 -0
  48. data/crates/kobako-runtime/src/vfs/cred.rs +334 -0
  49. data/crates/kobako-runtime/src/vfs/dentry.rs +1412 -0
  50. data/crates/kobako-runtime/src/vfs/errno.rs +153 -0
  51. data/crates/kobako-runtime/src/vfs/file.rs +1915 -0
  52. data/crates/kobako-runtime/src/vfs/inode.rs +429 -0
  53. data/crates/kobako-runtime/src/vfs/kern_path.rs +217 -0
  54. data/crates/kobako-runtime/src/vfs/mount.rs +59 -0
  55. data/crates/kobako-runtime/src/vfs/namei.rs +5708 -0
  56. data/crates/kobako-runtime/src/vfs/openat2.rs +116 -0
  57. data/crates/kobako-runtime/src/vfs/path.rs +64 -0
  58. data/crates/kobako-runtime/src/vfs/readdir.rs +163 -0
  59. data/crates/kobako-runtime/src/vfs/stat.rs +157 -0
  60. data/crates/kobako-runtime/src/vfs/super_block.rs +122 -0
  61. data/crates/kobako-runtime/src/vfs/utimes.rs +289 -0
  62. data/crates/kobako-runtime/src/vfs.rs +60 -0
  63. data/crates/kobako-runtime/tests/linux_baseline/calls.rs +171 -0
  64. data/crates/kobako-runtime/tests/linux_baseline/concurrent.rs +276 -0
  65. data/crates/kobako-runtime/tests/linux_baseline/kobako.rs +323 -0
  66. data/crates/kobako-runtime/tests/linux_baseline/linux-baseline-expected.txt +16 -0
  67. data/crates/kobako-runtime/tests/linux_baseline/linux.rs +449 -0
  68. data/crates/kobako-runtime/tests/linux_baseline/main.rs +611 -0
  69. data/crates/kobako-runtime/tests/linux_baseline/permissions.rs +236 -0
  70. data/crates/kobako-transport/CHANGELOG.md +8 -2
  71. data/crates/kobako-transport/Cargo.toml +3 -3
  72. data/crates/kobako-transport/README.md +6 -5
  73. data/crates/kobako-transport/src/envelope/bytes.rs +33 -25
  74. data/crates/kobako-transport/src/envelope/call.rs +5 -5
  75. data/crates/kobako-transport/src/envelope/compile_reply.rs +2 -2
  76. data/crates/kobako-transport/src/envelope/error_record.rs +3 -3
  77. data/crates/kobako-transport/src/envelope/fault.rs +2 -2
  78. data/crates/kobako-transport/src/envelope/outcome.rs +3 -3
  79. data/crates/kobako-transport/src/envelope/preamble.rs +12 -12
  80. data/crates/kobako-transport/src/envelope/reply.rs +2 -2
  81. data/crates/kobako-transport/src/envelope/request.rs +2 -2
  82. data/crates/kobako-transport/src/envelope/run.rs +1 -1
  83. data/crates/kobako-transport/src/envelope/unsupported.rs +2 -2
  84. data/crates/kobako-transport/src/envelope.rs +2 -2
  85. data/crates/kobako-wasmtime/CHANGELOG.md +154 -8
  86. data/crates/kobako-wasmtime/Cargo.toml +15 -14
  87. data/crates/kobako-wasmtime/README.md +9 -5
  88. data/crates/kobako-wasmtime/src/abi.rs +12 -20
  89. data/crates/kobako-wasmtime/src/ambient.rs +1 -2
  90. data/crates/kobako-wasmtime/src/cache.rs +5 -5
  91. data/crates/kobako-wasmtime/src/config.rs +22 -7
  92. data/crates/kobako-wasmtime/src/descriptors.rs +133 -100
  93. data/crates/kobako-wasmtime/src/driver.rs +29 -96
  94. data/crates/kobako-wasmtime/src/exports.rs +5 -16
  95. data/crates/kobako-wasmtime/src/guest.rs +38 -0
  96. data/crates/kobako-wasmtime/src/imports.rs +14 -8
  97. data/crates/kobako-wasmtime/src/instance_pre.rs +3 -3
  98. data/crates/kobako-wasmtime/src/invocation.rs +26 -46
  99. data/crates/kobako-wasmtime/src/lib.rs +1 -4
  100. data/crates/kobako-wasmtime/src/limiter.rs +6 -230
  101. data/crates/kobako-wasmtime/src/preview1/differential/mount.rs +69 -46
  102. data/crates/kobako-wasmtime/src/preview1/differential.rs +121 -7
  103. data/crates/kobako-wasmtime/src/preview1/errno.rs +74 -206
  104. data/crates/kobako-wasmtime/src/preview1/fd.rs +1248 -500
  105. data/crates/kobako-wasmtime/src/preview1/memory.rs +117 -150
  106. data/crates/kobako-wasmtime/src/preview1/mount_view/wrap.rs +211 -0
  107. data/crates/kobako-wasmtime/src/preview1/mount_view.rs +1939 -672
  108. data/crates/kobako-wasmtime/src/preview1/path.rs +230 -349
  109. data/crates/kobako-wasmtime/src/preview1/poll.rs +148 -100
  110. data/crates/kobako-wasmtime/src/preview1/sock.rs +6 -23
  111. data/crates/kobako-wasmtime/src/preview1/testsuite-expected.txt +6 -0
  112. data/crates/kobako-wasmtime/src/preview1/testsuite.rs +157 -0
  113. data/crates/kobako-wasmtime/src/preview1.rs +464 -413
  114. data/crates/kobako-wasmtime/src/trap.rs +9 -9
  115. data/crates/kobako-wasmtime/src/wasi.rs +53 -37
  116. data/crates/kobako-wasmtime/tests/{guest_file.rs → integration/guest_file.rs} +232 -201
  117. data/crates/kobako-wasmtime/tests/integration/main.rs +29 -0
  118. data/crates/kobako-wasmtime/tests/{preamble_flags.rs → integration/preamble_flags.rs} +1 -1
  119. data/crates/kobako-wasmtime/tests/{snippet_compile.rs → integration/snippet_compile.rs} +1 -1
  120. data/crates/kobako-wasmtime/tests/{unanswered_call.rs → integration/unanswered_call.rs} +1 -1
  121. data/crates/kobako-wasmtime/tests/{unsupported_call.rs → integration/unsupported_call.rs} +1 -1
  122. data/crates/kobako-wasmtime/witx/typenames.witx +750 -0
  123. data/crates/kobako-wasmtime/witx/wasi_snapshot_preview1.witx +441 -0
  124. data/data/kobako.wasm +0 -0
  125. data/ext/kobako/Cargo.toml +1 -1
  126. data/ext/kobako/src/runtime/bridge.rs +1 -1
  127. data/ext/kobako/src/runtime/errors.rs +1 -1
  128. data/ext/kobako/src/runtime/escape.rs +28 -14
  129. data/ext/kobako/src/runtime/filesystem/grants.rs +21 -5
  130. data/ext/kobako/src/runtime/filesystem/ruby.rs +464 -156
  131. data/ext/kobako/src/runtime/filesystem.rs +109 -137
  132. data/ext/kobako/src/runtime/gvl.rs +43 -8
  133. data/ext/kobako/src/runtime/snapshot.rs +1 -1
  134. data/ext/kobako/src/runtime.rs +6 -1
  135. data/lib/kobako/catalog/extensions.rb +5 -5
  136. data/lib/kobako/catalog/flags.rb +2 -2
  137. data/lib/kobako/catalog/handles.rb +6 -6
  138. data/lib/kobako/catalog/mount.rb +2 -10
  139. data/lib/kobako/catalog/services.rb +2 -2
  140. data/lib/kobako/catalog/snippets.rb +7 -7
  141. data/lib/kobako/catalog.rb +1 -1
  142. data/lib/kobako/codec/decoder.rb +10 -10
  143. data/lib/kobako/codec/ext_types.rb +3 -5
  144. data/lib/kobako/codec/handle_walk.rb +4 -6
  145. data/lib/kobako/codec/nesting.rb +7 -7
  146. data/lib/kobako/codec/utils.rb +1 -1
  147. data/lib/kobako/codec/value_budget.rb +3 -3
  148. data/lib/kobako/context.rb +5 -5
  149. data/lib/kobako/filesystem.rb +44 -13
  150. data/lib/kobako/payload/arguments.rb +2 -2
  151. data/lib/kobako/pool.rb +4 -4
  152. data/lib/kobako/sandbox.rb +28 -28
  153. data/lib/kobako/sandbox_options.rb +2 -3
  154. data/lib/kobako/snippet/bytecode.rb +1 -1
  155. data/lib/kobako/snippet/source.rb +1 -1
  156. data/lib/kobako/transport/dispatcher.rb +5 -5
  157. data/lib/kobako/transport/exposure.rb +2 -2
  158. data/lib/kobako/transport/reflection.rb +7 -7
  159. data/lib/kobako/transport/run.rb +5 -5
  160. data/lib/kobako/transport/yielder.rb +1 -1
  161. data/lib/kobako/version.rb +1 -1
  162. data/lib/kobako.rb +34 -0
  163. data/release-please-config.json +1 -0
  164. data/sig/kobako/catalog/extensions.rbs +3 -3
  165. data/sig/kobako/catalog/handles.rbs +3 -3
  166. data/sig/kobako/catalog/mount.rbs +0 -4
  167. data/sig/kobako/catalog/services.rbs +1 -1
  168. data/sig/kobako/catalog/snippets.rbs +3 -3
  169. data/sig/kobako/catalog.rbs +1 -1
  170. data/sig/kobako/codec/decoder.rbs +1 -1
  171. data/sig/kobako/codec/handle_walk.rbs +1 -1
  172. data/sig/kobako/codec/nesting.rbs +2 -2
  173. data/sig/kobako/codec/utils.rbs +1 -1
  174. data/sig/kobako/codec/value_budget.rbs +1 -1
  175. data/sig/kobako/codec.rbs +1 -2
  176. data/sig/kobako/context.rbs +1 -1
  177. data/sig/kobako/filesystem.rbs +19 -14
  178. data/sig/kobako/guest_yielder.rbs +1 -1
  179. data/sig/kobako/payload/arguments.rbs +1 -1
  180. data/sig/kobako/pool.rbs +2 -2
  181. data/sig/kobako/runtime.rbs +1 -1
  182. data/sig/kobako/sandbox.rbs +3 -5
  183. data/sig/kobako/sandbox_options.rbs +1 -1
  184. data/sig/kobako/transport/dispatcher.rbs +2 -2
  185. data/sig/kobako/transport/exposure.rbs +1 -1
  186. data/sig/kobako/transport/reflection.rbs +2 -2
  187. data/sig/kobako/transport/run.rbs +3 -3
  188. data/sig/kobako/transport.rbs +1 -1
  189. metadata +67 -19
  190. data/crates/kobako-runtime/src/filesystem/directory.rs +0 -1082
  191. data/crates/kobako-runtime/src/filesystem/memory.rs +0 -1100
  192. data/crates/kobako-runtime/src/filesystem/overlay/exclusive.rs +0 -88
  193. data/crates/kobako-runtime/src/filesystem/temporary.rs +0 -167
  194. data/crates/kobako-wasmtime/src/capture.rs +0 -96
  195. data/crates/kobako-wasmtime/src/dispatch.rs +0 -125
  196. data/crates/kobako-wasmtime/src/guest_mem.rs +0 -271
  197. data/crates/kobako-wasmtime/src/io.rs +0 -89
  198. data/crates/kobako-wasmtime/src/preview1/args.rs +0 -60
  199. data/crates/kobako-wasmtime/src/preview1/beneath.rs +0 -188
  200. /data/crates/kobako-wasmtime/tests/{compile_posture.rs → integration/compile_posture.rs} +0 -0
  201. /data/crates/kobako-wasmtime/tests/{support → integration/support}/mod.rs +0 -0
@@ -1,71 +1,81 @@
1
1
  //! How a host directory spells a name it holds. A host that folds case or
2
- //! Unicode form reaches one entry by many names, so a Policy judging the
3
- //! name as asked would see a spelling the host never stored.
2
+ //! Unicode form reaches one entry by many names, so the runtime keeping a
3
+ //! dentry per name as asked would hold one entry under several.
4
4
  //!
5
5
  //! macOS answers the stored name for any spelling. Elsewhere a listing
6
6
  //! finds the entry by its inode, skipped on a Linux filesystem known to
7
7
  //! match names exactly; without an inode, a spelling the listing lacks is
8
8
  //! refused.
9
9
 
10
+ #[cfg(not(target_os = "macos"))]
10
11
  use std::io;
11
12
 
12
13
  use cap_std::fs::Dir;
13
14
 
15
+ use super::{to_error, FilesystemError};
16
+
14
17
  /// The name `parent` stores for the entry `name` reaches, a link reached
15
- /// as itself, or `None` when nothing is there.
16
- pub(super) fn stored_name(parent: &Dir, name: &str) -> io::Result<Option<String>> {
18
+ /// as itself; `name` as given when nothing is there.
19
+ pub(super) fn lookup(parent: &Dir, name: &str) -> Result<String, FilesystemError> {
17
20
  #[cfg(target_os = "macos")]
18
21
  {
19
- macos::stored_name(parent, name)
22
+ macos::lookup(parent, name).map_err(to_error)
20
23
  }
21
24
  #[cfg(not(target_os = "macos"))]
22
25
  {
23
26
  #[cfg(target_os = "linux")]
24
27
  if linux::matches_exactly(parent) {
25
- return match parent.symlink_metadata(name) {
26
- Ok(_) => Ok(Some(name.to_string())),
27
- Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
28
- Err(err) => Err(err),
29
- };
28
+ return Ok(name.to_string());
30
29
  }
31
- stored_name_by_listing(parent, name)
30
+ lookup_by_listing(parent, name)
32
31
  }
33
32
  }
34
33
 
35
34
  /// The name a listing of `parent` holds for `name`: itself when listed,
36
- /// else the entry sharing its inode.
35
+ /// else the entry sharing its inode. A spelling the listing lacks and no
36
+ /// inode ties to a listed name is refused as `EACCES`.
37
37
  #[cfg(not(target_os = "macos"))]
38
- fn stored_name_by_listing(parent: &Dir, name: &str) -> io::Result<Option<String>> {
38
+ fn lookup_by_listing(parent: &Dir, name: &str) -> Result<String, FilesystemError> {
39
39
  let found = match parent.symlink_metadata(name) {
40
40
  Ok(found) => found,
41
- Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
42
- Err(err) => return Err(err),
41
+ Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(name.to_string()),
42
+ Err(err) => return Err(to_error(err)),
43
43
  };
44
44
  let mut names = Vec::new();
45
- for entry in parent.entries()? {
46
- let entry = entry?;
45
+ for entry in parent.entries().map_err(to_error)? {
46
+ let entry = entry.map_err(to_error)?;
47
47
  if entry.file_name() == name {
48
- return Ok(Some(name.to_string()));
48
+ return Ok(name.to_string());
49
49
  }
50
50
  names.push(entry);
51
51
  }
52
- #[cfg(unix)]
53
- {
54
- use cap_std::fs::MetadataExt;
55
- use std::os::unix::fs::DirEntryExt;
56
- for entry in names {
57
- if entry.ino() == found.ino() {
58
- return entry
59
- .file_name()
60
- .into_string()
61
- .map(Some)
62
- .map_err(|_| io::ErrorKind::InvalidData.into());
63
- }
64
- }
65
- }
66
- #[cfg(not(unix))]
67
- let _ = (found, names);
68
- Err(io::ErrorKind::PermissionDenied.into())
52
+ stored_by_inode(names, &found)
53
+ }
54
+
55
+ /// The name among `names` sharing the inode of `found`.
56
+ #[cfg(all(unix, not(target_os = "macos")))]
57
+ fn stored_by_inode(
58
+ names: Vec<cap_std::fs::DirEntry>,
59
+ found: &cap_std::fs::Metadata,
60
+ ) -> Result<String, FilesystemError> {
61
+ use cap_std::fs::MetadataExt;
62
+ use std::os::unix::fs::DirEntryExt;
63
+ names
64
+ .into_iter()
65
+ .find(|entry| entry.ino() == found.ino())
66
+ .ok_or(FilesystemError::PermissionDenied)?
67
+ .file_name()
68
+ .into_string()
69
+ .map_err(|_| to_error(io::ErrorKind::InvalidData.into()))
70
+ }
71
+
72
+ /// Without an inode nothing ties a spelling to a listed name.
73
+ #[cfg(not(unix))]
74
+ fn stored_by_inode(
75
+ _names: Vec<cap_std::fs::DirEntry>,
76
+ _found: &cap_std::fs::Metadata,
77
+ ) -> Result<String, FilesystemError> {
78
+ Err(FilesystemError::PermissionDenied)
69
79
  }
70
80
 
71
81
  #[cfg(target_os = "macos")]
@@ -85,7 +95,7 @@ mod macos {
85
95
  data: [u8; 1024],
86
96
  }
87
97
 
88
- pub(super) fn stored_name(parent: &Dir, name: &str) -> io::Result<Option<String>> {
98
+ pub(super) fn lookup(parent: &Dir, name: &str) -> io::Result<String> {
89
99
  let path = CString::new(name).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?;
90
100
  let mut request = libc::attrlist {
91
101
  bitmapcount: libc::ATTR_BIT_MAP_COUNT,
@@ -119,7 +129,7 @@ mod macos {
119
129
  if answered != 0 {
120
130
  let err = io::Error::last_os_error();
121
131
  return match err.kind() {
122
- io::ErrorKind::NotFound => Ok(None),
132
+ io::ErrorKind::NotFound => Ok(name.to_string()),
123
133
  _ => Err(err),
124
134
  };
125
135
  }
@@ -129,20 +139,18 @@ mod macos {
129
139
  .ok()
130
140
  .and_then(|offset| offset.checked_sub(size_of::<libc::attrreference_t>()));
131
141
  let len = (reply.name.attr_length as usize).checked_sub(1);
132
- let stored = start
142
+ let stored_name = start
133
143
  .zip(len)
134
144
  .and_then(|(start, len)| reply.data.get(start..start.checked_add(len)?))
135
145
  .ok_or(io::ErrorKind::InvalidData)?;
136
- String::from_utf8(stored.to_vec())
137
- .map(Some)
138
- .map_err(|_| io::ErrorKind::InvalidData.into())
146
+ String::from_utf8(stored_name.to_vec()).map_err(|_| io::ErrorKind::InvalidData.into())
139
147
  }
140
148
  }
141
149
 
142
150
  #[cfg(target_os = "linux")]
143
151
  mod linux {
144
152
  use cap_std::fs::Dir;
145
- use rustix::fs::{fstatfs, ioctl_getflags};
153
+ use rustix::fs::{fstatfs, ioctl_getflags, openat, Mode, OFlags};
146
154
 
147
155
  /// The 32-bit magic numbers of filesystems matching names byte for
148
156
  /// byte unless a directory is marked to fold case: ext4, Btrfs, tmpfs,
@@ -154,17 +162,51 @@ mod linux {
154
162
  /// Linux's `FS_CASEFOLD_FL`.
155
163
  const CASEFOLD: u32 = 0x4000_0000;
156
164
 
165
+ /// cap-std holds `parent` for its path alone, which answers no flags,
166
+ /// so they are asked of the same directory opened to read.
157
167
  pub(super) fn matches_exactly(parent: &Dir) -> bool {
158
168
  let exact = fstatfs(parent).is_ok_and(|stats| EXACT.contains(&(stats.f_type as u32)));
159
- exact && ioctl_getflags(parent).is_ok_and(|flags| flags.bits() & CASEFOLD == 0)
169
+ exact
170
+ && openat(
171
+ parent,
172
+ ".",
173
+ OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
174
+ Mode::empty(),
175
+ )
176
+ .and_then(|readable| ioctl_getflags(&readable))
177
+ .is_ok_and(|flags| flags.bits() & CASEFOLD == 0)
160
178
  }
161
179
 
162
180
  #[cfg(test)]
163
181
  mod tests {
164
182
  use cap_std::ambient_authority;
165
183
 
166
- use super::super::stored_name_by_listing;
167
- use super::Dir;
184
+ use super::super::lookup_by_listing;
185
+ use super::{fstatfs, matches_exactly, Dir, EXACT};
186
+
187
+ // A Directory opens its directories as cap-std does, for a path
188
+ // alone, and a filesystem known to match exactly still spells
189
+ // without listing. A host on another filesystem has nothing here
190
+ // to witness.
191
+ // @behavior FS-081
192
+ #[test]
193
+ fn a_directory_opened_for_its_path_alone_is_known_to_match_exactly() {
194
+ let path = std::env::temp_dir().join(format!("kobako-exact-{}", std::process::id()));
195
+ let _ = std::fs::remove_dir_all(&path);
196
+ std::fs::create_dir_all(&path).expect("the host directory must be created");
197
+ let dir = Dir::open_ambient_dir(&path, ambient_authority())
198
+ .expect("the host directory must open");
199
+ let known = fstatfs(&dir).is_ok_and(|stats| EXACT.contains(&(stats.f_type as u32)));
200
+ let exact = matches_exactly(&dir);
201
+ let _ = std::fs::remove_dir_all(&path);
202
+ if known {
203
+ assert!(
204
+ exact,
205
+ "a directory cap-std opened on a filesystem known to match names exactly, \
206
+ asked through matches_exactly, must answer that it matches exactly"
207
+ );
208
+ }
209
+ }
168
210
 
169
211
  // A Linux host whose filesystem is not known to match exactly,
170
212
  // XFS among them, spells by listing; the host here matches
@@ -179,15 +221,15 @@ mod linux {
179
221
  let dir = Dir::open_ambient_dir(&path, ambient_authority())
180
222
  .expect("the host directory must open");
181
223
  let answers = (
182
- stored_name_by_listing(&dir, "file").ok(),
183
- stored_name_by_listing(&dir, "missing").ok(),
224
+ lookup_by_listing(&dir, "file").ok(),
225
+ lookup_by_listing(&dir, "missing").ok(),
184
226
  );
185
227
  let _ = std::fs::remove_dir_all(&path);
186
228
  assert_eq!(
187
229
  answers,
188
- (Some(Some("file".to_string())), Some(None)),
230
+ (Some("file".to_string()), Some("missing".to_string())),
189
231
  "a name the host holds, spelled by listing its directory, must come back as \
190
- given, and a name it lacks as none"
232
+ given, and a name it lacks as given too"
191
233
  );
192
234
  }
193
235
  }
@@ -0,0 +1,249 @@
1
+ //! Opening a directory several names beneath one held open, in one call
2
+ //! where the host's kernel confines a path itself: Linux's `openat2` with
3
+ //! `RESOLVE_BENEATH` and `RESOLVE_NO_SYMLINKS`, and from macOS 26
4
+ //! `O_RESOLVE_BENEATH` with `O_NOFOLLOW_ANY`. Elsewhere each name opens in
5
+ //! turn without following a link, which answers the same.
6
+
7
+ use cap_std::fs::Dir;
8
+
9
+ use super::{open_dir_nofollow, FilesystemError};
10
+
11
+ /// The directory `names` reach beneath `dir`, a link on the way refused as
12
+ /// a loop, walked in one call as 9p's `Twalk` walks several names. A path past
13
+ /// what the host takes in one call is no name it refuses, so it goes name by
14
+ /// name, where only a name too long for the host is refused.
15
+ pub(super) fn walk(dir: &Dir, names: &[String]) -> Result<Dir, FilesystemError> {
16
+ match kernel::walk(dir, names) {
17
+ None | Some(Err(FilesystemError::InvalidFilename)) => by_name(dir, names),
18
+ Some(opened) => opened,
19
+ }
20
+ }
21
+
22
+ /// Each name opened in turn, the way every host can.
23
+ fn by_name(dir: &Dir, names: &[String]) -> Result<Dir, FilesystemError> {
24
+ let (first, rest) = names.split_first().ok_or(FilesystemError::InvalidInput)?;
25
+ rest.iter()
26
+ .try_fold(open_dir_nofollow(dir, first)?, |dir, name| {
27
+ open_dir_nofollow(&dir, name)
28
+ })
29
+ }
30
+
31
+ /// What the host's errno names, a link on the way being a loop. `names` hold no
32
+ /// `..`, so a path leading out of `dir` is one a move carried out as it was
33
+ /// walked, as `complete_walk` answers EXDEV, and nothing stands there now.
34
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
35
+ fn to_error(errno: i32) -> FilesystemError {
36
+ const LOOP: i32 = rustix::io::Errno::LOOP.raw_os_error();
37
+ #[cfg(target_os = "linux")]
38
+ const LEADS_OUT: i32 = rustix::io::Errno::XDEV.raw_os_error();
39
+ #[cfg(target_os = "macos")]
40
+ const LEADS_OUT: i32 = libc::ENOTCAPABLE;
41
+ match errno {
42
+ LOOP => FilesystemError::FilesystemLoop,
43
+ LEADS_OUT => FilesystemError::NotFound,
44
+ _ => super::to_error(std::io::Error::from_raw_os_error(errno)),
45
+ }
46
+ }
47
+
48
+ #[cfg(target_os = "linux")]
49
+ mod kernel {
50
+ use std::fs::File;
51
+ use std::sync::atomic::{AtomicBool, Ordering};
52
+
53
+ use cap_std::fs::Dir;
54
+ use rustix::fs::{openat2, Mode, OFlags, ResolveFlags};
55
+ use rustix::io::Errno;
56
+
57
+ use super::{to_error, FilesystemError};
58
+
59
+ /// Whether this kernel has `openat2`, which arrived in Linux 5.6.
60
+ static SUPPORTED: AtomicBool = AtomicBool::new(true);
61
+
62
+ /// A directory holds its path alone, as cap-std's own opens do, so one a
63
+ /// host lets a walk search but not read still opens. `names` hold no `..`,
64
+ /// the one thing `RESOLVE_BENEATH` asks a caller to retry.
65
+ pub(super) fn walk(dir: &Dir, names: &[String]) -> Option<Result<Dir, FilesystemError>> {
66
+ if !SUPPORTED.load(Ordering::Relaxed) {
67
+ return None;
68
+ }
69
+ match openat2(
70
+ dir,
71
+ names.join("/"),
72
+ OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
73
+ Mode::empty(),
74
+ ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
75
+ ) {
76
+ Ok(fd) => Some(Ok(Dir::from_std_file(File::from(fd)))),
77
+ Err(Errno::NOSYS) => {
78
+ SUPPORTED.store(false, Ordering::Relaxed);
79
+ None
80
+ }
81
+ Err(errno) => Some(Err(to_error(errno.raw_os_error()))),
82
+ }
83
+ }
84
+ }
85
+
86
+ #[cfg(target_os = "macos")]
87
+ mod kernel {
88
+ use std::ffi::CString;
89
+ use std::fs::File;
90
+ use std::io;
91
+ use std::os::fd::{AsRawFd, FromRawFd};
92
+ use std::sync::OnceLock;
93
+
94
+ use cap_std::fs::Dir;
95
+
96
+ use super::{to_error, FilesystemError};
97
+
98
+ /// `O_RESOLVE_BENEATH` as `sys/fcntl.h` numbers it; the libc crate
99
+ /// names no such flag for macOS.
100
+ const O_RESOLVE_BENEATH: libc::c_int = 0x1000;
101
+
102
+ /// The first Darwin release of macOS 26, below which an older kernel
103
+ /// could ignore a flag it does not know and follow a link.
104
+ const DARWIN_OF_MACOS_26: u32 = 25;
105
+
106
+ fn is_supported() -> bool {
107
+ static SUPPORTED: OnceLock<bool> = OnceLock::new();
108
+ *SUPPORTED.get_or_init(|| {
109
+ rustix::system::uname()
110
+ .release()
111
+ .to_str()
112
+ .ok()
113
+ .and_then(|release| release.split('.').next()?.parse::<u32>().ok())
114
+ .is_some_and(|major| major >= DARWIN_OF_MACOS_26)
115
+ })
116
+ }
117
+
118
+ pub(super) fn walk(dir: &Dir, names: &[String]) -> Option<Result<Dir, FilesystemError>> {
119
+ if !is_supported() {
120
+ return None;
121
+ }
122
+ let Ok(path) = CString::new(names.join("/")) else {
123
+ return Some(Err(FilesystemError::InvalidInput));
124
+ };
125
+ // SAFETY: `path` is a NUL-terminated string that outlives the call,
126
+ // and `dir` holds its descriptor open across it.
127
+ let fd = unsafe {
128
+ libc::openat(
129
+ dir.as_raw_fd(),
130
+ path.as_ptr(),
131
+ libc::O_RDONLY
132
+ | libc::O_DIRECTORY
133
+ | libc::O_CLOEXEC
134
+ | libc::O_NOFOLLOW_ANY
135
+ | O_RESOLVE_BENEATH,
136
+ )
137
+ };
138
+ if fd < 0 {
139
+ let errno = io::Error::last_os_error().raw_os_error().unwrap_or(0);
140
+ return Some(Err(to_error(errno)));
141
+ }
142
+ // SAFETY: `openat` just returned `fd`, which nothing else owns.
143
+ Some(Ok(Dir::from_std_file(unsafe { File::from_raw_fd(fd) })))
144
+ }
145
+ }
146
+
147
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
148
+ mod kernel {
149
+ use cap_std::fs::Dir;
150
+
151
+ use super::FilesystemError;
152
+
153
+ pub(super) fn walk(_dir: &Dir, _names: &[String]) -> Option<Result<Dir, FilesystemError>> {
154
+ None
155
+ }
156
+ }
157
+
158
+ #[cfg(test)]
159
+ mod tests {
160
+ use std::fs;
161
+ use std::path::PathBuf;
162
+
163
+ use cap_std::ambient_authority;
164
+
165
+ use super::*;
166
+
167
+ /// A host tree of the test's own: `a/b/c`, a file `a/file`, and a link
168
+ /// `a/link` to `b`, removed when it drops.
169
+ struct Tree(PathBuf);
170
+
171
+ impl Tree {
172
+ fn new(name: &str) -> Self {
173
+ let path =
174
+ std::env::temp_dir().join(format!("kobako-beneath-{name}-{}", std::process::id()));
175
+ let _ = fs::remove_dir_all(&path);
176
+ fs::create_dir_all(path.join("a/b/c")).expect("the tree must be made");
177
+ fs::write(path.join("a/file"), "x").expect("the file must be made");
178
+ #[cfg(unix)]
179
+ std::os::unix::fs::symlink("b", path.join("a/link")).expect("the link must be made");
180
+ Self(path)
181
+ }
182
+
183
+ fn dir(&self) -> Dir {
184
+ Dir::open_ambient_dir(&self.0, ambient_authority()).expect("the tree must open")
185
+ }
186
+ }
187
+
188
+ impl Drop for Tree {
189
+ fn drop(&mut self) {
190
+ let _ = fs::remove_dir_all(&self.0);
191
+ }
192
+ }
193
+
194
+ fn names(path: &str) -> Vec<String> {
195
+ path.split('/').map(str::to_string).collect()
196
+ }
197
+
198
+ /// What `open` answers for `path`, only whether it opened kept.
199
+ fn answer(
200
+ open: impl Fn(&Dir, &[String]) -> Result<Dir, FilesystemError>,
201
+ dir: &Dir,
202
+ path: &str,
203
+ ) -> Result<(), FilesystemError> {
204
+ open(dir, &names(path)).map(drop)
205
+ }
206
+
207
+ // The host's kernel path and the one-name-at-a-time path must answer
208
+ // alike, since which one runs depends on the host.
209
+ #[test]
210
+ fn the_kernel_and_each_name_answer_alike() {
211
+ let tree = Tree::new("alike");
212
+ let dir = tree.dir();
213
+ let mut paths = vec!["a/b/c", "a/missing/c", "a/file/c", "a/file"];
214
+ if cfg!(unix) {
215
+ paths.extend(["a/link/c", "a/link"]);
216
+ }
217
+ for path in paths {
218
+ assert_eq!(
219
+ answer(walk, &dir, path),
220
+ answer(by_name, &dir, path),
221
+ "{path} opened beneath a directory by the kernel must answer as each name opened \
222
+ in turn answers"
223
+ );
224
+ }
225
+ }
226
+
227
+ #[test]
228
+ fn a_link_on_the_way_is_a_loop() {
229
+ if !cfg!(unix) {
230
+ return;
231
+ }
232
+ let tree = Tree::new("link");
233
+ assert_eq!(
234
+ answer(walk, &tree.dir(), "a/link/c"),
235
+ Err(FilesystemError::FilesystemLoop),
236
+ "a/link/c opened beneath a directory, through a link, must be refused as a loop"
237
+ );
238
+ }
239
+
240
+ #[test]
241
+ fn a_path_reaches_its_directory() {
242
+ let tree = Tree::new("reach");
243
+ let opened = walk(&tree.dir(), &names("a/b")).expect("a/b must open");
244
+ assert!(
245
+ opened.open_dir("c").is_ok(),
246
+ "a/b opened beneath a directory must be the directory holding c"
247
+ );
248
+ }
249
+ }