Skip to main content

nanvm_effects_node/
files.rs

1//! The file and directory operations over `std`.
2//!
3//! What each answers is what `fjs/effects/node/module.mjs` answers, with the
4//! differences the doc of the function names. A failure is an [`IoError`], the
5//! `ioError` the language reads.
6
7use nanvm_lib::vm::unstable::number_text;
8use std::{
9    fs::{self, File, OpenOptions},
10    io::{self, ErrorKind, Read, Seek, SeekFrom, Write},
11    path::Path,
12};
13
14/// A failed operation as the error channel carries it: the Node error code
15/// where there is one, and a message.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct IoError {
18    pub code: Option<String>,
19    pub message: String,
20}
21
22impl IoError {
23    /// A failure that is not the host's, with the code Node gives for it: an
24    /// argument the host would reject.
25    pub fn refusal(code: &str, message: impl Into<String>) -> IoError {
26        IoError {
27            code: Some(code.to_string()),
28            message: message.into(),
29        }
30    }
31
32    /// A failure with no code: the message alone.
33    pub fn message(message: impl Into<String>) -> IoError {
34        IoError {
35            code: None,
36            message: message.into(),
37        }
38    }
39}
40
41/// An entry of a directory, as Node's `Dirent`.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Dirent {
44    pub name: String,
45    pub parent_path: String,
46    pub is_file: bool,
47    pub is_directory: bool,
48}
49
50/// The most a file may hold to be read: one `Vec`, which the language caps at
51/// 2^17 bytes (`maxLengthBytes` in `fjs/types/bit_vec`). Metadata can refuse a
52/// larger file before reading; the read also checks the bytes actually returned.
53pub const MAX_FILE_SIZE_BYTES: u64 = 1 << 17;
54
55/// `maxOffset` in `fjs/effects/node/module.f.mjs`: `Number.MAX_SAFE_INTEGER`.
56const MAX_OFFSET: u64 = (1 << 53) - 1;
57
58/// The Node error code of a failure, where `std` has a kind for it: what a
59/// program reads from `IoError.code` to tell a missing file from a refused one.
60fn code(kind: ErrorKind) -> Option<&'static str> {
61    match kind {
62        ErrorKind::NotFound => Some("ENOENT"),
63        ErrorKind::PermissionDenied => Some("EACCES"),
64        ErrorKind::AlreadyExists => Some("EEXIST"),
65        ErrorKind::NotADirectory => Some("ENOTDIR"),
66        ErrorKind::IsADirectory => Some("EISDIR"),
67        ErrorKind::DirectoryNotEmpty => Some("ENOTEMPTY"),
68        ErrorKind::ReadOnlyFilesystem => Some("EROFS"),
69        ErrorKind::StorageFull => Some("ENOSPC"),
70        _ => None,
71    }
72}
73
74/// Raw codes carry distinctions `ErrorKind` loses, including `EPERM` versus
75/// `EACCES`, descriptor exhaustion and symlink loops. Keep the numeric ABI
76/// scoped to its target; these are not portable POSIX numbers. Full parity is
77/// tracked in `nanvm/effects-node/todo/filesystem-error-codes.md`.
78fn raw_code(raw: i32) -> Option<&'static str> {
79    // Linux asm-generic errno ABI, used by the native Linux targets in CI.
80    #[cfg(all(
81        target_os = "linux",
82        any(target_arch = "x86", target_arch = "x86_64", target_arch = "aarch64")
83    ))]
84    let codes: &[(i32, &str)] = &[
85        (1, "EPERM"),
86        (5, "EIO"),
87        (23, "ENFILE"),
88        (24, "EMFILE"),
89        (28, "ENOSPC"),
90        (30, "EROFS"),
91        (40, "ELOOP"),
92    ];
93    // Darwin bsd/sys/errno.h.
94    #[cfg(target_os = "macos")]
95    let codes: &[(i32, &str)] = &[
96        (1, "EPERM"),
97        (5, "EIO"),
98        (23, "ENFILE"),
99        (24, "EMFILE"),
100        (28, "ENOSPC"),
101        (30, "EROFS"),
102        (62, "ELOOP"),
103    ];
104    // Win32 system errors, translated as libuv's src/win/error.c does.
105    #[cfg(target_os = "windows")]
106    let codes: &[(i32, &str)] = &[
107        (4, "EMFILE"),
108        (5, "EPERM"),
109        (19, "EROFS"),
110        (39, "ENOSPC"),
111        (112, "ENOSPC"),
112        (1_117, "EIO"),
113        (1_314, "EPERM"),
114        (1_921, "ELOOP"),
115    ];
116    #[cfg(not(any(
117        all(
118            target_os = "linux",
119            any(target_arch = "x86", target_arch = "x86_64", target_arch = "aarch64")
120        ),
121        target_os = "macos",
122        target_os = "windows"
123    )))]
124    let codes: &[(i32, &str)] = &[];
125    codes
126        .iter()
127        .find_map(|&(number, name)| (raw == number).then_some(name))
128}
129
130/// A failed call as the error channel carries it: the code where there is
131/// one, and a message naming the call and the path.
132pub(crate) fn failure(error: &io::Error, call: &str, path: &str) -> IoError {
133    IoError {
134        code: error
135            .raw_os_error()
136            .and_then(raw_code)
137            .or_else(|| code(error.kind()))
138            .map(str::to_string),
139        message: format!("{error}, {call} '{path}'"),
140    }
141}
142
143/// `normalize` of `fjs/path`, for the paths a directory read answers: `\` is
144/// `/`, empty and `.` segments vanish, `..` cancels the segment before it or
145/// is kept where nothing is left to cancel, and a root, `/`, `//` or a drive's
146/// `C:/`, stays and absorbs the `..` that would climb past it. A bare `C:` and
147/// the drive-relative `C:a` are ordinary segments, as they are there.
148pub fn normalize(path: &str) -> String {
149    let path = path.replace('\\', "/");
150    let bytes = path.as_bytes();
151    let (root, rest) = if bytes.len() >= 3
152        && bytes[0].is_ascii_alphabetic()
153        && bytes[1] == b':'
154        && bytes[2] == b'/'
155    {
156        (&path[..3], &path[3..])
157    } else if path.starts_with("//") && !path.starts_with("///") {
158        ("//", &path[2..])
159    } else if let Some(rest) = path.strip_prefix('/') {
160        ("/", rest)
161    } else {
162        ("", &path[..])
163    };
164    let mut segments: Vec<&str> = Vec::new();
165    for segment in rest.split('/') {
166        match segment {
167            "" | "." => {}
168            ".." => match segments.last() {
169                Some(&last) if last != ".." => {
170                    segments.pop();
171                }
172                _ if root.is_empty() => segments.push(".."),
173                _ => {}
174            },
175            _ => segments.push(segment),
176        }
177    }
178    format!("{root}{}", segments.join("/"))
179}
180
181/// The entries of one directory, in byte order of their names, as `Dirent`s
182/// with `parent` as their parent.
183fn entries(directory: &str, parent: &str) -> io::Result<Vec<Dirent>> {
184    let mut found = Vec::new();
185    for entry in fs::read_dir(directory)? {
186        let entry = entry?;
187        let kind = entry.file_type()?;
188        found.push(Dirent {
189            name: entry.file_name().to_string_lossy().into_owned(),
190            parent_path: parent.to_string(),
191            is_file: kind.is_file(),
192            is_directory: kind.is_dir(),
193        });
194    }
195    found.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
196    Ok(found)
197}
198
199/// Every entry below `path`, level by level: the entries of `path`, then those
200/// of each directory among them in the order found, as `readdir` with
201/// `recursive` answers.
202fn tree(path: &str) -> io::Result<Vec<Dirent>> {
203    let mut found = Vec::new();
204    let mut queue = [path.to_string()].to_vec();
205    let mut next = 0;
206    while next < queue.len() {
207        let directory = queue[next].clone();
208        next += 1;
209        for entry in entries(&directory, &normalize(&directory))? {
210            // The runner uses fs.promises.readdir with withFileTypes, not
211            // callback readdir: symlinks are listed but not traversed.
212            if entry.is_directory {
213                queue.push(
214                    Path::new(&directory)
215                        .join(&entry.name)
216                        .to_string_lossy()
217                        .into_owned(),
218                );
219            }
220            found.push(entry);
221        }
222    }
223    Ok(found)
224}
225
226/// A byte offset within the shared positional-I/O safe integer range.
227fn offset(value: f64) -> Option<u64> {
228    ((0.0..=MAX_OFFSET as f64).contains(&value) && value.fract() == 0.0).then_some(value as u64)
229}
230
231/// `recursive` is `create_dir_all`, which answers nothing for a directory that
232/// exists, as `mkdir` with `recursive` does; without it an existing directory
233/// is `EEXIST`.
234pub fn mkdir(path: &str, recursive: bool) -> Result<(), IoError> {
235    // `create_dir_all("")` is `Ok`, creating nothing: an empty path names
236    // nothing, and Node answers `ENOENT` for it, as `create_dir` does.
237    if path.is_empty() {
238        return Err(failure(&ErrorKind::NotFound.into(), "mkdir", path));
239    }
240    if recursive {
241        fs::create_dir_all(path)
242    } else {
243        fs::create_dir(path)
244    }
245    .map_err(|e| failure(&e, "mkdir", path))
246}
247
248/// A file over [`MAX_FILE_SIZE_BYTES`] is refused, with no code. Metadata
249/// rejects a known oversized file before reading, but cannot bound the read:
250/// a file may grow, and virtual files may report a size of zero.
251pub fn read_file(path: &str) -> Result<Vec<u8>, IoError> {
252    let size = fs::metadata(path)
253        .map_err(|e| failure(&e, "stat", path))?
254        .len();
255    if size > MAX_FILE_SIZE_BYTES {
256        return Err(IoError::message(format!(
257            "File size {size} exceeds maximum allowed size of {MAX_FILE_SIZE_BYTES} bytes: '{path}'"
258        )));
259    }
260    let file = File::open(path).map_err(|e| failure(&e, "open", path))?;
261    read_bounded(path, file)
262}
263
264/// Read at most one byte beyond a `Vec`: that extra byte distinguishes a
265/// complete value at the limit from an oversized value, never a truncated success.
266fn read_bounded(path: &str, reader: impl Read) -> Result<Vec<u8>, IoError> {
267    let mut bytes = Vec::new();
268    reader
269        .take(MAX_FILE_SIZE_BYTES + 1)
270        .read_to_end(&mut bytes)
271        .map_err(|e| failure(&e, "read", path))?;
272    if bytes.len() as u64 > MAX_FILE_SIZE_BYTES {
273        return Err(IoError::message(format!(
274            "File exceeds maximum allowed size of {MAX_FILE_SIZE_BYTES} bytes: '{path}'"
275        )));
276    }
277    Ok(bytes)
278}
279
280/// A whole file as `readWhole` answers it: windows of one `Vec` each, so a
281/// file of any size is read. A path that names no regular file is refused
282/// before it is opened, with `notAFileCode` and `notAFileMessage` of
283/// `fjs/effects/node/module.f.mjs`: a FIFO with no writer would hold the open.
284pub fn read_whole(path: &str) -> Result<Vec<Vec<u8>>, IoError> {
285    let metadata = fs::metadata(path).map_err(|e| failure(&e, "stat", path))?;
286    if !metadata.is_file() {
287        return Err(IoError::refusal(
288            "ERR_NOT_A_FILE",
289            format!("{path} is not a regular file"),
290        ));
291    }
292    let file = File::open(path).map_err(|e| failure(&e, "open", path))?;
293    read_windows(path, file)
294}
295
296/// Every window is [`MAX_FILE_SIZE_BYTES`] but the last, which is shorter and
297/// never empty: an empty file is no windows, as the Node runner answers.
298fn read_windows(path: &str, mut reader: impl Read) -> Result<Vec<Vec<u8>>, IoError> {
299    let mut bytes = Vec::new();
300    reader
301        .read_to_end(&mut bytes)
302        .map_err(|e| failure(&e, "read", path))?;
303    Ok(bytes
304        .chunks(MAX_FILE_SIZE_BYTES as usize)
305        .map(<[u8]>::to_vec)
306        .collect())
307}
308
309/// The entries of each directory are in byte order of their names, where the
310/// operating system's order is unspecified, and a recursive read lists a
311/// directory's entries before those of any directory below it.
312pub fn readdir(path: &str, recursive: bool) -> Result<Vec<Dirent>, IoError> {
313    if recursive {
314        tree(path)
315    } else {
316        entries(path, &normalize(path))
317    }
318    .map_err(|e| failure(&e, "scandir", path))
319}
320
321/// Syncs `file` before it is closed, so an error the filesystem would report
322/// only at the close, a delayed `EIO` or `ENOSPC`, is reported: `std` discards
323/// the one `close(2)` answers, where the Node handler awaits `fh.close()`. The
324/// flush to disk is a cost the Node handler does not pay and this accepts. A file that
325/// cannot be synced at all, a pipe, a terminal or a device such as `/dev/null`,
326/// answers `EINVAL` or is unsupported, and has nothing to flush: that is not a
327/// failure. A Windows console answers
328/// `ERROR_INVALID_HANDLE`, which Rust does not categorize. The exclusive operations create a regular
329/// file, which can always be synced, so they call `sync_all` and take every
330/// error.
331fn sync(file: &File) -> io::Result<()> {
332    match file.sync_all() {
333        Err(e) if nothing_to_flush(&e) => Ok(()),
334        result => result,
335    }
336}
337
338/// What `FlushFileBuffers` answers for a console, on Windows only.
339const ERROR_INVALID_HANDLE: i32 = 6;
340
341fn nothing_to_flush(e: &io::Error) -> bool {
342    matches!(e.kind(), ErrorKind::InvalidInput | ErrorKind::Unsupported)
343        || (cfg!(windows) && e.raw_os_error() == Some(ERROR_INVALID_HANDLE))
344}
345
346/// Creates the file or truncates it.
347pub fn write_file(path: &str, data: &[u8]) -> Result<(), IoError> {
348    let mut file = File::create(path).map_err(|e| failure(&e, "open", path))?;
349    file.write_all(data)
350        .map_err(|e| failure(&e, "write", path))?;
351    sync(&file).map_err(|e| failure(&e, "close", path))
352}
353
354/// Writes into a file that exists, at `at`, leaving the rest as it is. An
355/// offset outside the safe non-negative integer range is `ERR_OUT_OF_RANGE`,
356/// refused before the file is opened. Empty data still opens the path, but
357/// does not seek or write, as the Node runner skips its write loop.
358pub fn write_bytes(path: &str, at: f64, data: &[u8]) -> Result<(), IoError> {
359    let Some(at) = offset(at) else {
360        return Err(IoError::refusal(
361            "ERR_OUT_OF_RANGE",
362            format!("The value of \"position\" is out of range: {at}"),
363        ));
364    };
365    let mut file: File = OpenOptions::new()
366        .read(true)
367        .write(true)
368        .open(path)
369        .map_err(|e| failure(&e, "open", path))?;
370    if !data.is_empty() {
371        file.seek(SeekFrom::Start(at))
372            .and_then(|_| file.write_all(data))
373            .map_err(|e| failure(&e, "write", path))?;
374    }
375    sync(&file).map_err(|e| failure(&e, "close", path))
376}
377
378/// `windowRefusal` of `fjs/effects/node/module.f.mjs`: the numbers a positional
379/// read refuses, before a byte is read, in the words both runners use.
380fn window_refusal(at: f64, size: f64) -> Option<String> {
381    if at.fract() != 0.0 || !at.is_finite() {
382        Some(format!("Offset {} is not an integer", number_text(at)))
383    } else if at < 0.0 {
384        Some(format!("Offset {} is negative", number_text(at)))
385    } else if at > MAX_OFFSET as f64 {
386        Some(format!(
387            "Offset {} exceeds maximum allowed offset of {MAX_OFFSET}",
388            number_text(at)
389        ))
390    } else if size.fract() != 0.0 || !size.is_finite() {
391        Some(format!(
392            "Chunk size {} is not an integer",
393            number_text(size)
394        ))
395    } else if size < 0.0 {
396        Some(format!("Chunk size {} is negative", number_text(size)))
397    } else if size > MAX_FILE_SIZE_BYTES as f64 {
398        Some(format!(
399            "Chunk size {} exceeds maximum allowed size of {MAX_FILE_SIZE_BYTES} bytes",
400            number_text(size)
401        ))
402    } else {
403        None
404    }
405}
406
407/// The bytes at `at`, at most `size` of them; fewer only at the end of the
408/// file. A window the Node runner refuses is refused before the file is opened,
409/// with no code.
410pub fn read_bytes(path: &str, at: f64, size: f64) -> Result<Vec<u8>, IoError> {
411    check_window(at, size)?;
412    let file = File::open(path).map_err(|e| failure(&e, "open", path))?;
413    read_window(&file, at, size, path)
414}
415
416/// `size` bytes at `at` of an open file, fewer at its end. An empty window
417/// reads nothing, so it asks nothing of the file either: a pipe, which cannot
418/// seek, answers it as Node does.
419fn read_window(mut file: &File, at: f64, size: f64, what: &str) -> Result<Vec<u8>, IoError> {
420    let mut bytes = Vec::new();
421    if size == 0.0 {
422        return Ok(bytes);
423    }
424    file.seek(SeekFrom::Start(at as u64))
425        .and_then(|_| file.take(size as u64).read_to_end(&mut bytes))
426        .map_err(|e| failure(&e, "read", what))?;
427    Ok(bytes)
428}
429
430/// What a handle's operation answers for one that is closed.
431pub fn bad_descriptor() -> IoError {
432    IoError::refusal("EBADF", "bad file descriptor")
433}
434
435/// `open` of `fjs/effects/node/types.ts`: the file `path` names, for reading.
436/// A FIFO is refused before it is opened, `ERR_NOT_A_FILE` as for `readWhole`:
437/// a plain open of one with no writer blocks, the Node runner opens it without
438/// blocking and lets `fstat` say what it is, and `std` names no flag for that.
439pub fn open(path: &str) -> Result<File, IoError> {
440    #[cfg(unix)]
441    {
442        use std::os::unix::fs::FileTypeExt;
443        if fs::metadata(path).is_ok_and(|m| m.file_type().is_fifo()) {
444            return Err(IoError::refusal(
445                "ERR_NOT_A_FILE",
446                format!("{path} is a FIFO, which cannot be opened without blocking"),
447            ));
448        }
449    }
450    File::open(path).map_err(|e| failure(&e, "open", path))
451}
452
453/// `fstat`: what the open file is, as `stat` says of a name.
454pub fn fstat(file: &File) -> Result<Stat, IoError> {
455    let metadata = file
456        .metadata()
457        .map_err(|e| failure(&e, "fstat", "handle"))?;
458    Ok(metadata.into())
459}
460
461/// `pread`: the window `read_bytes` reads, through an open file. The window
462/// is checked by [`check_window`], which the Node runner does before it looks
463/// at the handle.
464pub fn pread(file: &File, at: f64, size: f64) -> Result<Vec<u8>, IoError> {
465    read_window(file, at, size, "handle")
466}
467
468/// Whether `size` bytes at `at` is a window a read may ask for.
469pub fn check_window(at: f64, size: f64) -> Result<(), IoError> {
470    match window_refusal(at, size) {
471        Some(message) => Err(IoError::message(message)),
472        None => Ok(()),
473    }
474}
475
476/// Creates `path` empty and fails if it exists (`O_CREAT|O_EXCL`). The file is
477/// synced before it is closed, so an error the filesystem would report only at
478/// the close is reported here; it is left in place, as
479/// the Node runner leaves it.
480pub fn create_exclusive(path: &str) -> Result<(), IoError> {
481    let file = exclusive(path).map_err(|e| failure(&e, "open", path))?;
482    file.sync_all().map_err(|e| failure(&e, "close", path))
483}
484
485fn exclusive(path: &str) -> io::Result<File> {
486    OpenOptions::new().write(true).create_new(true).open(path)
487}
488
489/// Creates `path` holding `data`, in one open that fails if the file exists:
490/// the file either exists holding all of `data` or does not exist. A write that
491/// fails after the create removes the file, which is this call's alone because
492/// the create was exclusive; that is why it is not `write_file`. The data is
493/// then synced before the file is closed, so an error the filesystem reports
494/// only at the close, a delayed `EIO` or `ENOSPC`, is reported as a failed
495/// `close`. As in the Node runner that failure does not remove the file, which
496/// is left behind, where a failed write removes it.
497pub fn write_exclusive(path: &str, data: &[Vec<u8>]) -> Result<(), IoError> {
498    write_exclusive_with(
499        path,
500        |file| data.iter().try_for_each(|d| file.write_all(d)),
501        File::sync_all,
502    )
503}
504
505fn write_exclusive_with(
506    path: &str,
507    write: impl FnOnce(&mut File) -> io::Result<()>,
508    finish: impl FnOnce(&File) -> io::Result<()>,
509) -> Result<(), IoError> {
510    // The handle is gone when the block ends, so a failed write removes the
511    // file by name only after it is closed.
512    let failed = {
513        let mut file = exclusive(path).map_err(|e| failure(&e, "open", path))?;
514        match write(&mut file) {
515            Ok(()) => return finish(&file).map_err(|e| failure(&e, "close", path)),
516            Err(e) => e,
517        }
518    };
519    // As the Node runner's `rm` with `force`: a file that is already gone is
520    // rolled back, and any other failure replaces the write's, because the
521    // caller is promised no file and would retry into `EEXIST`.
522    match fs::remove_file(path) {
523        Err(e) if e.kind() != ErrorKind::NotFound => Err(failure(&e, "unlink", path)),
524        _ => Err(failure(&failed, "write", path)),
525    }
526}
527
528/// Removes a file or a link, and refuses a directory with `ERR_FS_EISDIR`, the
529/// code `rm` without `recursive` gives on Node, where `remove_file` would
530/// answer what the platform does. Windows directory symlinks need `remove_dir`;
531/// the link's own metadata selects that API, even when its target is missing.
532pub fn rm(path: &str) -> Result<(), IoError> {
533    let metadata = fs::symlink_metadata(path);
534    if metadata.as_ref().is_ok_and(|m| m.is_dir()) {
535        return Err(IoError::refusal(
536            "ERR_FS_EISDIR",
537            format!("Path is a directory: rm returned EISDIR (is a directory) {path}"),
538        ));
539    }
540    #[cfg(windows)]
541    {
542        use std::os::windows::fs::FileTypeExt;
543
544        if metadata.is_ok_and(|m| m.file_type().is_symlink_dir()) {
545            return fs::remove_dir(path).map_err(|e| failure(&e, "rm", path));
546        }
547    }
548    fs::remove_file(path).map_err(|e| failure(&e, "rm", path))
549}
550
551/// What `stat` answers: the size and which of the two kinds a caller acts on
552/// the entry is. A link is followed, a FIFO, a device and a socket are neither
553/// (`FileStat` in `fjs/effects/node/types.ts`).
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub struct Stat {
556    pub size: u64,
557    pub is_file: bool,
558    pub is_directory: bool,
559}
560
561impl From<fs::Metadata> for Stat {
562    fn from(metadata: fs::Metadata) -> Stat {
563        Stat {
564            size: metadata.len(),
565            is_file: metadata.is_file(),
566            is_directory: metadata.is_dir(),
567        }
568    }
569}
570
571pub fn stat(path: &str) -> Result<Stat, IoError> {
572    let metadata = fs::metadata(path).map_err(|e| failure(&e, "stat", path))?;
573    Ok(metadata.into())
574}
575
576/// Whether the path exists, as `access` with no mode asks: a link is followed.
577pub fn access(path: &str) -> Result<(), IoError> {
578    fs::metadata(path)
579        .map(|_| ())
580        .map_err(|e| failure(&e, "access", path))
581}
582
583/// Moves `src` to `dst`, replacing a file there.
584pub fn rename(src: &str, dst: &str) -> Result<(), IoError> {
585    fs::rename(src, dst).map_err(|e| failure(&e, "rename", &format!("{src}' -> '{dst}")))
586}
587
588/// Removes an empty directory. Nothing is followed: a link, to a directory or
589/// not, is `ENOTDIR`, as is a file, which holds on every host (`Rmdir` in
590/// `fjs/effects/node/types.ts`); Windows would otherwise remove a directory
591/// link. `FileType::is_symlink` is true for a Windows junction too: std reads
592/// it from the reparse tag's name-surrogate bit (`0x20000000`), which the
593/// symlink tag `0xA000000C` and the mount-point tag `0xA0000003` both carry.
594pub fn rmdir(path: &str) -> Result<(), IoError> {
595    if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) {
596        return Err(failure(&ErrorKind::NotADirectory.into(), "rmdir", path));
597    }
598    fs::remove_dir(path).map_err(|e| failure(&e, "rmdir", path))
599}
600
601#[cfg(test)]
602mod test {
603    use super::*;
604
605    /// `normalize` agrees with `fjs/path`'s on the cases it was written from.
606    #[test]
607    fn normalizing() {
608        for (path, expected) in [
609            (".", ""),
610            ("", ""),
611            ("a/", "a"),
612            ("./a", "a"),
613            ("a//b", "a/b"),
614            ("/a/./b", "/a/b"),
615            ("/", "/"),
616            ("a/../b", "b"),
617            ("..", ".."),
618            ("../a", "../a"),
619            ("a/b/..", "a"),
620            ("/..", "/"),
621            ("/a/..", "/"),
622            ("//a", "//a"),
623            ("///a", "/a"),
624            ("//", "//"),
625            ("//a/../b", "//b"),
626            ("a\\b", "a/b"),
627            ("/a/../../b", "/b"),
628            ("a/../..", ".."),
629            ("../..", "../.."),
630            ("C:/", "C:/"),
631            ("C:", "C:"),
632            ("C:a", "C:a"),
633            ("C:a/b", "C:a/b"),
634            ("c:/x", "c:/x"),
635            ("C://a", "C:/a"),
636            ("C:/..", "C:/"),
637            ("C:/a/../..", "C:/"),
638            ("C:\\a\\..", "C:/"),
639            ("C:/a/./b/", "C:/a/b"),
640            ("1:/a", "1:/a"),
641            ("CC:/a", "CC:/a"),
642            ("//C:/a", "//C:/a"),
643            ("/C:/a", "/C:/a"),
644        ] {
645            assert_eq!(normalize(path), expected, "{path:?}");
646        }
647    }
648
649    /// The numbers a positional read refuses, in the words of `windowRefusal`.
650    #[test]
651    fn window_refusals() {
652        let said = |at, size| window_refusal(at, size);
653        assert_eq!(said(0.0, 0.0), None);
654        assert_eq!(said(9007199254740991.0, 131072.0), None);
655        assert_eq!(said(1.5, 1.0), Some("Offset 1.5 is not an integer".into()));
656        assert_eq!(
657            said(f64::NAN, 1.0),
658            Some("Offset NaN is not an integer".into())
659        );
660        assert_eq!(
661            said(f64::INFINITY, 1.0),
662            Some("Offset Infinity is not an integer".into())
663        );
664        assert_eq!(said(-1.0, 1.0), Some("Offset -1 is negative".into()));
665        assert_eq!(
666            said(9007199254740992.0, 1.0),
667            Some(
668                "Offset 9007199254740992 exceeds maximum allowed offset of 9007199254740991".into()
669            )
670        );
671        assert_eq!(
672            said(0.0, 0.5),
673            Some("Chunk size 0.5 is not an integer".into())
674        );
675        assert_eq!(said(0.0, -2.0), Some("Chunk size -2 is negative".into()));
676        assert_eq!(
677            said(0.0, 131073.0),
678            Some("Chunk size 131073 exceeds maximum allowed size of 131072 bytes".into())
679        );
680    }
681
682    /// An empty path names nothing, with or without `recursive`.
683    #[test]
684    fn mkdir_of_an_empty_path() {
685        for recursive in [false, true] {
686            assert_eq!(code_of(mkdir("", recursive)), Some("ENOENT".into()));
687        }
688    }
689
690    #[test]
691    fn offsets() {
692        assert_eq!(offset(0.0), Some(0));
693        assert_eq!(offset(5.0), Some(5));
694        assert_eq!(offset(-0.0), Some(0));
695        assert_eq!(offset(MAX_OFFSET as f64 - 1.0), Some(MAX_OFFSET - 1));
696        assert_eq!(offset(MAX_OFFSET as f64), Some(MAX_OFFSET));
697        assert_eq!(offset(MAX_OFFSET as f64 + 1.0), None);
698        assert_eq!(offset(u64::MAX as f64), None);
699        assert_eq!(offset(-1.0), None);
700        assert_eq!(offset(0.5), None);
701        assert_eq!(offset(f64::NAN), None);
702        assert_eq!(offset(f64::INFINITY), None);
703        assert_eq!(offset(f64::NEG_INFINITY), None);
704        assert_eq!(offset(1e30), None);
705    }
706
707    #[test]
708    fn codes() {
709        assert_eq!(code(ErrorKind::NotFound), Some("ENOENT"));
710        assert_eq!(code(ErrorKind::PermissionDenied), Some("EACCES"));
711        assert_eq!(code(ErrorKind::AlreadyExists), Some("EEXIST"));
712        assert_eq!(code(ErrorKind::NotADirectory), Some("ENOTDIR"));
713        assert_eq!(code(ErrorKind::IsADirectory), Some("EISDIR"));
714        assert_eq!(code(ErrorKind::DirectoryNotEmpty), Some("ENOTEMPTY"));
715        assert_eq!(code(ErrorKind::ReadOnlyFilesystem), Some("EROFS"));
716        assert_eq!(code(ErrorKind::StorageFull), Some("ENOSPC"));
717        assert_eq!(code(ErrorKind::Other), None);
718    }
719
720    #[test]
721    fn failures_preserve_codes_and_context() {
722        for (kind, expected) in [
723            (ErrorKind::ReadOnlyFilesystem, Some("EROFS")),
724            (ErrorKind::StorageFull, Some("ENOSPC")),
725            (ErrorKind::Other, None),
726        ] {
727            let error = failure(&kind.into(), "write", "p");
728            assert_eq!(error.code.as_deref(), expected);
729            assert!(error.message.ends_with(", write 'p'"));
730        }
731        let unknown = io::Error::from_raw_os_error(i32::MAX);
732        assert_eq!(raw_code(i32::MAX), None);
733        assert_eq!(failure(&unknown, "open", "p").code, None);
734    }
735
736    #[cfg(any(
737        all(
738            target_os = "linux",
739            any(target_arch = "x86", target_arch = "x86_64", target_arch = "aarch64")
740        ),
741        target_os = "macos"
742    ))]
743    #[test]
744    fn unix_failures_preserve_raw_codes() {
745        for (raw, expected) in [
746            (1, "EPERM"),
747            (5, "EIO"),
748            (23, "ENFILE"),
749            (24, "EMFILE"),
750            (28, "ENOSPC"),
751            (30, "EROFS"),
752        ] {
753            let error = io::Error::from_raw_os_error(raw);
754            assert_eq!(failure(&error, "open", "p").code.as_deref(), Some(expected));
755        }
756        // A raw permission error must not collapse EPERM into EACCES.
757        let denied = io::Error::from_raw_os_error(13);
758        assert_eq!(
759            failure(&denied, "open", "p").code.as_deref(),
760            Some("EACCES")
761        );
762    }
763
764    #[cfg(target_os = "windows")]
765    #[test]
766    fn windows_failures_preserve_raw_codes() {
767        for (raw, expected) in [
768            (4, "EMFILE"),
769            (5, "EPERM"),
770            (19, "EROFS"),
771            (39, "ENOSPC"),
772            (112, "ENOSPC"),
773            (1_117, "EIO"),
774            (1_314, "EPERM"),
775            (1_921, "ELOOP"),
776        ] {
777            let error = io::Error::from_raw_os_error(raw);
778            assert_eq!(failure(&error, "open", "p").code.as_deref(), Some(expected));
779        }
780    }
781
782    fn code_of<T: std::fmt::Debug>(r: Result<T, IoError>) -> Option<String> {
783        r.unwrap_err().code
784    }
785
786    #[test]
787    fn whole_read_answers_windows() {
788        let max = MAX_FILE_SIZE_BYTES;
789        for (size, expected) in [
790            (0, vec![]),
791            (1, vec![1]),
792            (max, vec![max]),
793            (max + 1, vec![max, 1]),
794            (2 * max + 5, vec![max, max, 5]),
795        ] {
796            let windows = read_windows("stream", io::repeat(0xa5).take(size)).unwrap();
797            let lengths: Vec<u64> = windows.iter().map(|w| w.len() as u64).collect();
798            assert_eq!(lengths, expected, "{size}");
799        }
800    }
801
802    /// A directory is no regular file, and is refused rather than read.
803    #[cfg(not(target_family = "wasm"))]
804    #[test]
805    fn whole_read_refuses_a_directory() {
806        let dir = std::env::temp_dir();
807        let path = dir.to_string_lossy();
808        let error = read_whole(&path).unwrap_err();
809        assert_eq!(error.code.as_deref(), Some("ERR_NOT_A_FILE"));
810        assert_eq!(error.message, format!("{path} is not a regular file"));
811        let missing = dir.join("nanvm-effects-node-no-such-file");
812        assert_eq!(
813            code_of(read_whole(&missing.to_string_lossy())),
814            Some("ENOENT".into())
815        );
816    }
817
818    #[test]
819    fn bounded_read_accepts_up_to_the_limit() {
820        for size in [0, 1, MAX_FILE_SIZE_BYTES - 1, MAX_FILE_SIZE_BYTES] {
821            let bytes = read_bounded("stream", io::repeat(0xa5).take(size)).unwrap();
822            assert_eq!(bytes.len() as u64, size);
823            assert!(bytes.iter().all(|&byte| byte == 0xa5));
824        }
825    }
826
827    /// A larger source is refused after the first excess byte, not read to its end.
828    #[test]
829    fn bounded_read_refuses_overflow() {
830        let mut reader = io::repeat(0).take(MAX_FILE_SIZE_BYTES + 8);
831        let error = read_bounded("stream", &mut reader).unwrap_err();
832        assert_eq!(reader.limit(), 7);
833        assert_eq!(error.code, None);
834        assert_eq!(
835            error.message,
836            "File exceeds maximum allowed size of 131072 bytes: 'stream'"
837        );
838    }
839
840    #[test]
841    fn bounded_read_reports_io_errors() {
842        struct Broken;
843        impl Read for Broken {
844            fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
845                Err(ErrorKind::PermissionDenied.into())
846            }
847        }
848        let error = read_bounded("unreadable", Broken).unwrap_err();
849        assert_eq!(error.code, Some("EACCES".into()));
850        assert!(error.message.ends_with(", read 'unreadable'"));
851    }
852
853    /// The operations against a real directory, which a WebAssembly host does
854    /// not give a test.
855    #[cfg(not(target_family = "wasm"))]
856    mod directory {
857        use super::*;
858        use crate::scratch::Scratch;
859
860        #[test]
861        fn stat_and_access() {
862            let dir = Scratch::new("files");
863            let file = dir.at("f");
864            write_file(&file, &[1, 2, 3]).unwrap();
865            assert_eq!(
866                stat(&file),
867                Ok(Stat {
868                    size: 3,
869                    is_file: true,
870                    is_directory: false
871                })
872            );
873            assert_eq!(
874                stat(&dir.at("")),
875                Ok(Stat {
876                    size: stat(&dir.at("")).unwrap().size,
877                    is_file: false,
878                    is_directory: true
879                })
880            );
881            assert_eq!(code_of(stat(&dir.at("none"))), Some("ENOENT".into()));
882            assert_eq!(access(&file), Ok(()));
883            assert_eq!(access(&dir.at("")), Ok(()));
884            assert_eq!(code_of(access(&dir.at("none"))), Some("ENOENT".into()));
885        }
886
887        #[test]
888        fn rename_replaces_a_file() {
889            let dir = Scratch::new("files");
890            write_file(&dir.at("a"), &[1]).unwrap();
891            write_file(&dir.at("b"), &[2]).unwrap();
892            assert_eq!(rename(&dir.at("a"), &dir.at("b")), Ok(()));
893            assert_eq!(read_file(&dir.at("b")), Ok([1].to_vec()));
894            assert_eq!(code_of(read_file(&dir.at("a"))), Some("ENOENT".into()));
895            let error = rename(&dir.at("a"), &dir.at("c")).unwrap_err();
896            assert_eq!(error.code, Some("ENOENT".into()));
897            assert!(error.message.contains(" -> "), "{}", error.message);
898        }
899
900        #[test]
901        fn rmdir_removes_an_empty_directory_only() {
902            let dir = Scratch::new("files");
903            mkdir(&dir.at("d"), false).unwrap();
904            write_file(&dir.at("d/f"), &[]).unwrap();
905            assert_eq!(code_of(rmdir(&dir.at("d"))), Some("ENOTEMPTY".into()));
906            rm(&dir.at("d/f")).unwrap();
907            assert_eq!(rmdir(&dir.at("d")), Ok(()));
908            assert_eq!(code_of(rmdir(&dir.at("d"))), Some("ENOENT".into()));
909            write_file(&dir.at("f"), &[]).unwrap();
910            assert_eq!(code_of(rmdir(&dir.at("f"))), Some("ENOTDIR".into()));
911        }
912
913        /// A link to a directory is `ENOTDIR` and is left alone.
914        #[cfg(unix)]
915        #[test]
916        fn rmdir_refuses_a_link() {
917            let dir = Scratch::new("files");
918            mkdir(&dir.at("target"), false).unwrap();
919            std::os::unix::fs::symlink(dir.at("target"), dir.at("link")).unwrap();
920            assert_eq!(code_of(rmdir(&dir.at("link"))), Some("ENOTDIR".into()));
921            assert_eq!(access(&dir.at("link")), Ok(()));
922            assert_eq!(rmdir(&dir.at("target")), Ok(()));
923        }
924
925        #[test]
926        fn read_bytes_windows() {
927            let dir = Scratch::new("files");
928            let file = dir.at("f");
929            write_file(&file, &[1, 2, 3, 4, 5]).unwrap();
930            assert_eq!(read_bytes(&file, 1.0, 3.0), Ok([2, 3, 4].to_vec()));
931            assert_eq!(read_bytes(&file, 3.0, 10.0), Ok([4, 5].to_vec()));
932            assert_eq!(read_bytes(&file, 9.0, 4.0), Ok(Vec::new()));
933            assert_eq!(read_bytes(&file, 0.0, 0.0), Ok(Vec::new()));
934            assert_eq!(
935                code_of(read_bytes(&dir.at("none"), 0.0, 1.0)),
936                Some("ENOENT".into())
937            );
938            // Refused before the path is opened: no code, not ENOENT.
939            let error = read_bytes(&dir.at("none"), -1.0, 1.0).unwrap_err();
940            assert_eq!(error.code, None);
941            assert_eq!(error.message, "Offset -1 is negative");
942        }
943
944        /// An empty window opens the path and seeks nowhere, so a pipe answers it.
945        #[cfg(target_os = "linux")]
946        #[test]
947        fn empty_read_does_not_seek_a_pipe() {
948            use std::os::fd::AsRawFd;
949
950            let (reader, _writer) = io::pipe().unwrap();
951            let path = format!("/proc/self/fd/{}", reader.as_raw_fd());
952            for at in [0.0, MAX_OFFSET as f64] {
953                assert_eq!(read_bytes(&path, at, 0.0), Ok(Vec::new()));
954            }
955            // A nonempty window still needs the seek, which this file cannot do.
956            assert!(read_bytes(&path, 0.0, 1.0).is_err());
957        }
958
959        /// A FIFO is refused before it is opened, where a plain open of one
960        /// with no writer would never return.
961        #[cfg(target_os = "linux")]
962        #[test]
963        fn a_fifo_is_not_opened() {
964            use std::os::fd::AsRawFd;
965
966            let (reader, _writer) = io::pipe().unwrap();
967            let path = format!("/proc/self/fd/{}", reader.as_raw_fd());
968            assert_eq!(code_of(open(&path)), Some("ERR_NOT_A_FILE".into()));
969        }
970
971        #[test]
972        fn exclusive_creation() {
973            let dir = Scratch::new("files");
974            let file = dir.at("f");
975            assert_eq!(create_exclusive(&file), Ok(()));
976            assert_eq!(read_file(&file), Ok(Vec::new()));
977            assert_eq!(code_of(create_exclusive(&file)), Some("EEXIST".into()));
978            assert_eq!(code_of(write_exclusive(&file, &[])), Some("EEXIST".into()));
979            assert_eq!(read_file(&file), Ok(Vec::new()));
980        }
981
982        #[test]
983        fn write_exclusive_writes_every_chunk() {
984            let dir = Scratch::new("files");
985            let file = dir.at("f");
986            assert_eq!(
987                write_exclusive(&file, &[[1, 2].to_vec(), Vec::new(), [3].to_vec()]),
988                Ok(())
989            );
990            assert_eq!(read_file(&file), Ok([1, 2, 3].to_vec()));
991            assert_eq!(
992                code_of(write_exclusive(&dir.at("a/b"), &[])),
993                Some("ENOENT".into())
994            );
995        }
996
997        /// A write that fails after the create leaves no file, and the failure
998        /// is the write's, not the cleanup's.
999        #[test]
1000        fn a_failed_exclusive_write_is_rolled_back() {
1001            let dir = Scratch::new("files");
1002            let file = dir.at("f");
1003            let result = write_exclusive_with(
1004                &file,
1005                |f| {
1006                    f.write_all(&[1])?;
1007                    Err(ErrorKind::StorageFull.into())
1008                },
1009                |_| unreachable!("a failed write is not finished"),
1010            );
1011            let error = result.unwrap_err();
1012            assert_eq!(error.code, Some("ENOSPC".into()));
1013            assert!(error.message.contains("write"), "{}", error.message);
1014            assert_eq!(code_of(access(&file)), Some("ENOENT".into()));
1015        }
1016
1017        /// A rollback that fails is the failure reported, since the file the
1018        /// caller was promised gone is still there; one that finds the file
1019        /// already gone is a rollback.
1020        #[test]
1021        fn a_failed_rollback_is_reported() {
1022            let dir = Scratch::new("files");
1023            let file = dir.at("f");
1024            let error = write_exclusive_with(
1025                &file,
1026                |_| {
1027                    fs::remove_file(&file)?;
1028                    fs::create_dir_all(format!("{file}/inside"))?;
1029                    Err(ErrorKind::StorageFull.into())
1030                },
1031                |_| unreachable!("a failed write is not finished"),
1032            )
1033            .unwrap_err();
1034            assert!(error.message.contains("unlink"), "{}", error.message);
1035            let error = write_exclusive_with(
1036                &dir.at("g"),
1037                |_| {
1038                    fs::remove_file(dir.at("g"))?;
1039                    Err(ErrorKind::StorageFull.into())
1040                },
1041                |_| unreachable!("a failed write is not finished"),
1042            )
1043            .unwrap_err();
1044            assert_eq!(error.code, Some("ENOSPC".into()));
1045            assert!(error.message.contains("write"), "{}", error.message);
1046        }
1047
1048        /// What a console, a pipe or a device answers to a sync is nothing to flush.
1049        #[test]
1050        fn a_console_that_cannot_be_flushed_has_nothing_to_flush() {
1051            let invalid_handle = io::Error::from_raw_os_error(ERROR_INVALID_HANDLE);
1052            assert_eq!(nothing_to_flush(&invalid_handle), cfg!(windows));
1053            assert!(nothing_to_flush(&ErrorKind::InvalidInput.into()));
1054            assert!(!nothing_to_flush(&ErrorKind::StorageFull.into()));
1055        }
1056
1057        /// A file that cannot be synced has nothing to flush: a write to a pipe
1058        /// succeeds, where `sync_all` alone would answer `EINVAL`.
1059        #[cfg(target_os = "linux")]
1060        #[test]
1061        fn a_file_that_cannot_be_synced_is_not_a_failure() {
1062            use std::os::fd::AsRawFd;
1063
1064            let (_reader, writer) = io::pipe().unwrap();
1065            let path = format!("/proc/self/fd/{}", writer.as_raw_fd());
1066            let opened = OpenOptions::new().write(true).open(&path).unwrap();
1067            assert_eq!(
1068                opened.sync_all().unwrap_err().kind(),
1069                ErrorKind::InvalidInput
1070            );
1071            assert_eq!(write_file(&path, &[1]), Ok(()));
1072            assert_eq!(write_bytes(&path, 0.0, &[]), Ok(()));
1073        }
1074
1075        #[test]
1076        fn write_read_and_rm() {
1077            let dir = Scratch::new("files");
1078            let file = dir.at("a.bin");
1079            assert_eq!(write_file(&file, &[1, 2, 3]), Ok(()));
1080            assert_eq!(read_file(&file), Ok([1, 2, 3].to_vec()));
1081            assert_eq!(write_file(&file, &[9]), Ok(()));
1082            assert_eq!(read_file(&file), Ok([9].to_vec()));
1083            assert_eq!(rm(&file), Ok(()));
1084            assert_eq!(code_of(read_file(&file)), Some("ENOENT".into()));
1085            assert_eq!(code_of(rm(&file)), Some("ENOENT".into()));
1086        }
1087
1088        #[test]
1089        fn read_refuses_a_file_over_a_vec() {
1090            let dir = Scratch::new("files");
1091            let file = dir.at("big");
1092            assert_eq!(
1093                write_file(&file, &[0; MAX_FILE_SIZE_BYTES as usize]),
1094                Ok(())
1095            );
1096            assert_eq!(read_file(&file).map(|v| v.len()), Ok(1 << 17));
1097            assert_eq!(write_bytes(&file, (1 << 17) as f64, &[0]), Ok(()));
1098            let error = read_file(&file).unwrap_err();
1099            assert_eq!(error.code, None);
1100            assert!(
1101                error.message.starts_with("File size 131073 exceeds"),
1102                "{}",
1103                error.message
1104            );
1105        }
1106
1107        /// `/dev/zero` reports size zero but never reaches EOF. The metadata
1108        /// precheck alone cannot keep this read within a `Vec`.
1109        #[cfg(target_os = "linux")]
1110        #[test]
1111        fn read_refuses_bytes_hidden_by_metadata() {
1112            assert_eq!(fs::metadata("/dev/zero").unwrap().len(), 0);
1113            let error = read_file("/dev/zero").unwrap_err();
1114            assert_eq!(error.code, None);
1115            assert_eq!(
1116                error.message,
1117                "File exceeds maximum allowed size of 131072 bytes: '/dev/zero'"
1118            );
1119        }
1120
1121        #[cfg(any(
1122            all(
1123                target_os = "linux",
1124                any(target_arch = "x86", target_arch = "x86_64", target_arch = "aarch64")
1125            ),
1126            target_os = "macos"
1127        ))]
1128        #[test]
1129        fn read_reports_a_symlink_loop() {
1130            let dir = Scratch::new("files");
1131            let link = dir.at("loop");
1132            std::os::unix::fs::symlink("loop", &link).unwrap();
1133            assert_eq!(code_of(read_file(&link)), Some("ELOOP".into()));
1134        }
1135
1136        #[test]
1137        fn unsafe_offsets_are_refused_before_open() {
1138            let dir = Scratch::new("files");
1139            let missing = dir.at("missing");
1140            for at in [MAX_OFFSET as f64 + 1.0, u64::MAX as f64] {
1141                // A valid offset on this path would be ENOENT. No sparse file
1142                // is created to prove the numeric boundary.
1143                assert_eq!(
1144                    code_of(write_bytes(&missing, at, &[1])),
1145                    Some("ERR_OUT_OF_RANGE".into())
1146                );
1147            }
1148            assert!(!Path::new(&missing).exists());
1149        }
1150
1151        #[test]
1152        fn rm_refuses_a_directory() {
1153            let dir = Scratch::new("files");
1154            assert_eq!(code_of(rm(&dir.at(""))), Some("ERR_FS_EISDIR".into()));
1155            assert_eq!(code_of(rm(&dir.at("none"))), Some("ENOENT".into()));
1156        }
1157
1158        /// Removing directory links must leave both empty and nonempty targets.
1159        /// On Windows, creating these links requires Developer Mode or privilege.
1160        #[cfg(any(unix, windows))]
1161        #[test]
1162        fn rm_removes_directory_links_not_targets() {
1163            #[cfg(unix)]
1164            use std::os::unix::fs::symlink as symlink_dir;
1165            #[cfg(windows)]
1166            use std::os::windows::fs::symlink_dir;
1167
1168            let dir = Scratch::new("files");
1169            for name in ["empty", "nonempty"] {
1170                mkdir(&dir.at(name), false).unwrap();
1171            }
1172            let child = dir.at("nonempty/child");
1173            write_file(&child, &[1, 2, 3]).unwrap();
1174            for name in ["empty", "nonempty", "missing"] {
1175                let link = dir.at(&format!("{name}-link"));
1176                symlink_dir(dir.at(name), &link).unwrap();
1177                assert!(fs::symlink_metadata(&link).unwrap().is_symlink());
1178                assert_eq!(rm(&link), Ok(()));
1179                // exists() follows links, so it cannot prove a dangling link is gone.
1180                let error = fs::symlink_metadata(&link).unwrap_err();
1181                assert_eq!(error.kind(), ErrorKind::NotFound);
1182            }
1183            for name in ["empty", "nonempty"] {
1184                let target = dir.at(name);
1185                assert!(Path::new(&target).is_dir());
1186                assert_eq!(code_of(rm(&target)), Some("ERR_FS_EISDIR".into()));
1187                assert!(Path::new(&target).is_dir());
1188            }
1189            assert_eq!(read_file(&child), Ok([1, 2, 3].to_vec()));
1190            assert!(!Path::new(&dir.at("missing")).exists());
1191        }
1192
1193        /// File links, including dangling ones, still use the file-removal path.
1194        #[cfg(any(unix, windows))]
1195        #[test]
1196        fn rm_removes_file_links_not_targets() {
1197            #[cfg(unix)]
1198            use std::os::unix::fs::symlink as symlink_file;
1199            #[cfg(windows)]
1200            use std::os::windows::fs::symlink_file;
1201
1202            let dir = Scratch::new("files");
1203            let target = dir.at("file");
1204            write_file(&target, &[4, 5]).unwrap();
1205            for name in ["file", "missing"] {
1206                let link = dir.at(&format!("{name}-link"));
1207                symlink_file(dir.at(name), &link).unwrap();
1208                assert!(fs::symlink_metadata(&link).unwrap().is_symlink());
1209                assert_eq!(rm(&link), Ok(()));
1210                let error = fs::symlink_metadata(&link).unwrap_err();
1211                assert_eq!(error.kind(), ErrorKind::NotFound);
1212            }
1213            assert_eq!(read_file(&target), Ok([4, 5].to_vec()));
1214            assert!(!Path::new(&dir.at("missing")).exists());
1215        }
1216
1217        #[test]
1218        fn mkdir_nested() {
1219            let dir = Scratch::new("files");
1220            let nested = dir.at("a/b/c");
1221            assert_eq!(code_of(mkdir(&nested, false)), Some("ENOENT".into()));
1222            assert_eq!(mkdir(&nested, true), Ok(()));
1223            assert_eq!(mkdir(&nested, true), Ok(()));
1224            assert_eq!(code_of(mkdir(&nested, false)), Some("EEXIST".into()));
1225            assert_eq!(mkdir(&dir.at("d"), false), Ok(()));
1226        }
1227
1228        #[test]
1229        fn write_bytes_in_place() {
1230            let dir = Scratch::new("files");
1231            let file = dir.at("a");
1232            assert_eq!(
1233                code_of(write_bytes(&file, 0.0, &[1])),
1234                Some("ENOENT".into())
1235            );
1236            write_file(&file, &[1, 2, 3, 4]).unwrap();
1237            assert_eq!(write_bytes(&file, 1.0, &[9, 8]), Ok(()));
1238            assert_eq!(read_file(&file), Ok([1, 9, 8, 4].to_vec()));
1239            assert_eq!(write_bytes(&file, 6.0, &[5]), Ok(()));
1240            assert_eq!(read_file(&file), Ok([1, 9, 8, 4, 0, 0, 5].to_vec()));
1241            assert_eq!(
1242                code_of(write_bytes(&file, -1.0, &[1])),
1243                Some("ERR_OUT_OF_RANGE".into())
1244            );
1245            assert_eq!(
1246                code_of(write_bytes(&file, 0.5, &[1])),
1247                Some("ERR_OUT_OF_RANGE".into())
1248            );
1249        }
1250
1251        #[test]
1252        fn empty_write_preserves_open_and_offset_checks() {
1253            let dir = Scratch::new("files");
1254            let file = dir.at("a");
1255            assert_eq!(code_of(write_bytes(&file, 0.0, &[])), Some("ENOENT".into()));
1256            assert!(!Path::new(&file).exists());
1257            write_file(&file, &[1, 2, 3]).unwrap();
1258            for at in [0.0, 1.0, MAX_OFFSET as f64] {
1259                assert_eq!(write_bytes(&file, at, &[]), Ok(()));
1260                assert_eq!(read_file(&file), Ok([1, 2, 3].to_vec()));
1261            }
1262            for at in [-1.0, 0.5, f64::NAN, f64::INFINITY, MAX_OFFSET as f64 + 1.0] {
1263                assert_eq!(
1264                    code_of(write_bytes(&file, at, &[])),
1265                    Some("ERR_OUT_OF_RANGE".into())
1266                );
1267            }
1268        }
1269
1270        /// Reopen an anonymous pipe through procfs without a FIFO utility or FFI.
1271        /// A nonempty positional write still fails: this file is not seekable.
1272        #[cfg(target_os = "linux")]
1273        #[test]
1274        fn empty_write_does_not_seek_a_pipe() {
1275            use std::os::fd::AsRawFd;
1276
1277            let (_reader, writer) = io::pipe().unwrap();
1278            let path = format!("/proc/self/fd/{}", writer.as_raw_fd());
1279            for at in [0.0, MAX_OFFSET as f64] {
1280                assert_eq!(write_bytes(&path, at, &[]), Ok(()));
1281            }
1282            assert!(write_bytes(&path, 0.0, &[1]).is_err());
1283        }
1284
1285        /// Match the promise API the Node runner uses. Callback and sync
1286        /// readdir follow directory links; fs.promises.readdir with withFileTypes
1287        /// does not. Links to files and missing targets keep both flags false too.
1288        #[cfg(unix)]
1289        #[test]
1290        fn readdir_lists_symlinks_without_following_them() {
1291            use std::os::unix::fs::symlink;
1292
1293            let dir = Scratch::new("files");
1294            mkdir(&dir.at("root"), false).unwrap();
1295            mkdir(&dir.at("target"), false).unwrap();
1296            write_file(&dir.at("target/child"), &[]).unwrap();
1297            for (name, target) in [
1298                ("dir-link", "../target"),
1299                ("file-link", "../target/child"),
1300                ("missing-link", "../missing"),
1301            ] {
1302                symlink(target, dir.at(&format!("root/{name}"))).unwrap();
1303            }
1304            let root = dir.at("root");
1305            let expected = ["dir-link", "file-link", "missing-link"].map(|name| Dirent {
1306                name: name.into(),
1307                parent_path: normalize(&root),
1308                is_file: false,
1309                is_directory: false,
1310            });
1311            for recursive in [false, true] {
1312                assert_eq!(readdir(&root, recursive).unwrap(), expected);
1313            }
1314        }
1315
1316        /// A directory read is sorted, and a recursive one goes level by level.
1317        #[test]
1318        fn readdir_order() {
1319            let dir = Scratch::new("files");
1320            for d in ["b", "a", "a/x", "b/y", "a/x/deep"] {
1321                mkdir(&dir.at(d), false).unwrap();
1322            }
1323            for f in ["z", "a/f", "b/g", "a/x/deep/h"] {
1324                write_file(&dir.at(f), &[]).unwrap();
1325            }
1326            let root = normalize(&dir.at(""));
1327            let dirent = |parent: &str, name: &str, is_file: bool| Dirent {
1328                name: name.into(),
1329                parent_path: normalize(&format!("{root}/{parent}")),
1330                is_file,
1331                is_directory: !is_file,
1332            };
1333            assert_eq!(
1334                readdir(&dir.at(""), false).unwrap(),
1335                [
1336                    dirent("", "a", false),
1337                    dirent("", "b", false),
1338                    dirent("", "z", true)
1339                ]
1340            );
1341            assert_eq!(
1342                readdir(&dir.at(""), true).unwrap(),
1343                [
1344                    dirent("", "a", false),
1345                    dirent("", "b", false),
1346                    dirent("", "z", true),
1347                    dirent("a", "f", true),
1348                    dirent("a", "x", false),
1349                    dirent("b", "g", true),
1350                    dirent("b", "y", false),
1351                    dirent("a/x", "deep", false),
1352                    dirent("a/x/deep", "h", true),
1353                ]
1354            );
1355            assert_eq!(
1356                code_of(readdir(&dir.at("none"), false)),
1357                Some("ENOENT".into())
1358            );
1359            assert!(readdir(&dir.at("z"), false).is_err());
1360        }
1361    }
1362}