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,1082 @@
1
+ //! `Directory`: a Filesystem over a host directory, from which nothing
2
+ //! outside it is reachable.
3
+ //!
4
+ //! Every path resolves through `cap_std::fs::Dir`, which refuses a `..`
5
+ //! or a symbolic link that would lead out of the directory, so the
6
+ //! confinement holds whatever path a caller hands over.
7
+
8
+ mod spelling;
9
+
10
+ use std::io::{self, Read, Seek, SeekFrom, Write};
11
+ use std::path::{Component, Path};
12
+ use std::sync::Arc;
13
+
14
+ use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
15
+ use cap_std::ambient_authority;
16
+ use cap_std::fs::{Dir, OpenOptions as CapOpenOptions};
17
+
18
+ use super::path::{components, name, parent};
19
+ use super::{
20
+ DirEntry, File, FileType, Filesystem, FilesystemError, Hold, Metadata, OpenOptions, Rename,
21
+ };
22
+
23
+ /// A host directory and everything beneath it. What a guest writes lands
24
+ /// on the host and lasts as long as the directory does.
25
+ pub struct Directory {
26
+ dir: Arc<Dir>,
27
+ /// Whether a path may pass through a link, which a Directory for a
28
+ /// Policy follows nowhere on it.
29
+ follows_links: bool,
30
+ }
31
+
32
+ impl Directory {
33
+ /// Open the host directory at `path`, the one place this Filesystem
34
+ /// reaches outside itself.
35
+ pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
36
+ Ok(Self {
37
+ dir: Arc::new(Dir::open_ambient_dir(path, ambient_authority())?),
38
+ follows_links: true,
39
+ })
40
+ }
41
+
42
+ /// Answer `call` with the directory holding `path`'s last name and
43
+ /// that name. A Directory for a Policy reaches that directory one name
44
+ /// at a time from the one it already holds, following no link, so a
45
+ /// link swapped in on the way is a loop rather than a path elsewhere.
46
+ fn at<T>(
47
+ &self,
48
+ path: &str,
49
+ call: impl FnOnce(&Dir, &str) -> Result<T, FilesystemError>,
50
+ ) -> Result<T, FilesystemError> {
51
+ if self.follows_links {
52
+ return call(&self.dir, to_host_path(path)?);
53
+ }
54
+ let mut held: Option<Dir> = None;
55
+ for component in components(parent(path)) {
56
+ let from = held.as_ref().unwrap_or(&self.dir);
57
+ held = Some(open_dir_nofollow(from, to_host_path(component)?)?);
58
+ }
59
+ call(
60
+ held.as_ref().unwrap_or(&self.dir),
61
+ to_host_path(name(path))?,
62
+ )
63
+ }
64
+
65
+ /// The directory `name` in `dir`, a link there followed only where
66
+ /// this Directory follows links.
67
+ fn open_dir_in(&self, dir: &Dir, name: &str) -> Result<Dir, FilesystemError> {
68
+ if self.follows_links {
69
+ dir.open_dir(name).map_err(to_error)
70
+ } else {
71
+ open_dir_nofollow(dir, name)
72
+ }
73
+ }
74
+ }
75
+
76
+ /// The directory `name` in `dir`, a link there refused as a loop, as
77
+ /// Linux's `RESOLVE_NO_SYMLINKS` refuses one, where the host names it no
78
+ /// directory.
79
+ fn open_dir_nofollow(dir: &Dir, name: &str) -> Result<Dir, FilesystemError> {
80
+ dir.open_dir_nofollow(name)
81
+ .map_err(|err| match dir.symlink_metadata(name) {
82
+ Ok(metadata) if metadata.file_type().is_symlink() => FilesystemError::FilesystemLoop,
83
+ _ => to_error(err),
84
+ })
85
+ }
86
+
87
+ /// `path` as `cap_std` takes it, the root being `.`. Windows reads `\` as
88
+ /// a separator too, so there a name holding one is refused rather than
89
+ /// reached as names a Mount's Policy never saw.
90
+ fn to_host_path(path: &str) -> Result<&str, FilesystemError> {
91
+ if cfg!(windows) && path.contains('\\') {
92
+ return Err(FilesystemError::InvalidInput);
93
+ }
94
+ Ok(if path.is_empty() { "." } else { path })
95
+ }
96
+
97
+ /// The FilesystemError a host failure names. cap-std's own refusal of a
98
+ /// path leading out of the directory carries no OS code, and answers as
99
+ /// not permitted, as wasmtime-wasi reads it. A link loop stays unnamed
100
+ /// until std's `FilesystemLoop` is stable.
101
+ fn to_error(err: io::Error) -> FilesystemError {
102
+ match err.kind() {
103
+ io::ErrorKind::PermissionDenied if err.raw_os_error().is_none() => {
104
+ FilesystemError::NotPermitted
105
+ }
106
+ io::ErrorKind::NotFound => FilesystemError::NotFound,
107
+ io::ErrorKind::AlreadyExists => FilesystemError::AlreadyExists,
108
+ io::ErrorKind::NotADirectory => FilesystemError::NotADirectory,
109
+ io::ErrorKind::IsADirectory => FilesystemError::IsADirectory,
110
+ io::ErrorKind::DirectoryNotEmpty => FilesystemError::DirectoryNotEmpty,
111
+ io::ErrorKind::PermissionDenied => FilesystemError::PermissionDenied,
112
+ io::ErrorKind::ReadOnlyFilesystem => FilesystemError::ReadOnlyFilesystem,
113
+ io::ErrorKind::InvalidInput => FilesystemError::InvalidInput,
114
+ io::ErrorKind::Unsupported => FilesystemError::Unsupported,
115
+ io::ErrorKind::StorageFull => FilesystemError::StorageFull,
116
+ io::ErrorKind::FileTooLarge => FilesystemError::FileTooLarge,
117
+ io::ErrorKind::CrossesDevices => FilesystemError::CrossesDevices,
118
+ io::ErrorKind::Deadlock => FilesystemError::Deadlock,
119
+ io::ErrorKind::ResourceBusy => FilesystemError::ResourceBusy,
120
+ io::ErrorKind::InvalidFilename => FilesystemError::InvalidFilename,
121
+ io::ErrorKind::NotSeekable => FilesystemError::NotSeekable,
122
+ io::ErrorKind::Interrupted => FilesystemError::Interrupted,
123
+ io::ErrorKind::OutOfMemory => FilesystemError::OutOfMemory,
124
+ _ => FilesystemError::Other(err.to_string()),
125
+ }
126
+ }
127
+
128
+ fn to_file_type(host: cap_std::fs::FileType) -> FileType {
129
+ if host.is_symlink() {
130
+ FileType::Symlink
131
+ } else if host.is_dir() {
132
+ FileType::Directory
133
+ } else if host.is_file() {
134
+ FileType::File
135
+ } else {
136
+ FileType::Unknown
137
+ }
138
+ }
139
+
140
+ /// What a host entry is, a file's size alone counted: a link reports no
141
+ /// size, so nothing of where it points reaches the guest. The host names
142
+ /// its identity on unix; elsewhere a Directory reports none.
143
+ fn to_metadata(metadata: cap_std::fs::Metadata) -> Metadata {
144
+ let file_type = to_file_type(metadata.file_type());
145
+ let len = if file_type == FileType::File {
146
+ metadata.len()
147
+ } else {
148
+ 0
149
+ };
150
+ #[cfg(unix)]
151
+ {
152
+ use cap_std::fs::MetadataExt;
153
+ Metadata::new(file_type, len).with_identity(metadata.dev(), metadata.ino())
154
+ }
155
+ #[cfg(not(unix))]
156
+ Metadata::new(file_type, len)
157
+ }
158
+
159
+ /// Open `name` in `dir` as `options` asks.
160
+ fn open_in(dir: &Dir, name: &str, options: &OpenOptions) -> Result<Box<dyn File>, FilesystemError> {
161
+ // The host creates only for a writer, so a file already there is
162
+ // opened as asked rather than for writing it was never given.
163
+ if options.create && !(options.create_new || options.write || options.truncate) {
164
+ let existing = OpenOptions {
165
+ create: false,
166
+ ..*options
167
+ };
168
+ match open_in(dir, name, &existing) {
169
+ Err(FilesystemError::NotFound) => {}
170
+ opened => return opened,
171
+ }
172
+ }
173
+ let creates = options.create || options.create_new || options.truncate;
174
+ let writes = options.write || creates;
175
+ let mut open = CapOpenOptions::new();
176
+ // The host refuses an open for neither reading nor writing, and one
177
+ // that creates or truncates without writing; the runtime decides
178
+ // what is read and written, so such opens read or write here.
179
+ open.read(options.read || !writes)
180
+ .write(writes)
181
+ .create(options.create)
182
+ .create_new(options.create_new)
183
+ .truncate(options.truncate);
184
+ // Only a file or a directory opens: a FIFO would wait for a peer
185
+ // and a device would act on being opened. The host could swap an
186
+ // entry in between, so what opened is checked again before its
187
+ // first use, the open itself never waiting for a peer, nor following
188
+ // a link swapped in where none was asked to be followed.
189
+ let found = if options.nofollow {
190
+ open.follow(FollowSymlinks::No);
191
+ dir.symlink_metadata(name)
192
+ } else {
193
+ dir.metadata(name)
194
+ };
195
+ match found.map(|metadata| to_file_type(metadata.file_type())) {
196
+ Ok(FileType::Symlink) => return Err(FilesystemError::FilesystemLoop),
197
+ Ok(FileType::Unknown) => return Err(FilesystemError::PermissionDenied),
198
+ _ => {}
199
+ }
200
+ #[cfg(unix)]
201
+ {
202
+ use cap_std::fs::OpenOptionsExt;
203
+ use rustix::fs::OFlags;
204
+ open.custom_flags((OFlags::NONBLOCK | OFlags::NOCTTY).bits() as i32);
205
+ }
206
+ let file = dir.open_with(name, &open).map_err(to_error)?;
207
+ match to_file_type(file.metadata().map_err(to_error)?.file_type()) {
208
+ FileType::File => {}
209
+ FileType::Directory => return Err(FilesystemError::IsADirectory),
210
+ _ => return Err(FilesystemError::PermissionDenied),
211
+ }
212
+ #[cfg(unix)]
213
+ {
214
+ use rustix::fs::{fcntl_getfl, fcntl_setfl, OFlags};
215
+ let flags = fcntl_getfl(&file)
216
+ .map_err(io::Error::from)
217
+ .map_err(to_error)?;
218
+ fcntl_setfl(&file, flags - OFlags::NONBLOCK)
219
+ .map_err(io::Error::from)
220
+ .map_err(to_error)?;
221
+ }
222
+ Ok(Box::new(DirectoryFile {
223
+ file: file.into_std(),
224
+ }))
225
+ }
226
+
227
+ impl Filesystem for Directory {
228
+ /// A Directory for a Policy follows no link at the end either.
229
+ fn open(&self, path: &str, options: &OpenOptions) -> Result<Box<dyn File>, FilesystemError> {
230
+ let options = OpenOptions {
231
+ nofollow: options.nofollow || !self.follows_links,
232
+ ..*options
233
+ };
234
+ self.at(path, |dir, name| open_in(dir, name, &options))
235
+ }
236
+
237
+ /// A Directory for a Policy answers a link at the end as a loop rather
238
+ /// than follow it.
239
+ fn metadata(&self, path: &str) -> Result<Metadata, FilesystemError> {
240
+ if !self.follows_links {
241
+ let metadata = self.symlink_metadata(path)?;
242
+ if metadata.file_type == FileType::Symlink {
243
+ return Err(FilesystemError::FilesystemLoop);
244
+ }
245
+ return Ok(metadata);
246
+ }
247
+ self.at(path, |dir, name| {
248
+ Ok(to_metadata(dir.metadata(name).map_err(to_error)?))
249
+ })
250
+ }
251
+
252
+ fn symlink_metadata(&self, path: &str) -> Result<Metadata, FilesystemError> {
253
+ self.at(path, |dir, name| {
254
+ Ok(to_metadata(dir.symlink_metadata(name).map_err(to_error)?))
255
+ })
256
+ }
257
+
258
+ /// The host directory's own descriptor, so its inode stays taken.
259
+ /// Elsewhere a Directory reports no identity, so it holds nothing.
260
+ fn hold_directory(&self, path: &str) -> Result<Option<Hold>, FilesystemError> {
261
+ if cfg!(not(unix)) {
262
+ return Ok(None);
263
+ }
264
+ let dir = self.at(path, |dir, name| self.open_dir_in(dir, name))?;
265
+ Ok(Some(Hold::new(dir)))
266
+ }
267
+
268
+ fn stored_path(&self, path: &str) -> Result<String, FilesystemError> {
269
+ let mut parent = Some(self.dir.try_clone().map_err(to_error)?);
270
+ let mut stored = Vec::new();
271
+ let mut names = components(path).peekable();
272
+ while let Some(name) = names.next() {
273
+ let found = match &parent {
274
+ Some(dir) => spelling::stored_name(dir, to_host_path(name)?).map_err(to_error)?,
275
+ None => None,
276
+ };
277
+ // The last name is spelled from its parent alone, so a link
278
+ // standing there is never followed to spell it.
279
+ parent = match (&parent, &found) {
280
+ (Some(dir), Some(found)) if names.peek().is_some() => {
281
+ match self.open_dir_in(dir, found) {
282
+ Ok(next) => Some(next),
283
+ Err(FilesystemError::NotFound | FilesystemError::NotADirectory) => None,
284
+ Err(err) => return Err(err),
285
+ }
286
+ }
287
+ _ => None,
288
+ };
289
+ stored.push(found.unwrap_or_else(|| name.to_string()));
290
+ }
291
+ Ok(stored.join("/"))
292
+ }
293
+
294
+ /// A Directory for a Policy follows no link, so a path resolves as
295
+ /// given.
296
+ fn canonicalize_beneath(&self, base: &str, path: &str) -> Result<String, FilesystemError> {
297
+ if !self.follows_links {
298
+ return Ok(path.to_string());
299
+ }
300
+ let beneath = self.dir.open_dir(to_host_path(base)?).map_err(to_error)?;
301
+ let found = beneath
302
+ .canonicalize(to_host_path(path)?)
303
+ .map_err(to_error)?;
304
+ found
305
+ .components()
306
+ .filter_map(|component| match component {
307
+ Component::Normal(name) => Some(
308
+ name.to_str()
309
+ .map(str::to_string)
310
+ .ok_or(FilesystemError::IllegalSequence),
311
+ ),
312
+ _ => None,
313
+ })
314
+ .collect::<Result<Vec<_>, _>>()
315
+ .map(|names| names.join("/"))
316
+ }
317
+
318
+ fn read_dir(&self, path: &str) -> Result<Vec<DirEntry>, FilesystemError> {
319
+ self.at(path, |dir, name| self.open_dir_in(dir, name))?
320
+ .entries()
321
+ .map_err(to_error)?
322
+ .map(|entry| {
323
+ let entry = entry.map_err(to_error)?;
324
+ let name = entry
325
+ .file_name()
326
+ .into_string()
327
+ .map_err(|_| FilesystemError::IllegalSequence)?;
328
+ Ok(DirEntry {
329
+ name,
330
+ file_type: to_file_type(entry.file_type().map_err(to_error)?),
331
+ })
332
+ })
333
+ .collect()
334
+ }
335
+
336
+ fn create_dir(&self, path: &str) -> Result<(), FilesystemError> {
337
+ self.at(path, |dir, name| dir.create_dir(name).map_err(to_error))
338
+ }
339
+
340
+ fn remove_dir(&self, path: &str) -> Result<(), FilesystemError> {
341
+ self.at(path, |dir, name| dir.remove_dir(name).map_err(to_error))
342
+ }
343
+
344
+ /// A directory is refused as one on every host, where the host itself
345
+ /// answers EISDIR on Linux but EPERM on macOS.
346
+ fn remove_file(&self, path: &str) -> Result<(), FilesystemError> {
347
+ self.at(path, |dir, name| {
348
+ if dir.symlink_metadata(name).is_ok_and(|m| m.is_dir()) {
349
+ return Err(FilesystemError::IsADirectory);
350
+ }
351
+ dir.remove_file(name).map_err(to_error)
352
+ })
353
+ }
354
+
355
+ /// A host directory takes no lock a Filesystem could check the source
356
+ /// under, so another process of the host's changing it in between is
357
+ /// the host's to rule out. A link moves as itself, as `rename(2)`
358
+ /// moves one.
359
+ fn rename(&self, from: &str, to: &str, rename: Rename) -> Result<(), FilesystemError> {
360
+ if from != to {
361
+ rename.check(self.symlink_metadata(from)?.file_type)?;
362
+ }
363
+ self.at(from, |from_dir, from_name| {
364
+ self.at(to, |to_dir, to_name| {
365
+ from_dir
366
+ .rename(from_name, to_dir, to_name)
367
+ .map_err(to_error)
368
+ })
369
+ })
370
+ }
371
+
372
+ /// Every Invocation shares the host directory.
373
+ fn for_invocation(&self) -> Option<Arc<dyn Filesystem>> {
374
+ None
375
+ }
376
+
377
+ /// The same host directory, reached one name at a time.
378
+ fn for_policy(&self) -> Option<Arc<dyn Filesystem>> {
379
+ Some(Arc::new(Self {
380
+ dir: self.dir.clone(),
381
+ follows_links: false,
382
+ }))
383
+ }
384
+ }
385
+
386
+ /// An open host file. The runtime names every offset, so each access seeks
387
+ /// first.
388
+ struct DirectoryFile {
389
+ file: std::fs::File,
390
+ }
391
+
392
+ impl File for DirectoryFile {
393
+ fn read_at(&mut self, buf: &mut [u8], offset: u64) -> Result<usize, FilesystemError> {
394
+ self.file.seek(SeekFrom::Start(offset)).map_err(to_error)?;
395
+ self.file.read(buf).map_err(to_error)
396
+ }
397
+
398
+ fn write_at(&mut self, buf: &[u8], offset: u64) -> Result<usize, FilesystemError> {
399
+ self.file.seek(SeekFrom::Start(offset)).map_err(to_error)?;
400
+ self.file.write(buf).map_err(to_error)
401
+ }
402
+
403
+ /// Windows has no append to switch one handle to for a single write,
404
+ /// so an append that could land over another writer's is refused.
405
+ #[cfg(not(unix))]
406
+ fn append(&mut self, _buf: &[u8]) -> Result<(u64, usize), FilesystemError> {
407
+ Err(FilesystemError::Unsupported)
408
+ }
409
+
410
+ /// The host places the write at the file's end as `O_APPEND` does, so
411
+ /// another writer's append lands before or after it, never over it.
412
+ #[cfg(unix)]
413
+ fn append(&mut self, buf: &[u8]) -> Result<(u64, usize), FilesystemError> {
414
+ use rustix::fs::{fcntl_getfl, fcntl_setfl, OFlags};
415
+
416
+ let flags = fcntl_getfl(&self.file)
417
+ .map_err(io::Error::from)
418
+ .map_err(to_error)?;
419
+ fcntl_setfl(&self.file, flags | OFlags::APPEND)
420
+ .map_err(io::Error::from)
421
+ .map_err(to_error)?;
422
+ let written = self.file.write(buf);
423
+ let end = self.file.stream_position();
424
+ fcntl_setfl(&self.file, flags)
425
+ .map_err(io::Error::from)
426
+ .map_err(to_error)?;
427
+ let written = written.map_err(to_error)?;
428
+ Ok((end.map_err(to_error)? - written as u64, written))
429
+ }
430
+
431
+ fn metadata(&self) -> Result<Metadata, FilesystemError> {
432
+ let metadata = self.file.metadata().map_err(to_error)?;
433
+ Ok(Metadata::new(FileType::File, metadata.len()))
434
+ }
435
+
436
+ fn set_len(&mut self, len: u64) -> Result<(), FilesystemError> {
437
+ self.file.set_len(len).map_err(to_error)
438
+ }
439
+
440
+ fn sync(&mut self) -> Result<(), FilesystemError> {
441
+ self.file.sync_all().map_err(to_error)
442
+ }
443
+ }
444
+
445
+ #[cfg(test)]
446
+ mod tests {
447
+ use std::fs;
448
+ use std::path::PathBuf;
449
+
450
+ use super::*;
451
+
452
+ /// A fresh host directory of the test's own, removed when it drops.
453
+ struct HostDir(PathBuf);
454
+
455
+ impl HostDir {
456
+ fn new(name: &str) -> Self {
457
+ let path = std::env::temp_dir()
458
+ .join(format!("kobako-directory-{name}-{}", std::process::id()));
459
+ let _ = fs::remove_dir_all(&path);
460
+ fs::create_dir_all(&path).expect("the host directory must be created");
461
+ Self(path)
462
+ }
463
+ }
464
+
465
+ impl Drop for HostDir {
466
+ fn drop(&mut self) {
467
+ let _ = fs::remove_dir_all(&self.0);
468
+ }
469
+ }
470
+
471
+ /// A host directory of the test's own holding the FIFO `pipe`.
472
+ #[cfg(unix)]
473
+ fn make_fifo_host(name: &str) -> HostDir {
474
+ let host = HostDir::new(name);
475
+ let made = std::process::Command::new("mkfifo")
476
+ .arg(host.0.join("pipe"))
477
+ .status()
478
+ .expect("mkfifo must run");
479
+ assert!(made.success(), "the FIFO must be made");
480
+ host
481
+ }
482
+
483
+ // Opening the FIFO with no writer would wait for one, so only an open
484
+ // refused before it waits answers within the deadline; `/dev/null`
485
+ // is a device every unix host holds.
486
+ // @behavior FS-090
487
+ #[cfg(unix)]
488
+ #[test]
489
+ fn a_directory_opens_only_a_file_or_a_directory() {
490
+ let host = make_fifo_host("fifo-open");
491
+ let fifo = Directory::new(&host.0).expect("the host directory must open");
492
+ let devices = Directory::new("/dev").expect("the device directory must open");
493
+ let (sender, answered) = std::sync::mpsc::channel();
494
+ std::thread::spawn(move || {
495
+ let read = OpenOptions {
496
+ read: true,
497
+ ..OpenOptions::default()
498
+ };
499
+ let _ = sender.send([
500
+ fifo.open("pipe", &read).err(),
501
+ devices.open("null", &read).err(),
502
+ ]);
503
+ });
504
+ assert_eq!(
505
+ answered.recv_timeout(std::time::Duration::from_secs(5)),
506
+ Ok([
507
+ Some(FilesystemError::PermissionDenied),
508
+ Some(FilesystemError::PermissionDenied)
509
+ ]),
510
+ "a FIFO and a device opened through Directory::open must be refused as permission \
511
+ denied without waiting"
512
+ );
513
+ }
514
+
515
+ // @behavior FS-091
516
+ #[cfg(unix)]
517
+ #[test]
518
+ fn a_directory_reports_a_fifo_as_of_an_unknown_type() {
519
+ let host = make_fifo_host("fifo-type");
520
+ let directory = Directory::new(&host.0).expect("the host directory must open");
521
+ let listed: Vec<_> = directory
522
+ .read_dir("")
523
+ .expect("the directory must list")
524
+ .into_iter()
525
+ .map(|entry| entry.file_type)
526
+ .collect();
527
+ assert_eq!(
528
+ (
529
+ directory
530
+ .metadata("pipe")
531
+ .map(|metadata| metadata.file_type),
532
+ directory
533
+ .symlink_metadata("pipe")
534
+ .map(|metadata| metadata.file_type),
535
+ listed
536
+ ),
537
+ (
538
+ Ok(FileType::Unknown),
539
+ Ok(FileType::Unknown),
540
+ vec![FileType::Unknown]
541
+ ),
542
+ "a FIFO looked up through Directory::metadata and symlink_metadata, and listed \
543
+ through read_dir, must answer an unknown type"
544
+ );
545
+ }
546
+
547
+ // @behavior FS-005
548
+ #[test]
549
+ fn a_directory_serves_the_files_of_its_host_directory() {
550
+ let host = HostDir::new("serves");
551
+ fs::write(host.0.join("data.csv"), "a,b").expect("the host file must be written");
552
+
553
+ let directory = Directory::new(&host.0).expect("the host directory must open");
554
+ let mut file = directory
555
+ .open(
556
+ "data.csv",
557
+ &OpenOptions {
558
+ read: true,
559
+ ..OpenOptions::default()
560
+ },
561
+ )
562
+ .expect("the host file must open");
563
+ let mut buf = [0; 16];
564
+ let read = file.read_at(&mut buf, 0).expect("the host file must read");
565
+ assert_eq!(
566
+ &buf[..read],
567
+ b"a,b",
568
+ "a host file read through a Directory's open must read back its content"
569
+ );
570
+ }
571
+
572
+ #[test]
573
+ fn an_open_for_neither_reading_nor_writing_still_opens() {
574
+ let host = HostDir::new("lookup");
575
+ fs::write(host.0.join("seen"), "x").expect("the host file must be written");
576
+ let directory = Directory::new(&host.0).expect("the host directory must open");
577
+ assert!(directory.open("seen", &OpenOptions::default()).is_ok());
578
+ }
579
+
580
+ // @behavior FS-006
581
+ #[test]
582
+ fn a_directory_writes_into_its_host_directory() {
583
+ let host = HostDir::new("writes");
584
+ let directory = Directory::new(&host.0).expect("the host directory must open");
585
+ directory
586
+ .open(
587
+ "out.txt",
588
+ &OpenOptions {
589
+ write: true,
590
+ create: true,
591
+ ..OpenOptions::default()
592
+ },
593
+ )
594
+ .expect("a new file must be created")
595
+ .write_at(b"written", 0)
596
+ .expect("the write must land");
597
+ assert_eq!(
598
+ fs::read(host.0.join("out.txt")).expect("the host file must exist"),
599
+ b"written",
600
+ "a file created through a Directory's open must land in the host directory"
601
+ );
602
+ }
603
+
604
+ // The file is opened to write, so only the refusal keeps the append
605
+ // from taking the two steps another writer could land between.
606
+ // @behavior FS-093
607
+ #[cfg(windows)]
608
+ #[test]
609
+ fn a_directory_on_windows_refuses_to_append() {
610
+ let host = HostDir::new("append");
611
+ fs::write(host.0.join("f"), "x").expect("the host file must be written");
612
+ let directory = Directory::new(&host.0).expect("the host directory must open");
613
+ let mut file = directory
614
+ .open(
615
+ "f",
616
+ &OpenOptions {
617
+ write: true,
618
+ ..OpenOptions::default()
619
+ },
620
+ )
621
+ .expect("the host file must open");
622
+ assert_eq!(
623
+ (
624
+ file.append(b"y").err(),
625
+ fs::read(host.0.join("f")).expect("the host file must read")
626
+ ),
627
+ (Some(FilesystemError::Unsupported), b"x".to_vec()),
628
+ "an append through a Directory file on Windows must be refused as unsupported and \
629
+ leave the file as it was"
630
+ );
631
+ }
632
+
633
+ // The host file exists beneath `d`, so only the refusal can keep the
634
+ // open from reaching it.
635
+ // @behavior FS-029
636
+ #[cfg(windows)]
637
+ #[test]
638
+ fn a_directory_on_windows_refuses_a_name_holding_a_backslash() {
639
+ let host = HostDir::new("backslash");
640
+ fs::create_dir_all(host.0.join("d")).expect("the host directory must be created");
641
+ fs::write(host.0.join("d").join("f"), "x").expect("the host file must be written");
642
+ let directory = Directory::new(&host.0).expect("the host directory must open");
643
+ assert!(
644
+ matches!(
645
+ directory.open(r"d\f", &OpenOptions::default()),
646
+ Err(FilesystemError::InvalidInput)
647
+ ),
648
+ "a path holding a backslash opened through a Directory on Windows must be refused as \
649
+ invalid input"
650
+ );
651
+ }
652
+
653
+ // The host answers ENAMETOOLONG, which only a mapping naming it keeps
654
+ // from reaching the guest as an EIO.
655
+ // @behavior FS-065
656
+ #[test]
657
+ fn a_directory_answers_a_host_failure_as_the_error_naming_it() {
658
+ let host = HostDir::new("names");
659
+ let directory = Directory::new(&host.0).expect("the host directory must open");
660
+ let created = directory.open(
661
+ &"x".repeat(300),
662
+ &OpenOptions {
663
+ write: true,
664
+ create: true,
665
+ ..OpenOptions::default()
666
+ },
667
+ );
668
+ assert_eq!(
669
+ created.map(drop),
670
+ Err(FilesystemError::InvalidFilename),
671
+ "a name past the host's limit created through Directory::open must fail as \
672
+ InvalidFilename, not as an unnamed failure"
673
+ );
674
+ }
675
+
676
+ // Which offset a host refuses depends on its filesystem: tmpfs, XFS and
677
+ // overlay keep files up to `i64::MAX`. The host's own EFBIG, 27 on Linux
678
+ // and macOS alike, is what the Directory must name.
679
+ // @behavior FS-068
680
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
681
+ #[test]
682
+ fn a_directory_answers_a_host_file_too_large_as_too_large() {
683
+ assert_eq!(
684
+ to_error(io::Error::from_raw_os_error(27)),
685
+ FilesystemError::FileTooLarge,
686
+ "a host EFBIG through Directory's error mapping must fail as FileTooLarge, \
687
+ not as an unnamed failure"
688
+ );
689
+ }
690
+
691
+ // Only Linux keeps a name that is not UTF-8; macOS refuses to make one.
692
+ // @behavior FS-066
693
+ #[cfg(target_os = "linux")]
694
+ #[test]
695
+ fn a_directory_refuses_to_list_a_host_name_that_is_not_utf8() {
696
+ use std::os::unix::ffi::OsStrExt;
697
+
698
+ let host = HostDir::new("ilseq");
699
+ fs::write(host.0.join(std::ffi::OsStr::from_bytes(b"\xff")), "")
700
+ .expect("the host file must be written");
701
+ let directory = Directory::new(&host.0).expect("the host directory must open");
702
+ assert_eq!(
703
+ directory.read_dir("").map(drop),
704
+ Err(FilesystemError::IllegalSequence),
705
+ "a host name that is not UTF-8 listed through Directory::read_dir must fail as \
706
+ IllegalSequence, not as an unnamed failure"
707
+ );
708
+ }
709
+
710
+ // The host file only reads, so a host open asking to write, as one that
711
+ // creates must, would be refused where nothing is to be created.
712
+ // @behavior FS-064
713
+ #[cfg(unix)]
714
+ #[test]
715
+ fn a_directory_opens_a_file_already_there_without_writing_to_create_it() {
716
+ use std::os::unix::fs::PermissionsExt;
717
+ let host = HostDir::new("create-existing");
718
+ fs::write(host.0.join("note"), "x").expect("the host file must be written");
719
+ fs::set_permissions(host.0.join("note"), fs::Permissions::from_mode(0o444))
720
+ .expect("the host file must become read-only");
721
+ let directory = Directory::new(&host.0).expect("the host directory must open");
722
+ let creates = OpenOptions {
723
+ read: true,
724
+ create: true,
725
+ ..OpenOptions::default()
726
+ };
727
+ assert_eq!(
728
+ (
729
+ directory.open("note", &creates).map(drop),
730
+ directory.open("new", &creates).map(drop),
731
+ host.0.join("new").exists()
732
+ ),
733
+ (Ok(()), Ok(()), true),
734
+ "a Directory::open that may create, of a read-only host file already there, must open \
735
+ it, and of a missing one must create it"
736
+ );
737
+ }
738
+
739
+ // The link's target is a readable file, so only the refusal of the
740
+ // link itself keeps the open from reaching it.
741
+ // @behavior FS-047
742
+ #[cfg(unix)]
743
+ #[test]
744
+ fn a_directory_opens_no_link_an_open_asks_not_to_follow() {
745
+ let host = HostDir::new("nofollow");
746
+ fs::write(host.0.join("target"), "x").expect("the host file must be written");
747
+ std::os::unix::fs::symlink("target", host.0.join("link")).expect("the link must be made");
748
+ let directory = Directory::new(&host.0).expect("the host directory must open");
749
+ let open = |nofollow| {
750
+ directory
751
+ .open(
752
+ "link",
753
+ &OpenOptions {
754
+ read: true,
755
+ nofollow,
756
+ ..OpenOptions::default()
757
+ },
758
+ )
759
+ .map(drop)
760
+ };
761
+ assert_eq!(
762
+ (open(true), open(false)),
763
+ (Err(FilesystemError::FilesystemLoop), Ok(())),
764
+ "a host link opened through a Directory must be refused when the open asks to follow \
765
+ none, and reached when it does not"
766
+ );
767
+ }
768
+
769
+ // The target's five bytes would show as the link's size if a lookup
770
+ // that follows none still followed it.
771
+ // @behavior FS-048
772
+ #[cfg(unix)]
773
+ #[test]
774
+ fn a_directory_reports_a_link_a_lookup_does_not_follow_as_a_link() {
775
+ let host = HostDir::new("lstat");
776
+ fs::write(host.0.join("target"), "hello").expect("the host file must be written");
777
+ std::os::unix::fs::symlink("target", host.0.join("link")).expect("the link must be made");
778
+ let directory = Directory::new(&host.0).expect("the host directory must open");
779
+ let listed = directory
780
+ .read_dir("")
781
+ .expect("the host directory must list")
782
+ .into_iter()
783
+ .find(|entry| entry.name == "link")
784
+ .map(|entry| entry.file_type);
785
+ let shape = |metadata: Metadata| (metadata.file_type, metadata.len);
786
+ assert_eq!(
787
+ (
788
+ directory.symlink_metadata("link").map(shape),
789
+ listed,
790
+ directory.metadata("link").map(shape)
791
+ ),
792
+ (
793
+ Ok((FileType::Symlink, 0)),
794
+ Some(FileType::Symlink),
795
+ Ok((FileType::File, 5))
796
+ ),
797
+ "a host link looked up through Directory::symlink_metadata and listed through \
798
+ Directory::read_dir must report a link with no size, and Directory::metadata must \
799
+ report the file it points to"
800
+ );
801
+ }
802
+
803
+ // @behavior FS-028
804
+ #[test]
805
+ fn a_directory_refuses_to_remove_a_directory_as_a_file() {
806
+ let host = HostDir::new("unlink-dir");
807
+ fs::create_dir(host.0.join("sub")).expect("the host directory must be created");
808
+ let directory = Directory::new(&host.0).expect("the host directory must open");
809
+ assert_eq!(
810
+ directory.remove_file("sub"),
811
+ Err(FilesystemError::IsADirectory),
812
+ "a host directory removed through Directory::remove_file must answer IsADirectory"
813
+ );
814
+ }
815
+
816
+ // Both links stay inside the host directory, so only resolving beneath
817
+ // `dir` rather than beneath the root can refuse the outer one.
818
+ // @behavior FS-097
819
+ #[cfg(unix)]
820
+ #[test]
821
+ fn a_directory_follows_a_link_only_within_the_directory_it_resolves_beneath() {
822
+ let host = HostDir::new("beneath");
823
+ fs::create_dir_all(host.0.join("dir/sub")).expect("the host directory must be created");
824
+ fs::write(host.0.join("dir/sub/y"), "inner").expect("the inner file must be written");
825
+ fs::write(host.0.join("secret"), "outer").expect("the outer file must be written");
826
+ std::os::unix::fs::symlink("sub", host.0.join("dir/in")).expect("the link must be made");
827
+ std::os::unix::fs::symlink("../secret", host.0.join("dir/up"))
828
+ .expect("the link must be made");
829
+
830
+ let directory = Directory::new(&host.0).expect("the host directory must open");
831
+ assert_eq!(
832
+ (
833
+ directory.canonicalize_beneath("dir", "in/y"),
834
+ directory.canonicalize_beneath("dir", "up"),
835
+ ),
836
+ (Ok("sub/y".to_string()), Err(FilesystemError::NotPermitted)),
837
+ "a link staying within `dir`, resolved through Directory::canonicalize_beneath, must \
838
+ resolve to where it leads, and one leading out of `dir` must not be permitted"
839
+ );
840
+ }
841
+
842
+ // The link leads to a directory inside the host directory, so only a
843
+ // Directory following no link refuses what a plain one reaches.
844
+ // @behavior FS-099
845
+ #[cfg(unix)]
846
+ #[test]
847
+ fn a_directory_for_a_policy_follows_no_link_on_a_path() {
848
+ let host = HostDir::new("for-policy");
849
+ fs::create_dir_all(host.0.join("target")).expect("the host directory must be created");
850
+ fs::write(host.0.join("target/x"), "inner").expect("the inner file must be written");
851
+ std::os::unix::fs::symlink("target", host.0.join("link")).expect("the link must be made");
852
+ let directory = Directory::new(&host.0).expect("the host directory must open");
853
+ let reading = OpenOptions {
854
+ read: true,
855
+ ..OpenOptions::default()
856
+ };
857
+ let answers = |filesystem: &dyn Filesystem| {
858
+ (
859
+ filesystem.open("link/x", &reading).map(drop),
860
+ filesystem.read_dir("link").map(drop),
861
+ )
862
+ };
863
+ let for_policy = directory
864
+ .for_policy()
865
+ .expect("a Directory has a Filesystem for a Policy");
866
+ assert_eq!(
867
+ (answers(&directory), answers(for_policy.as_ref())),
868
+ (
869
+ (Ok(()), Ok(())),
870
+ (
871
+ Err(FilesystemError::FilesystemLoop),
872
+ Err(FilesystemError::FilesystemLoop)
873
+ ),
874
+ ),
875
+ "a path through a link opened, and the link listed, through a Directory's \
876
+ for_policy must each be a link loop, where the Directory itself reaches them"
877
+ );
878
+ }
879
+
880
+ // @behavior FS-007
881
+ #[cfg(unix)]
882
+ #[test]
883
+ fn a_directory_reaches_nothing_outside_its_host_directory() {
884
+ let outside = HostDir::new("outside");
885
+ fs::write(outside.0.join("secret"), "host").expect("the outside file must be written");
886
+ let host = HostDir::new("confined");
887
+ std::os::unix::fs::symlink(outside.0.join("secret"), host.0.join("escape"))
888
+ .expect("the link must be created");
889
+ fs::write(host.0.join("inside"), "guest").expect("the inside file must be written");
890
+ std::os::unix::fs::symlink("inside", host.0.join("alias"))
891
+ .expect("the inside link must be created");
892
+
893
+ let directory = Directory::new(&host.0).expect("the host directory must open");
894
+ assert!(
895
+ directory
896
+ .open(
897
+ "alias",
898
+ &OpenOptions {
899
+ read: true,
900
+ ..OpenOptions::default()
901
+ }
902
+ )
903
+ .is_ok(),
904
+ "a link that stays inside the host directory must still open through a Directory's open"
905
+ );
906
+ assert!(
907
+ directory
908
+ .open(
909
+ "escape",
910
+ &OpenOptions {
911
+ read: true,
912
+ ..OpenOptions::default()
913
+ }
914
+ )
915
+ .is_err(),
916
+ "a link out of the host directory opened through a Directory's open must be refused"
917
+ );
918
+ }
919
+
920
+ // @behavior FS-071
921
+ #[cfg(unix)]
922
+ #[test]
923
+ fn a_directory_reports_the_identity_its_host_gives_a_path() {
924
+ use std::os::unix::fs::MetadataExt;
925
+
926
+ let host = HostDir::new("identity");
927
+ fs::create_dir(host.0.join("dir")).expect("the host directory must be made");
928
+ let expected = fs::metadata(host.0.join("dir")).expect("the host must stat it");
929
+ let directory = Directory::new(&host.0).expect("the host directory must open");
930
+ let reported = directory.metadata("dir").expect("the directory must stand");
931
+ assert_eq!(
932
+ (reported.dev, reported.ino),
933
+ (expected.dev(), expected.ino()),
934
+ "a host directory's metadata through Directory::metadata must carry the device and \
935
+ inode numbers the host reports"
936
+ );
937
+ }
938
+
939
+ // Two handles racing one-byte appends: a two-step append (read the
940
+ // length, then write there) loses about half the bytes to the other
941
+ // handle's writes, so only an append the host places keeps them all.
942
+ // @behavior FS-069
943
+ #[cfg(unix)]
944
+ #[test]
945
+ fn a_directory_appends_at_its_host_files_end_in_one_step() {
946
+ use std::sync::Arc;
947
+
948
+ let host = HostDir::new("append");
949
+ fs::write(host.0.join("log.txt"), "").expect("the host file must be written");
950
+ let directory = Arc::new(Directory::new(&host.0).expect("the host directory must open"));
951
+
952
+ let writers: Vec<_> = [b"a", b"b"]
953
+ .into_iter()
954
+ .map(|byte| {
955
+ let directory = Arc::clone(&directory);
956
+ std::thread::spawn(move || {
957
+ let options = OpenOptions {
958
+ write: true,
959
+ ..OpenOptions::default()
960
+ };
961
+ let mut file = directory
962
+ .open("log.txt", &options)
963
+ .expect("the host file must open to write");
964
+ for _ in 0..10_000 {
965
+ file.append(byte).expect("the append must succeed");
966
+ }
967
+ })
968
+ })
969
+ .collect();
970
+ for writer in writers {
971
+ writer.join().expect("the writer must finish");
972
+ }
973
+
974
+ let written = fs::read(host.0.join("log.txt")).expect("the host file must be read");
975
+ assert_eq!(
976
+ (
977
+ written.iter().filter(|&&b| b == b'a').count(),
978
+ written.iter().filter(|&&b| b == b'b').count()
979
+ ),
980
+ (10_000, 10_000),
981
+ "two handles appending to one host file through a Directory's File::append must each \
982
+ keep every byte"
983
+ );
984
+ }
985
+
986
+ // ext4 hands a removed directory's inode number to the next one made,
987
+ // so the number staying unused while held is what the Hold keeps. A
988
+ // host that never reuses numbers cannot show that, but every Linux
989
+ // host lists the removed directory among the process's descriptors
990
+ // while it is held.
991
+ // @behavior FS-086
992
+ #[cfg(target_os = "linux")]
993
+ #[test]
994
+ fn a_directory_held_keeps_its_inode_number_from_the_next_one_made() {
995
+ use std::os::unix::fs::MetadataExt;
996
+
997
+ let host = HostDir::new("held");
998
+ let ino = |path: &str| {
999
+ fs::metadata(host.0.join(path))
1000
+ .expect("it must stand")
1001
+ .ino()
1002
+ };
1003
+ fs::create_dir(host.0.join("dir")).expect("the host directory must be made");
1004
+ let first = ino("dir");
1005
+ fs::remove_dir(host.0.join("dir")).expect("the host directory must go");
1006
+ fs::create_dir(host.0.join("dir")).expect("the host directory must be made");
1007
+ let reuses = ino("dir") == first;
1008
+ let first = ino("dir");
1009
+ let directory = Directory::new(&host.0).expect("the host directory must open");
1010
+ let hold = directory
1011
+ .hold_directory("dir")
1012
+ .expect("the directory must be held");
1013
+ fs::remove_dir(host.0.join("dir")).expect("the host directory must go");
1014
+ let removed = format!("{} (deleted)", host.0.join("dir").display());
1015
+ let held_open = fs::read_dir("/proc/self/fd")
1016
+ .expect("the process's descriptors must list")
1017
+ .filter_map(|entry| fs::read_link(entry.ok()?.path()).ok())
1018
+ .any(|target| target.to_string_lossy() == removed);
1019
+ fs::create_dir(host.0.join("dir")).expect("the host directory must be made");
1020
+ let made = ino("dir");
1021
+ drop(hold);
1022
+ assert!(
1023
+ held_open,
1024
+ "a directory Directory::hold_directory holds must stay open once removed"
1025
+ );
1026
+ if !reuses {
1027
+ eprintln!("the host does not reuse inode numbers; nothing more to witness");
1028
+ return;
1029
+ }
1030
+ assert_ne!(
1031
+ made, first,
1032
+ "a directory made where Directory::hold_directory holds a removed one must take \
1033
+ another inode number on a host reusing them"
1034
+ );
1035
+ }
1036
+
1037
+ // @behavior FS-081
1038
+ #[test]
1039
+ fn a_directory_spells_names_it_matches_exactly_as_given() {
1040
+ let host = HostDir::new("spelled");
1041
+ fs::create_dir(host.0.join("dir")).expect("the host directory must be made");
1042
+ fs::write(host.0.join("dir/file"), "x").expect("the host file must be written");
1043
+ let directory = Directory::new(&host.0).expect("the host directory must open");
1044
+ assert_eq!(
1045
+ (
1046
+ directory.stored_path("dir/file"),
1047
+ directory.stored_path("dir/new/name")
1048
+ ),
1049
+ (Ok("dir/file".to_string()), Ok("dir/new/name".to_string())),
1050
+ "a path asked of Directory::stored_path in the spelling its host stores, and one \
1051
+ whose tail is missing, must come back as given"
1052
+ );
1053
+ }
1054
+
1055
+ // macOS volumes fold case and Unicode form unless formatted
1056
+ // case-sensitive, where every spelling is already the stored one.
1057
+ // @behavior FS-082
1058
+ #[cfg(target_os = "macos")]
1059
+ #[test]
1060
+ fn a_directory_spells_a_path_as_a_folding_host_stores_it() {
1061
+ let host = HostDir::new("folded");
1062
+ fs::create_dir(host.0.join("private")).expect("the host directory must be made");
1063
+ fs::write(host.0.join("private/caf\u{e9}"), "x").expect("the host file must be written");
1064
+ if !host.0.join("PRIVATE").exists() {
1065
+ eprintln!("the host volume matches names exactly; nothing folds to witness");
1066
+ return;
1067
+ }
1068
+ let directory = Directory::new(&host.0).expect("the host directory must open");
1069
+ assert_eq!(
1070
+ (
1071
+ directory.stored_path("PRIVATE/CAFE\u{301}"),
1072
+ directory.stored_path("Private/missing")
1073
+ ),
1074
+ (
1075
+ Ok("private/caf\u{e9}".to_string()),
1076
+ Ok("private/missing".to_string())
1077
+ ),
1078
+ "a path asked of Directory::stored_path in another case and Unicode form on a host \
1079
+ folding both must come back as the host stores each name it holds"
1080
+ );
1081
+ }
1082
+ }