Skip to main content

nodejs/stdlib/
fs.rs

1//! Node `fs` module: synchronous file operations, the async callback forms, and
2//! the file-descriptor / directory / stream / watcher surfaces.
3//!
4//! Path operations map straight onto `std::fs`; descriptor operations
5//! (`open`/`read`/`write`/`fstat`/…) keep a thread-local table of open
6//! `std::fs::File`s keyed by a synthetic integer fd (starting at 3, after the
7//! stdio fds). Ownership / timestamp / statvfs operations that `std::fs` does not
8//! cover use `libc` directly (all targets are unix). Async callback variants run
9//! the same synchronous work then schedule the callback as a microtask, matching
10//! Node's ordering for simple scripts.
11
12use super::{arg_num, arg_str, native_tag};
13use crate::host::{invoke, with_host, JsObj};
14use fusevm::Value;
15use indexmap::IndexMap;
16use std::cell::RefCell;
17use std::collections::HashMap;
18use std::ffi::CString;
19use std::fs::File;
20use std::io::{Read, Seek, SeekFrom, Write};
21use std::os::unix::fs::{MetadataExt, PermissionsExt};
22use std::os::unix::io::AsRawFd;
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::Arc;
26use std::time::{Duration, UNIX_EPOCH};
27
28pub const METHODS: &[&str] = &[
29    // path — sync
30    "readFileSync",
31    "writeFileSync",
32    "appendFileSync",
33    "existsSync",
34    "readdirSync",
35    "mkdirSync",
36    "rmdirSync",
37    "unlinkSync",
38    "rmSync",
39    "statSync",
40    "lstatSync",
41    "statfsSync",
42    "accessSync",
43    "chmodSync",
44    "chownSync",
45    "lchownSync",
46    "copyFileSync",
47    "cpSync",
48    "linkSync",
49    "symlinkSync",
50    "readlinkSync",
51    "realpathSync",
52    "renameSync",
53    "truncateSync",
54    "utimesSync",
55    "lutimesSync",
56    "mkdtempSync",
57    "opendirSync",
58    "globSync",
59    // fd — sync
60    "openSync",
61    "closeSync",
62    "readSync",
63    "writeSync",
64    "readvSync",
65    "writevSync",
66    "fstatSync",
67    "fchmodSync",
68    "fchownSync",
69    "ftruncateSync",
70    "futimesSync",
71    "fsyncSync",
72    "fdatasyncSync",
73    // path — async (callback)
74    "readFile",
75    "writeFile",
76    "appendFile",
77    "readdir",
78    "mkdir",
79    "rmdir",
80    "rm",
81    "unlink",
82    "stat",
83    "lstat",
84    "statfs",
85    "access",
86    "chmod",
87    "chown",
88    "lchown",
89    "copyFile",
90    "cp",
91    "link",
92    "symlink",
93    "readlink",
94    "realpath",
95    "rename",
96    "truncate",
97    "utimes",
98    "lutimes",
99    "mkdtemp",
100    "opendir",
101    "glob",
102    "exists",
103    // fd — async (callback)
104    "open",
105    "close",
106    "read",
107    "write",
108    "readv",
109    "writev",
110    "fstat",
111    "fchmod",
112    "fchown",
113    "ftruncate",
114    "futimes",
115    "fsync",
116    "fdatasync",
117    // watchers + streams
118    "watchFile",
119    "unwatchFile",
120    "createReadStream",
121    "createWriteStream",
122];
123
124// ── file-descriptor table ────────────────────────────────────────────────────
125
126thread_local! {
127    static FD_TABLE: RefCell<HashMap<i32, File>> = RefCell::new(HashMap::new());
128    static NEXT_FD: RefCell<i32> = const { RefCell::new(3) };
129    static WATCHERS: RefCell<Vec<WatchEntry>> = const { RefCell::new(Vec::new()) };
130    static NEXT_WATCH_ID: RefCell<u64> = const { RefCell::new(1) };
131}
132
133struct WatchEntry {
134    // Reserved for a future StatWatcher handle; assigned but not yet read.
135    #[allow(dead_code)]
136    id: u64,
137    path: String,
138    listener: Value,
139    stop: Arc<AtomicBool>,
140}
141
142fn register_fd(file: File) -> i32 {
143    NEXT_FD.with(|n| {
144        let fd = *n.borrow();
145        *n.borrow_mut() = fd + 1;
146        FD_TABLE.with(|t| t.borrow_mut().insert(fd, file));
147        fd
148    })
149}
150
151fn with_file<R>(fd: i32, f: impl FnOnce(&File) -> R) -> Option<R> {
152    FD_TABLE.with(|t| t.borrow().get(&fd).map(f))
153}
154
155/// The file descriptor an fs call names when its first argument is a number
156/// rather than a path.
157fn fd_arg(v: Option<&Value>) -> Option<i32> {
158    match v? {
159        Value::Int(n) => Some(*n as i32),
160        Value::Float(n) if n.fract() == 0.0 => Some(*n as i32),
161        _ => None,
162    }
163}
164
165/// Everything left to read on `fd`: standard input for `0`, otherwise a
166/// descriptor `openSync` handed out. `fstat` is the call node fails on for an
167/// unknown descriptor.
168fn read_fd_to_end(fd: i32) -> Result<Vec<u8>, String> {
169    let mut out = Vec::new();
170    let r = if fd == 0 {
171        // Through Rust's shared stdin buffer, so a read here and a
172        // `readline` read of the same input never skip each other's bytes.
173        std::io::stdin().lock().read_to_end(&mut out)
174    } else {
175        match with_file(fd, |file| {
176            let mut fr: &File = file;
177            fr.read_to_end(&mut out)
178        }) {
179            Some(r) => r,
180            None => return Err("Error: EBADF: bad file descriptor, fstat".to_string()),
181        }
182    };
183    r.map(|_| out).map_err(|e| err_str("read", "", &e))
184}
185
186fn close_fd(fd: i32) -> bool {
187    FD_TABLE.with(|t| t.borrow_mut().remove(&fd).is_some())
188}
189
190// ── dispatch ─────────────────────────────────────────────────────────────────
191
192pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
193    Some(match method {
194        // ── path, sync ──
195        "readFileSync" => read_file_sync(args),
196        "writeFileSync" => write_file_impl(args),
197        "appendFileSync" => append_file_impl(args),
198        "existsSync" => Ok(Value::Bool(Path::new(&arg_str(args, 0)).exists())),
199        "readdirSync" => readdir_impl(args),
200        "mkdirSync" => mkdir_impl(args),
201        "rmdirSync" | "unlinkSync" | "rmSync" => rm_impl(method, args),
202        "statSync" => stat_impl("statSync", args, true),
203        "lstatSync" => stat_impl("lstatSync", args, false),
204        "statfsSync" => statfs_impl(args),
205        "accessSync" => access_impl(args),
206        "chmodSync" => chmod_impl(args),
207        "chownSync" => chown_impl(args, true),
208        "lchownSync" => chown_impl(args, false),
209        "copyFileSync" => copy_file_impl(args),
210        "cpSync" => cp_impl(args),
211        "linkSync" => link_impl(args),
212        "symlinkSync" => symlink_impl(args),
213        "readlinkSync" => readlink_impl(args),
214        "realpathSync" => realpath_impl(args),
215        "renameSync" => rename_impl(args),
216        "truncateSync" => truncate_impl(args),
217        "utimesSync" => utimes_impl(args, true),
218        "lutimesSync" => utimes_impl(args, false),
219        "mkdtempSync" => mkdtemp_impl(args),
220        "opendirSync" => opendir_impl(args),
221        "globSync" => glob_impl(args),
222        // ── fd, sync ──
223        "openSync" => open_impl(args),
224        "closeSync" => close_impl(args),
225        "readSync" => read_impl(args).map(|n| Value::Float(n as f64)),
226        "writeSync" => write_impl(args).map(|n| Value::Float(n as f64)),
227        "readvSync" => readv_impl(args).map(|n| Value::Float(n as f64)),
228        "writevSync" => writev_impl(args).map(|n| Value::Float(n as f64)),
229        "fstatSync" => fstat_impl(args),
230        "fchmodSync" => fchmod_impl(args),
231        "fchownSync" => fchown_impl(args),
232        "ftruncateSync" => ftruncate_impl(args),
233        "futimesSync" => futimes_impl(args),
234        "fsyncSync" => fsync_impl(args, false),
235        "fdatasyncSync" => fsync_impl(args, true),
236        // ── path, async ──
237        "readFile" => return Some(read_file_async(args)),
238        "writeFile" => async_cb(args, write_file_impl(args)),
239        "appendFile" => async_cb(args, append_file_impl(args)),
240        "readdir" => async_cb(args, readdir_impl(args)),
241        "mkdir" => async_cb(args, mkdir_impl(args)),
242        "rmdir" => async_cb(args, rm_impl("rmdir", args)),
243        "rm" => async_cb(args, rm_impl("rm", args)),
244        "unlink" => async_cb(args, rm_impl("unlink", args)),
245        "stat" => async_cb(args, stat_impl("stat", args, true)),
246        "lstat" => async_cb(args, stat_impl("lstat", args, false)),
247        "statfs" => async_cb(args, statfs_impl(args)),
248        "access" => async_cb(args, access_impl(args)),
249        "chmod" => async_cb(args, chmod_impl(args)),
250        "chown" => async_cb(args, chown_impl(args, true)),
251        "lchown" => async_cb(args, chown_impl(args, false)),
252        "copyFile" => async_cb(args, copy_file_impl(args)),
253        "cp" => async_cb(args, cp_impl(args)),
254        "link" => async_cb(args, link_impl(args)),
255        "symlink" => async_cb(args, symlink_impl(args)),
256        "readlink" => async_cb(args, readlink_impl(args)),
257        "realpath" => async_cb(args, realpath_impl(args)),
258        "rename" => async_cb(args, rename_impl(args)),
259        "truncate" => async_cb(args, truncate_impl(args)),
260        "utimes" => async_cb(args, utimes_impl(args, true)),
261        "lutimes" => async_cb(args, utimes_impl(args, false)),
262        "mkdtemp" => async_cb(args, mkdtemp_impl(args)),
263        "opendir" => async_cb(args, opendir_impl(args)),
264        "glob" => async_cb(args, glob_impl(args)),
265        "exists" => exists_async(args),
266        // ── fd, async ──
267        "open" => async_cb(args, open_impl(args)),
268        "close" => async_cb(args, close_impl(args)),
269        "read" => return Some(read_write_async(args, read_impl(args))),
270        "write" => return Some(read_write_async(args, write_impl(args))),
271        "readv" => async_cb(args, readv_impl(args).map(|n| Value::Float(n as f64))),
272        "writev" => async_cb(args, writev_impl(args).map(|n| Value::Float(n as f64))),
273        "fstat" => async_cb(args, fstat_impl(args)),
274        "fchmod" => async_cb(args, fchmod_impl(args)),
275        "fchown" => async_cb(args, fchown_impl(args)),
276        "ftruncate" => async_cb(args, ftruncate_impl(args)),
277        "futimes" => async_cb(args, futimes_impl(args)),
278        "fsync" => async_cb(args, fsync_impl(args, false)),
279        "fdatasync" => async_cb(args, fsync_impl(args, true)),
280        // ── watchers + streams ──
281        "watchFile" => watch_file(args),
282        "unwatchFile" => unwatch_file(args),
283        "createReadStream" => create_read_stream(args),
284        "createWriteStream" => create_write_stream(args),
285        _ => return None,
286    })
287}
288
289// ── async callback plumbing ──────────────────────────────────────────────────
290
291/// Schedule the trailing callback as a microtask: `cb(null, value)` on success,
292/// `cb(err)` (an error string, matching the existing `readFile`/`writeFile`
293/// callbacks) on failure. Always returns `undefined` (the method's own result).
294fn async_cb(args: &[Value], result: Result<Value, String>) -> Result<Value, String> {
295    let Some(cb) = args.last().cloned().filter(is_fn) else {
296        return Ok(Value::Undef);
297    };
298    match result {
299        Ok(v) => with_host(|h| {
300            let n = h.null();
301            h.queue_micro(cb, vec![n, v]);
302        }),
303        // An error-first callback is handed an ERROR OBJECT, not the message:
304        // `fs.stat(missing, (err) => …)` gives an `err` carrying `code`,
305        // `syscall`, `path` and `errno`, and `err.code === 'ENOENT'` is the most
306        // common check in node code. A bare string made every one of those
307        // `undefined` — and `err.message` too, so even printing it said
308        // `undefined`. The promise forms already built the object.
309        Err(e) => with_host(|h| {
310            let ev = crate::builtins::synth_error(h, &e);
311            h.queue_micro(cb, vec![ev]);
312        }),
313    }
314    Ok(Value::Undef)
315}
316
317/// `read`/`write` callbacks carry a third argument: `cb(err, count, buffer)`.
318fn read_write_async(args: &[Value], result: Result<usize, String>) -> Result<Value, String> {
319    let Some(cb) = args.last().cloned().filter(is_fn) else {
320        return Ok(Value::Undef);
321    };
322    let buffer = args.get(1).cloned().unwrap_or(Value::Undef);
323    match result {
324        Ok(n) => with_host(|h| {
325            let nul = h.null();
326            h.queue_micro(cb, vec![nul, Value::Float(n as f64), buffer]);
327        }),
328        Err(e) => with_host(|h| {
329            let ev = crate::builtins::synth_error(h, &e);
330            h.queue_micro(cb, vec![ev]);
331        }),
332    }
333    Ok(Value::Undef)
334}
335
336fn is_fn(v: &Value) -> bool {
337    with_host(|h| crate::host::is_callable(h, v))
338}
339
340// ── read/write/append ────────────────────────────────────────────────────────
341
342fn read_file_sync(args: &[Value]) -> Result<Value, String> {
343    let enc = encoding_arg(args, 1);
344    // A NUMBER is a file descriptor, read from its current position to the
345    // end: `readFileSync(0, 'utf8')` is how a script slurps its stdin. The
346    // number was stringified and opened as a path, so that was `ENOENT ... '0'`.
347    if let Some(fd) = fd_arg(args.first()) {
348        let bytes = read_fd_to_end(fd)?;
349        return Ok(match enc {
350            Some(e) => with_host(|h| h.new_str(super::buffer::encode_bytes(&bytes, &e))),
351            None => super::buffer::from_bytes(&bytes),
352        });
353    }
354    let path = arg_str(args, 0);
355
356    match std::fs::read(&path) {
357        // The encoding names the REPRESENTATION to return, not just "decode as
358        // text": `readFileSync(p, 'hex')` gives the hex digits of the file, and
359        // `'base64'` its base64. Every encoding used to come back as the raw
360        // UTF-8 text.
361        Ok(bytes) => Ok(match enc {
362            Some(e) => with_host(|h| h.new_str(super::buffer::encode_bytes(&bytes, &e))),
363            None => super::buffer::from_bytes(&bytes),
364        }),
365        // A DIRECTORY opens fine and fails at the `read`, which is the syscall
366        // node names — and it names no path there, because the descriptor no
367        // longer carries one. This reported `open '<dir>'`, an operation that
368        // had in fact succeeded.
369        // The raw errno rather than `ErrorKind::IsADirectory`, which is stable
370        // only since 1.83 and this crate's MSRV is 1.80.
371        Err(e) if e.raw_os_error() == Some(libc::EISDIR) => Err(err_str("read", "", &e)),
372        Err(e) => Err(err_str("readFileSync", &path, &e)),
373    }
374}
375
376/// The bytes a `writeFileSync`/`appendFileSync` should put on disk.
377///
378/// When an encoding is given, the DATA STRING is in that encoding and has to be
379/// decoded first — `writeFileSync(p, '68656c6c6f', 'hex')` writes `hello`, five
380/// bytes, not the ten digits. The encoding argument was ignored, so the digits
381/// themselves were written. A Buffer argument carries its own bytes and is
382/// unaffected.
383fn encoded_bytes(args: &[Value]) -> Vec<u8> {
384    let v = args.get(1).cloned().unwrap_or(Value::Undef);
385    // Any byte VIEW writes its bytes; only a string goes through the encoding.
386    // The Buffer case alone was handled, so `writeFileSync(p, new Uint8Array(…))`
387    // wrote the text `[object Object]`.
388    if let Some(bytes) = super::buffer::view_bytes(&v) {
389        return bytes;
390    }
391    let text = with_host(|h| h.str_of(&v));
392    match encoding_arg(args, 2) {
393        Some(e) => super::buffer::decode_str(&text, &e),
394        None => text.into_bytes(),
395    }
396}
397
398fn write_file_impl(args: &[Value]) -> Result<Value, String> {
399    let path = arg_str(args, 0);
400    let data = encoded_bytes(args);
401    match std::fs::write(&path, data) {
402        Ok(_) => Ok(Value::Undef),
403        Err(e) => Err(err_str("writeFile", &path, &e)),
404    }
405}
406
407fn append_file_impl(args: &[Value]) -> Result<Value, String> {
408    let path = arg_str(args, 0);
409    let data = encoded_bytes(args);
410    let r = std::fs::OpenOptions::new()
411        .create(true)
412        .append(true)
413        .open(&path)
414        .and_then(|mut f| f.write_all(&data));
415    match r {
416        Ok(_) => Ok(Value::Undef),
417        Err(e) => Err(err_str("appendFile", &path, &e)),
418    }
419}
420
421fn read_file_async(args: &[Value]) -> Result<Value, String> {
422    let path = arg_str(args, 0);
423    let Some(cb) = args.last().cloned().filter(is_fn) else {
424        return Ok(Value::Undef);
425    };
426    let enc = if args.len() >= 3 {
427        encoding_arg(args, 1)
428    } else {
429        None
430    };
431    let (err, data) = match std::fs::read(&path) {
432        Ok(bytes) => (
433            with_host(|h| h.null()),
434            // Same representation rule as the sync read: the encoding names
435            // what to return, so `readFile(p, 'hex', cb)` hands the callback
436            // hex digits.
437            match enc {
438                Some(e) => with_host(|h| h.new_str(super::buffer::encode_bytes(&bytes, &e))),
439                None => super::buffer::from_bytes(&bytes),
440            },
441        ),
442        Err(e) => (
443            // An ERROR OBJECT, as every error-first callback receives — see
444            // `async_cb`; this path built its own and handed over a string.
445            with_host(|h| {
446                // The ASYNC form names the path where the sync one does not:
447                // node's `fs.readFile(dir, cb)` reports `read '<dir>'` and
448                // `fs.readFileSync(dir)` reports a bare `read`. Measured, not
449                // assumed — the two really do differ.
450                let msg = if e.raw_os_error() == Some(libc::EISDIR) {
451                    err_str("read", &path, &e)
452                } else {
453                    err_str("readFile", &path, &e)
454                };
455                crate::builtins::synth_error(h, &msg)
456            }),
457            Value::Undef,
458        ),
459    };
460    with_host(|h| h.queue_micro(cb, vec![err, data]));
461    Ok(Value::Undef)
462}
463
464fn exists_async(args: &[Value]) -> Result<Value, String> {
465    let path = arg_str(args, 0);
466    let Some(cb) = args.last().cloned().filter(is_fn) else {
467        return Ok(Value::Undef);
468    };
469    let ex = Path::new(&path).exists();
470    with_host(|h| h.queue_micro(cb, vec![Value::Bool(ex)]));
471    Ok(Value::Undef)
472}
473
474// ── directories ──────────────────────────────────────────────────────────────
475
476fn mkdir_impl(args: &[Value]) -> Result<Value, String> {
477    let path = arg_str(args, 0);
478    let recursive = opt_flag(args, "recursive");
479    let r = if recursive {
480        std::fs::create_dir_all(&path)
481    } else {
482        std::fs::create_dir(&path)
483    };
484    match r {
485        Ok(_) => Ok(Value::Undef),
486        Err(e) => Err(err_str("mkdir", &path, &e)),
487    }
488}
489
490fn rm_impl(op: &str, args: &[Value]) -> Result<Value, String> {
491    let path = arg_str(args, 0);
492    let p = Path::new(&path);
493    let force = opt_flag(args, "force");
494    let r = if p.is_dir() {
495        if opt_flag(args, "recursive") {
496            std::fs::remove_dir_all(p)
497        } else {
498            std::fs::remove_dir(p)
499        }
500    } else {
501        std::fs::remove_file(p)
502    };
503    match r {
504        Ok(_) => Ok(Value::Undef),
505        Err(e) if force && e.kind() == std::io::ErrorKind::NotFound => Ok(Value::Undef),
506        Err(e) => Err(err_str(op, &path, &e)),
507    }
508}
509
510fn readdir_impl(args: &[Value]) -> Result<Value, String> {
511    let path = arg_str(args, 0);
512    let file_types = opt_flag(args, "withFileTypes");
513    let recursive = opt_flag(args, "recursive");
514    let mut names: Vec<(String, String, std::fs::FileType)> = Vec::new();
515    collect_dir(Path::new(&path), &path, "", recursive, &mut names)
516        .map_err(|e| err_str("readdir", &path, &e))?;
517    names.sort_by(|a, b| a.0.cmp(&b.0));
518    Ok(with_host(|h| {
519        let items: Vec<Value> = names
520            .into_iter()
521            .map(|(rel, parent, ft)| {
522                if file_types {
523                    let base = rel.rsplit('/').next().unwrap_or(&rel).to_string();
524                    build_dirent(h, base, &parent, ft)
525                } else {
526                    h.new_str(rel)
527                }
528            })
529            .collect();
530        h.new_array(items)
531    }))
532}
533
534/// Collect directory entries. `rel_prefix` is the path (relative to the root)
535/// under which `dir`'s children are listed; `parent` is the absolute-ish parent
536/// path recorded on each `Dirent`.
537fn collect_dir(
538    dir: &Path,
539    parent: &str,
540    rel_prefix: &str,
541    recursive: bool,
542    out: &mut Vec<(String, String, std::fs::FileType)>,
543) -> std::io::Result<()> {
544    for e in std::fs::read_dir(dir)? {
545        let e = e?;
546        let name = e.file_name().to_string_lossy().into_owned();
547        let rel = if rel_prefix.is_empty() {
548            name.clone()
549        } else {
550            format!("{rel_prefix}/{name}")
551        };
552        let ft = e.file_type()?;
553        out.push((rel.clone(), parent.to_string(), ft));
554        if recursive && ft.is_dir() {
555            let sub = e.path();
556            let sub_parent = sub.to_string_lossy().into_owned();
557            collect_dir(&sub, &sub_parent, &rel, recursive, out)?;
558        }
559    }
560    Ok(())
561}
562
563fn opendir_impl(args: &[Value]) -> Result<Value, String> {
564    let path = arg_str(args, 0);
565    let rd = std::fs::read_dir(&path).map_err(|e| err_str("opendir", &path, &e))?;
566    let mut entries: Vec<(String, std::fs::FileType)> = rd
567        .filter_map(|e| e.ok())
568        .filter_map(|e| {
569            e.file_type()
570                .ok()
571                .map(|ft| (e.file_name().to_string_lossy().into_owned(), ft))
572        })
573        .collect();
574    entries.sort_by(|a, b| a.0.cmp(&b.0));
575    Ok(with_host(|h| {
576        let dirents: Vec<Value> = entries
577            .into_iter()
578            .map(|(name, ft)| build_dirent(h, name, &path, ft))
579            .collect();
580        let arr = h.new_array(dirents);
581        let mut m = IndexMap::new();
582        m.insert("@@native".into(), h.new_str("Dir"));
583        m.insert("path".into(), h.new_str(path.clone()));
584        m.insert("@@entries".into(), arr);
585        m.insert("@@pos".into(), Value::Float(0.0));
586        h.new_object(m)
587    }))
588}
589
590// ── ownership / permissions / timestamps ─────────────────────────────────────
591
592fn access_impl(args: &[Value]) -> Result<Value, String> {
593    let path = arg_str(args, 0);
594    match std::fs::metadata(&path) {
595        Ok(_) => Ok(Value::Undef),
596        Err(e) => Err(err_str("access", &path, &e)),
597    }
598}
599
600fn chmod_impl(args: &[Value]) -> Result<Value, String> {
601    let path = arg_str(args, 0);
602    let mode = arg_num(args, 1) as u32;
603    match std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)) {
604        Ok(_) => Ok(Value::Undef),
605        Err(e) => Err(err_str("chmod", &path, &e)),
606    }
607}
608
609fn chown_impl(args: &[Value], follow: bool) -> Result<Value, String> {
610    let path = arg_str(args, 0);
611    let uid = arg_num(args, 1) as libc::uid_t;
612    let gid = arg_num(args, 2) as libc::gid_t;
613    let c = cpath(&path, "chown")?;
614    let rc = unsafe {
615        if follow {
616            libc::chown(c.as_ptr(), uid, gid)
617        } else {
618            libc::lchown(c.as_ptr(), uid, gid)
619        }
620    };
621    ok_or_errno(rc, "chown", &path)
622}
623
624fn utimes_impl(args: &[Value], follow: bool) -> Result<Value, String> {
625    let path = arg_str(args, 0);
626    let times = [
627        to_timeval(time_secs(args, 1)),
628        to_timeval(time_secs(args, 2)),
629    ];
630    let c = cpath(&path, "utimes")?;
631    let rc = unsafe {
632        if follow {
633            libc::utimes(c.as_ptr(), times.as_ptr())
634        } else {
635            libc::lutimes(c.as_ptr(), times.as_ptr())
636        }
637    };
638    ok_or_errno(rc, "utimes", &path)
639}
640
641// ── copy / link / rename / truncate ──────────────────────────────────────────
642
643const COPYFILE_EXCL: u32 = 1;
644
645fn copy_file_impl(args: &[Value]) -> Result<Value, String> {
646    let src = arg_str(args, 0);
647    let dest = arg_str(args, 1);
648    let mode = arg_num(args, 2);
649    if !mode.is_nan() && (mode as u32) & COPYFILE_EXCL != 0 && Path::new(&dest).exists() {
650        return Err(format!(
651            "Error: EEXIST: file already exists, copyfile '{src}' -> '{dest}'"
652        ));
653    }
654    match std::fs::copy(&src, &dest) {
655        Ok(_) => Ok(Value::Undef),
656        Err(e) => Err(err_str2("copyFile", &src, &dest, &e)),
657    }
658}
659
660fn cp_impl(args: &[Value]) -> Result<Value, String> {
661    let src = arg_str(args, 0);
662    let dest = arg_str(args, 1);
663    let recursive = opt_flag(args, "recursive");
664    let r = if recursive {
665        cp_recursive(Path::new(&src), Path::new(&dest))
666    } else {
667        std::fs::copy(&src, &dest).map(|_| ())
668    };
669    match r {
670        Ok(_) => Ok(Value::Undef),
671        Err(e) => Err(err_str("cp", &src, &e)),
672    }
673}
674
675fn cp_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
676    if src.is_dir() {
677        std::fs::create_dir_all(dest)?;
678        for e in std::fs::read_dir(src)? {
679            let e = e?;
680            cp_recursive(&e.path(), &dest.join(e.file_name()))?;
681        }
682        Ok(())
683    } else {
684        if let Some(parent) = dest.parent() {
685            std::fs::create_dir_all(parent).ok();
686        }
687        std::fs::copy(src, dest).map(|_| ())
688    }
689}
690
691fn link_impl(args: &[Value]) -> Result<Value, String> {
692    let existing = arg_str(args, 0);
693    let new = arg_str(args, 1);
694    match std::fs::hard_link(&existing, &new) {
695        Ok(_) => Ok(Value::Undef),
696        Err(e) => Err(err_str("link", &existing, &e)),
697    }
698}
699
700fn symlink_impl(args: &[Value]) -> Result<Value, String> {
701    let target = arg_str(args, 0);
702    let path = arg_str(args, 1);
703    match std::os::unix::fs::symlink(&target, &path) {
704        Ok(_) => Ok(Value::Undef),
705        Err(e) => Err(err_str("symlink", &path, &e)),
706    }
707}
708
709fn readlink_impl(args: &[Value]) -> Result<Value, String> {
710    let path = arg_str(args, 0);
711    match std::fs::read_link(&path) {
712        Ok(p) => Ok(with_host(|h| h.new_str(p.to_string_lossy().into_owned()))),
713        Err(e) => Err(err_str("readlink", &path, &e)),
714    }
715}
716
717fn realpath_impl(args: &[Value]) -> Result<Value, String> {
718    let path = arg_str(args, 0);
719    match std::fs::canonicalize(&path) {
720        Ok(p) => Ok(with_host(|h| h.new_str(p.to_string_lossy().into_owned()))),
721        // The message names the path as far as it was RESOLVED before the walk
722        // failed — on macOS `/var` is a symlink, so node reports
723        // `/private/var/…` where the caller wrote `/var/…`. Reporting the
724        // argument verbatim names a path the failing `lstat` never saw.
725        Err(e) => Err(err_str("realpath", &resolved_prefix(&path), &e)),
726    }
727}
728
729/// `path` with its deepest EXISTING ancestor canonicalized and the rest of the
730/// components appended — what `realpath(3)` has resolved at the point it gives
731/// up.
732fn resolved_prefix(path: &str) -> String {
733    let p = Path::new(path);
734    let mut rest: Vec<&std::ffi::OsStr> = Vec::new();
735    let mut cur = p;
736    loop {
737        if let Ok(base) = std::fs::canonicalize(cur) {
738            let mut out = base;
739            for part in rest.iter().rev() {
740                out.push(part);
741            }
742            return out.to_string_lossy().into_owned();
743        }
744        match (cur.file_name(), cur.parent()) {
745            (Some(name), Some(parent)) if !parent.as_os_str().is_empty() => {
746                rest.push(name);
747                cur = parent;
748            }
749            _ => return path.to_string(),
750        }
751    }
752}
753
754fn rename_impl(args: &[Value]) -> Result<Value, String> {
755    let from = arg_str(args, 0);
756    let to = arg_str(args, 1);
757    match std::fs::rename(&from, &to) {
758        Ok(_) => Ok(Value::Undef),
759        Err(e) => Err(err_str2("rename", &from, &to, &e)),
760    }
761}
762
763fn truncate_impl(args: &[Value]) -> Result<Value, String> {
764    let path = arg_str(args, 0);
765    let len = arg_num(args, 1);
766    let len = if len.is_nan() { 0 } else { len as u64 };
767    let r = std::fs::OpenOptions::new()
768        .write(true)
769        .open(&path)
770        .and_then(|f| f.set_len(len));
771    match r {
772        Ok(_) => Ok(Value::Undef),
773        // The failure is in the `open` this does first, which is the syscall
774        // node names — `truncate` only appears once a descriptor exists.
775        Err(e) => Err(err_str("open", &path, &e)),
776    }
777}
778
779fn mkdtemp_impl(args: &[Value]) -> Result<Value, String> {
780    let prefix = arg_str(args, 0);
781    for _ in 0..64 {
782        let candidate = format!("{prefix}{}", random_suffix());
783        match std::fs::create_dir(&candidate) {
784            Ok(_) => return Ok(with_host(|h| h.new_str(candidate))),
785            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
786            Err(e) => return Err(err_str("mkdtemp", &prefix, &e)),
787        }
788    }
789    Err(format!(
790        "Error: EEXIST: file already exists, mkdtemp '{prefix}'"
791    ))
792}
793
794/// Six random `[0-9A-Za-z]` characters — Node's `mkdtemp` suffix alphabet.
795fn random_suffix() -> String {
796    const CHARS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
797    let mut raw = [0u8; 6];
798    if getrandom::getrandom(&mut raw).is_err() {
799        // Fall back to a clock-derived seed; uniqueness is retried by the caller.
800        let nanos = std::time::SystemTime::now()
801            .duration_since(UNIX_EPOCH)
802            .map(|d| d.subsec_nanos())
803            .unwrap_or(0);
804        raw = nanos
805            .to_le_bytes()
806            .iter()
807            .cycle()
808            .take(6)
809            .copied()
810            .collect::<Vec<_>>()
811            .try_into()
812            .unwrap();
813    }
814    raw.iter()
815        .map(|b| CHARS[(*b as usize) % 62] as char)
816        .collect()
817}
818
819// ── file descriptors ─────────────────────────────────────────────────────────
820
821fn open_impl(args: &[Value]) -> Result<Value, String> {
822    let path = arg_str(args, 0);
823    let flags = match args.get(1) {
824        Some(v) if !matches!(v, Value::Undef) && !is_fn(v) => arg_str(args, 1),
825        _ => "r".to_string(),
826    };
827    match open_options(&flags).open(&path) {
828        Ok(f) => Ok(Value::Float(register_fd(f) as f64)),
829        Err(e) => Err(err_str("open", &path, &e)),
830    }
831}
832
833fn open_options(flags: &str) -> std::fs::OpenOptions {
834    let mut o = std::fs::OpenOptions::new();
835    match flags {
836        "r" | "rs" | "sr" => {
837            o.read(true);
838        }
839        "r+" | "rs+" | "sr+" => {
840            o.read(true).write(true);
841        }
842        "w" => {
843            o.write(true).create(true).truncate(true);
844        }
845        "wx" | "xw" => {
846            o.write(true).create_new(true);
847        }
848        "w+" => {
849            o.read(true).write(true).create(true).truncate(true);
850        }
851        "wx+" | "xw+" => {
852            o.read(true).write(true).create_new(true);
853        }
854        "a" => {
855            o.append(true).create(true);
856        }
857        "ax" | "xa" => {
858            o.append(true).create_new(true);
859        }
860        "a+" => {
861            o.read(true).append(true).create(true);
862        }
863        "ax+" | "xa+" => {
864            o.read(true).append(true).create_new(true);
865        }
866        _ => {
867            o.read(true);
868        }
869    }
870    o
871}
872
873fn close_impl(args: &[Value]) -> Result<Value, String> {
874    let fd = arg_num(args, 0) as i32;
875    if close_fd(fd) {
876        Ok(Value::Undef)
877    } else {
878        Err("Error: EBADF: bad file descriptor, close".to_string())
879    }
880}
881
882fn read_impl(args: &[Value]) -> Result<usize, String> {
883    let fd = arg_num(args, 0) as i32;
884    let buffer = args.get(1).cloned().unwrap_or(Value::Undef);
885    let cap = buf_len(&buffer);
886    let offset = num_or(args, 2, 0.0) as usize;
887    let length = num_or(args, 3, (cap.saturating_sub(offset)) as f64) as usize;
888    let position = position_arg(args, 4);
889    // Descriptor 0 is standard input, which `openSync` never registers: a
890    // `readSync(0, buf)` loop is how a script reads its input in pieces.
891    if fd == 0 {
892        let mut buf = vec![0u8; length];
893        return match std::io::stdin().lock().read(&mut buf) {
894            Ok(n) => Ok(write_into_buffer(&buffer, offset, &buf[..n])),
895            Err(e) => Err(err_str("read", "", &e)),
896        };
897    }
898    let n = with_file(fd, |file| {
899        let mut fr: &File = file;
900        if let Some(pos) = position {
901            fr.seek(SeekFrom::Start(pos)).ok();
902        }
903        let mut buf = vec![0u8; length];
904        fr.read(&mut buf).map(|n| {
905            buf.truncate(n);
906            buf
907        })
908    });
909    match n {
910        Some(Ok(data)) => Ok(write_into_buffer(&buffer, offset, &data)),
911        Some(Err(e)) => Err(err_str("read", "", &e)),
912        None => Err("Error: EBADF: bad file descriptor, read".to_string()),
913    }
914}
915
916fn write_impl(args: &[Value]) -> Result<usize, String> {
917    let fd = arg_num(args, 0) as i32;
918    let src = args.get(1).cloned().unwrap_or(Value::Undef);
919    // The positional form differs for a byte VIEW and a string, so the two are
920    // distinguished by what the argument IS, not by whether it is a Buffer.
921    let view = super::buffer::view_bytes(&src);
922    // Buffer form: write(fd, buffer, offset, length, position)
923    // String form: write(fd, string, position, encoding)
924    let (data, position) = if let Some(all) = view {
925        let offset = num_or(args, 2, 0.0) as usize;
926        let length = num_or(args, 3, (all.len().saturating_sub(offset)) as f64) as usize;
927        let end = (offset + length).min(all.len());
928        (
929            all[offset.min(all.len())..end].to_vec(),
930            position_arg(args, 4),
931        )
932    } else {
933        (
934            with_host(|h| h.str_of(&src)).into_bytes(),
935            position_arg(args, 2),
936        )
937    };
938    let r = with_file(fd, |file| {
939        let mut fr: &File = file;
940        if let Some(pos) = position {
941            fr.seek(SeekFrom::Start(pos)).ok();
942        }
943        fr.write(&data)
944    });
945    match r {
946        Some(Ok(n)) => Ok(n),
947        Some(Err(e)) => Err(err_str("write", "", &e)),
948        None => Err("Error: EBADF: bad file descriptor, write".to_string()),
949    }
950}
951
952fn readv_impl(args: &[Value]) -> Result<usize, String> {
953    let fd = arg_num(args, 0) as i32;
954    let buffers = array_items(args.get(1));
955    let position = position_arg(args, 2);
956    let total = with_file(fd, |file| {
957        let mut fr: &File = file;
958        if let Some(pos) = position {
959            fr.seek(SeekFrom::Start(pos)).ok();
960        }
961        let mut chunks: Vec<(Value, Vec<u8>)> = Vec::new();
962        for b in &buffers {
963            let cap = buf_len(b);
964            let mut buf = vec![0u8; cap];
965            match fr.read(&mut buf) {
966                Ok(0) => break,
967                Ok(n) => {
968                    buf.truncate(n);
969                    chunks.push((b.clone(), buf));
970                }
971                Err(e) => return Err(e),
972            }
973        }
974        Ok(chunks)
975    });
976    match total {
977        Some(Ok(chunks)) => Ok(chunks.iter().map(|(b, d)| write_into_buffer(b, 0, d)).sum()),
978        Some(Err(e)) => Err(err_str("readv", "", &e)),
979        None => Err("Error: EBADF: bad file descriptor, readv".to_string()),
980    }
981}
982
983fn writev_impl(args: &[Value]) -> Result<usize, String> {
984    let fd = arg_num(args, 0) as i32;
985    let buffers = array_items(args.get(1));
986    let position = position_arg(args, 2);
987    let mut data = Vec::new();
988    for b in &buffers {
989        data.extend(buf_bytes(b));
990    }
991    let r = with_file(fd, |file| {
992        let mut fr: &File = file;
993        if let Some(pos) = position {
994            fr.seek(SeekFrom::Start(pos)).ok();
995        }
996        fr.write(&data)
997    });
998    match r {
999        Some(Ok(n)) => Ok(n),
1000        Some(Err(e)) => Err(err_str("writev", "", &e)),
1001        None => Err("Error: EBADF: bad file descriptor, writev".to_string()),
1002    }
1003}
1004
1005fn fstat_impl(args: &[Value]) -> Result<Value, String> {
1006    let fd = arg_num(args, 0) as i32;
1007    let md = with_file(fd, |file| file.metadata());
1008    match md {
1009        Some(Ok(md)) => Ok(with_host(|h| build_stats(h, &md))),
1010        Some(Err(e)) => Err(err_str("fstat", "", &e)),
1011        None => Err("Error: EBADF: bad file descriptor, fstat".to_string()),
1012    }
1013}
1014
1015fn fchmod_impl(args: &[Value]) -> Result<Value, String> {
1016    let fd = arg_num(args, 0) as i32;
1017    let mode = arg_num(args, 1) as libc::mode_t;
1018    let rc = with_file(fd, |file| unsafe { libc::fchmod(file.as_raw_fd(), mode) });
1019    fd_result(rc, "fchmod")
1020}
1021
1022fn fchown_impl(args: &[Value]) -> Result<Value, String> {
1023    let fd = arg_num(args, 0) as i32;
1024    let uid = arg_num(args, 1) as libc::uid_t;
1025    let gid = arg_num(args, 2) as libc::gid_t;
1026    let rc = with_file(fd, |file| unsafe {
1027        libc::fchown(file.as_raw_fd(), uid, gid)
1028    });
1029    fd_result(rc, "fchown")
1030}
1031
1032fn futimes_impl(args: &[Value]) -> Result<Value, String> {
1033    let fd = arg_num(args, 0) as i32;
1034    let times = [
1035        to_timeval(time_secs(args, 1)),
1036        to_timeval(time_secs(args, 2)),
1037    ];
1038    let rc = with_file(fd, |file| unsafe {
1039        libc::futimes(file.as_raw_fd(), times.as_ptr())
1040    });
1041    fd_result(rc, "futimes")
1042}
1043
1044fn ftruncate_impl(args: &[Value]) -> Result<Value, String> {
1045    let fd = arg_num(args, 0) as i32;
1046    let len = arg_num(args, 1);
1047    let len = if len.is_nan() { 0 } else { len as u64 };
1048    match with_file(fd, |file| file.set_len(len)) {
1049        Some(Ok(_)) => Ok(Value::Undef),
1050        Some(Err(e)) => Err(err_str("ftruncate", "", &e)),
1051        None => Err("Error: EBADF: bad file descriptor, ftruncate".to_string()),
1052    }
1053}
1054
1055fn fsync_impl(args: &[Value], data_only: bool) -> Result<Value, String> {
1056    let fd = arg_num(args, 0) as i32;
1057    let op = if data_only { "fdatasync" } else { "fsync" };
1058    let r = with_file(fd, |file| {
1059        if data_only {
1060            file.sync_data()
1061        } else {
1062            file.sync_all()
1063        }
1064    });
1065    match r {
1066        Some(Ok(_)) => Ok(Value::Undef),
1067        Some(Err(e)) => Err(err_str(op, "", &e)),
1068        None => Err(format!("Error: EBADF: bad file descriptor, {op}")),
1069    }
1070}
1071
1072/// Translate the return of a `with_file` libc call: `None` = bad fd, `Some(0)` =
1073/// success, `Some(_)` = the C failure whose reason is in `errno`.
1074fn fd_result(rc: Option<libc::c_int>, op: &str) -> Result<Value, String> {
1075    match rc {
1076        Some(0) => Ok(Value::Undef),
1077        Some(_) => Err(err_str(op, "", &std::io::Error::last_os_error())),
1078        None => Err(format!("Error: EBADF: bad file descriptor, {op}")),
1079    }
1080}
1081
1082// ── stat / statfs ────────────────────────────────────────────────────────────
1083
1084fn stat_impl(op: &str, args: &[Value], follow: bool) -> Result<Value, String> {
1085    let path = arg_str(args, 0);
1086    let md = if follow {
1087        std::fs::metadata(&path)
1088    } else {
1089        std::fs::symlink_metadata(&path)
1090    };
1091    match md {
1092        Ok(md) => Ok(with_host(|h| build_stats(h, &md))),
1093        Err(e) => Err(err_str(op, &path, &e)),
1094    }
1095}
1096
1097fn statfs_impl(args: &[Value]) -> Result<Value, String> {
1098    let path = arg_str(args, 0);
1099    let c = cpath(&path, "statfs")?;
1100    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
1101    if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
1102        return Err(err_str("statfs", &path, &std::io::Error::last_os_error()));
1103    }
1104    Ok(with_host(|h| {
1105        let mut m = IndexMap::new();
1106        m.insert("type".into(), Value::Float(st.f_fsid as f64));
1107        m.insert("bsize".into(), Value::Float(st.f_bsize as f64));
1108        m.insert("blocks".into(), Value::Float(st.f_blocks as f64));
1109        m.insert("bfree".into(), Value::Float(st.f_bfree as f64));
1110        m.insert("bavail".into(), Value::Float(st.f_bavail as f64));
1111        m.insert("files".into(), Value::Float(st.f_files as f64));
1112        m.insert("ffree".into(), Value::Float(st.f_ffree as f64));
1113        h.new_object(m)
1114    }))
1115}
1116
1117/// Build a full `fs.Stats` object from `Metadata` (unix fields via `MetadataExt`).
1118fn build_stats(h: &mut crate::host::JsHost, md: &std::fs::Metadata) -> Value {
1119    let ns = |s: i64, n: i64| s as f64 * 1000.0 + n as f64 / 1_000_000.0;
1120    let atime_ms = ns(md.atime(), md.atime_nsec());
1121    let mtime_ms = ns(md.mtime(), md.mtime_nsec());
1122    let ctime_ms = ns(md.ctime(), md.ctime_nsec());
1123    let birth_ms = md
1124        .created()
1125        .ok()
1126        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
1127        .map(|d| d.as_secs_f64() * 1000.0)
1128        .unwrap_or(mtime_ms);
1129    let ft = md.file_type();
1130    let date = |h: &mut crate::host::JsHost, ms: f64| {
1131        let mut d = IndexMap::new();
1132        d.insert("@@native".into(), h.new_str("Date"));
1133        d.insert("@@ms".into(), Value::Float(ms));
1134        h.new_object(d)
1135    };
1136    let mut m = IndexMap::new();
1137    m.insert("@@native".into(), h.new_str("Stats"));
1138    m.insert("@@isFile".into(), Value::Bool(md.is_file()));
1139    m.insert("@@isDir".into(), Value::Bool(md.is_dir()));
1140    m.insert("@@isSymlink".into(), Value::Bool(ft.is_symlink()));
1141    m.insert("dev".into(), Value::Float(md.dev() as f64));
1142    m.insert("mode".into(), Value::Float(md.mode() as f64));
1143    m.insert("nlink".into(), Value::Float(md.nlink() as f64));
1144    m.insert("uid".into(), Value::Float(md.uid() as f64));
1145    m.insert("gid".into(), Value::Float(md.gid() as f64));
1146    m.insert("rdev".into(), Value::Float(md.rdev() as f64));
1147    m.insert("blksize".into(), Value::Float(md.blksize() as f64));
1148    m.insert("ino".into(), Value::Float(md.ino() as f64));
1149    m.insert("size".into(), Value::Float(md.len() as f64));
1150    m.insert("blocks".into(), Value::Float(md.blocks() as f64));
1151    m.insert("atimeMs".into(), Value::Float(atime_ms));
1152    m.insert("mtimeMs".into(), Value::Float(mtime_ms));
1153    m.insert("ctimeMs".into(), Value::Float(ctime_ms));
1154    m.insert("birthtimeMs".into(), Value::Float(birth_ms));
1155    let atime = date(h, atime_ms);
1156    m.insert("atime".into(), atime);
1157    let mtime = date(h, mtime_ms);
1158    m.insert("mtime".into(), mtime);
1159    let ctime = date(h, ctime_ms);
1160    m.insert("ctime".into(), ctime);
1161    let birthtime = date(h, birth_ms);
1162    m.insert("birthtime".into(), birthtime);
1163    let obj = h.new_object(m);
1164    // The four Date fields are ACCESSORS on `Stats.prototype` in node, so they
1165    // are not own enumerable keys: `Object.keys(statSync(f))` is the fourteen
1166    // numeric fields and nothing else. They are stored here rather than
1167    // computed, so the equivalent is to hide them — a `JSON.stringify(stat)`
1168    // or an `{...stat}` carried four extra entries node does not.
1169    for k in ["atime", "mtime", "ctime", "birthtime"] {
1170        h.hide_prop(&obj, k);
1171    }
1172    obj
1173}
1174
1175// ── watchFile / unwatchFile ──────────────────────────────────────────────────
1176
1177fn watch_file(args: &[Value]) -> Result<Value, String> {
1178    let path = arg_str(args, 0);
1179    let Some(listener) = args.last().cloned().filter(is_fn) else {
1180        return Ok(Value::Undef);
1181    };
1182    let interval = interval_opt(args).unwrap_or(5007.0).max(1.0) as u64;
1183    let abs = std::fs::canonicalize(&path)
1184        .map(|p| p.to_string_lossy().into_owned())
1185        .unwrap_or_else(|_| path.clone());
1186
1187    let stop = Arc::new(AtomicBool::new(false));
1188    let id = NEXT_WATCH_ID.with(|n| {
1189        let v = *n.borrow();
1190        *n.borrow_mut() = v + 1;
1191        v
1192    });
1193    WATCHERS.with(|w| {
1194        w.borrow_mut().push(WatchEntry {
1195            id,
1196            path: abs.clone(),
1197            listener: listener.clone(),
1198            stop: stop.clone(),
1199        });
1200    });
1201    with_host(|h| h.incr_handle());
1202
1203    let tx = with_host(|h| h.io_sender());
1204    let poll_stop = stop.clone();
1205    let poll_listener = listener;
1206    std::thread::spawn(move || {
1207        let mut prev = stat_parts(&abs);
1208        loop {
1209            if poll_stop.load(Ordering::Acquire) {
1210                break;
1211            }
1212            std::thread::sleep(Duration::from_millis(interval));
1213            if poll_stop.load(Ordering::Acquire) {
1214                break;
1215            }
1216            let curr = stat_parts(&abs);
1217            if curr != prev {
1218                let l = poll_listener.clone();
1219                let (p0, p1, p2) = prev;
1220                let (c0, c1, c2) = curr;
1221                let _ = tx.send(Box::new(move || {
1222                    let cur = with_host(|h| stats_from_parts(h, c0, c1, c2));
1223                    let old = with_host(|h| stats_from_parts(h, p0, p1, p2));
1224                    if let Err(e) = invoke(&l, vec![cur, old], None) {
1225                        eprintln!("{e}");
1226                    }
1227                    Ok(())
1228                }));
1229                prev = curr;
1230            }
1231        }
1232    });
1233    Ok(Value::Undef)
1234}
1235
1236fn unwatch_file(args: &[Value]) -> Result<Value, String> {
1237    let path = arg_str(args, 0);
1238    let abs = std::fs::canonicalize(&path)
1239        .map(|p| p.to_string_lossy().into_owned())
1240        .unwrap_or_else(|_| path.clone());
1241    let listener = args.get(1).cloned().filter(is_fn);
1242    let removed = WATCHERS.with(|w| {
1243        let mut w = w.borrow_mut();
1244        let mut count = 0;
1245        w.retain(|e| {
1246            let matches =
1247                e.path == abs && listener.as_ref().map(|l| *l == e.listener).unwrap_or(true);
1248            if matches {
1249                e.stop.store(true, Ordering::Release);
1250                count += 1;
1251            }
1252            !matches
1253        });
1254        count
1255    });
1256    for _ in 0..removed {
1257        with_host(|h| h.decr_handle());
1258    }
1259    // Wake the event loop so it can re-evaluate the handle count.
1260    if removed > 0 {
1261        let _ = with_host(|h| h.io_sender()).send(Box::new(|| Ok(())));
1262    }
1263    Ok(Value::Undef)
1264}
1265
1266/// A file's watch-relevant state: `(exists, mtime_ms, size)`.
1267fn stat_parts(path: &str) -> (bool, i64, u64) {
1268    match std::fs::metadata(path) {
1269        Ok(md) => (
1270            true,
1271            md.mtime() * 1000 + md.mtime_nsec() / 1_000_000,
1272            md.len(),
1273        ),
1274        Err(_) => (false, 0, 0),
1275    }
1276}
1277
1278fn stats_from_parts(h: &mut crate::host::JsHost, exists: bool, mtime_ms: i64, size: u64) -> Value {
1279    let ms = mtime_ms as f64;
1280    let date = |h: &mut crate::host::JsHost, ms: f64| {
1281        let mut d = IndexMap::new();
1282        d.insert("@@native".into(), h.new_str("Date"));
1283        d.insert("@@ms".into(), Value::Float(ms));
1284        h.new_object(d)
1285    };
1286    let mut m = IndexMap::new();
1287    m.insert("@@native".into(), h.new_str("Stats"));
1288    m.insert("@@isFile".into(), Value::Bool(exists));
1289    m.insert("@@isDir".into(), Value::Bool(false));
1290    m.insert("@@isSymlink".into(), Value::Bool(false));
1291    m.insert(
1292        "size".into(),
1293        Value::Float(if exists { size as f64 } else { 0.0 }),
1294    );
1295    m.insert("atimeMs".into(), Value::Float(ms));
1296    m.insert("mtimeMs".into(), Value::Float(ms));
1297    m.insert("ctimeMs".into(), Value::Float(ms));
1298    m.insert("birthtimeMs".into(), Value::Float(ms));
1299    let mt = date(h, ms);
1300    m.insert("mtime".into(), mt);
1301    let at = date(h, ms);
1302    m.insert("atime".into(), at);
1303    h.new_object(m)
1304}
1305
1306// ── read / write streams ─────────────────────────────────────────────────────
1307
1308fn create_read_stream(args: &[Value]) -> Result<Value, String> {
1309    let path = arg_str(args, 0);
1310    let enc = encoding_arg(args, 1);
1311    let stream = with_host(|h| {
1312        let mut extra = IndexMap::new();
1313        extra.insert("path".into(), h.new_str(path.clone()));
1314        if let Some(e) = &enc {
1315            extra.insert("@@encoding".into(), h.new_str(e.clone()));
1316        }
1317        extra
1318    });
1319    let stream = super::net::new_emitter_object("FSReadStream", stream);
1320    with_host(|h| h.incr_handle());
1321    let recv = stream.clone();
1322    let p = path;
1323    with_host(|h| {
1324        h.queue_micro_native(Box::new(move || {
1325            read_stream_pump(&recv, &p);
1326            Ok(())
1327        }))
1328    });
1329    Ok(stream)
1330}
1331
1332/// Read the whole file and drive the stream's events on the microtask tick after
1333/// creation (so synchronously-attached `on('data')`/`on('end')` listeners fire).
1334fn read_stream_pump(recv: &Value, path: &str) {
1335    with_host(|h| h.decr_handle());
1336    let opened = File::open(path).and_then(|f| {
1337        let mut bytes = Vec::new();
1338        (&f).read_to_end(&mut bytes).map(|_| (f, bytes))
1339    });
1340    let bytes = match opened {
1341        Ok((file, b)) => {
1342            // `open` hands listeners the descriptor, then `ready`; the
1343            // descriptor is real and closed again once the data is out.
1344            let fd = register_fd(file);
1345            let open = with_host(|h| h.new_str("open"));
1346            let _ = super::events::instance_call(recv, "emit", vec![open, Value::Float(fd as f64)]);
1347            let _ =
1348                super::events::instance_call(recv, "emit", vec![with_host(|h| h.new_str("ready"))]);
1349            close_fd(fd);
1350            b
1351        }
1352
1353        Err(e) => {
1354            let ev = with_host(|h| crate::builtins::synth_error(h, &err_str("open", path, &e)));
1355            let _ = super::events::instance_call(
1356                recv,
1357                "emit",
1358                vec![with_host(|h| h.new_str("error")), ev],
1359            );
1360            return;
1361        }
1362    };
1363    let enc = get_prop(recv, "@@encoding").map(|v| with_host(|h| h.str_of(&v)));
1364    let chunk = match enc.as_deref() {
1365        Some(e) if e != "buffer" => {
1366            with_host(|h| h.new_str(String::from_utf8_lossy(&bytes).into_owned()))
1367        }
1368        _ => super::buffer::from_bytes(&bytes),
1369    };
1370    if let Some(dest) = get_prop(recv, "@@pipeDest") {
1371        let _ = crate::host::call_method(&dest, "write", vec![chunk]);
1372        let _ = crate::host::call_method(&dest, "end", vec![]);
1373    } else {
1374        let name = with_host(|h| h.new_str("data"));
1375        let _ = super::events::instance_call(recv, "emit", vec![name, chunk]);
1376    }
1377    // `end` and then `close` each follow on a later tick, as node schedules
1378    // them, so what a `data` listener queued runs before the stream ends.
1379    let target = recv.clone();
1380    with_host(|h| {
1381        h.queue_micro_native(Box::new(move || {
1382            let end = with_host(|h| h.new_str("end"));
1383            super::events::instance_call(&target, "emit", vec![end])?;
1384            with_host(|h| {
1385                h.queue_micro_native(Box::new(move || {
1386                    let close = with_host(|h| h.new_str("close"));
1387                    super::events::instance_call(&target, "emit", vec![close]).map(|_| ())
1388                }))
1389            });
1390            Ok(())
1391        }))
1392    });
1393}
1394
1395pub const READ_STREAM_METHODS: &[&str] = &[
1396    "pipe",
1397    "pause",
1398    "resume",
1399    "setEncoding",
1400    "destroy",
1401    "close",
1402    "read",
1403];
1404
1405/// `fs.ReadStream` instance dispatch (tag `FSReadStream`). The EventEmitter methods (`on`, `once`, `emit`, …) delegate to `events`, as every
1406/// other emitter-backed instance does; this covers the stream-only surface.
1407/// Nothing routed them here, so `fs.createReadStream(p).on('data', …)` threw
1408/// `on is not a function`.
1409pub fn read_stream_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
1410    if !READ_STREAM_METHODS.contains(&method) && super::events::METHODS.contains(&method) {
1411        return super::events::instance_call(recv, method, args);
1412    }
1413    match method {
1414        "pipe" => {
1415            if let Some(dest) = args.first().cloned() {
1416                set_prop(recv, "@@pipeDest", dest.clone());
1417                Ok(dest)
1418            } else {
1419                Ok(recv.clone())
1420            }
1421        }
1422        "setEncoding" => {
1423            set_prop(
1424                recv,
1425                "@@encoding",
1426                with_host(|h| h.new_str(super::arg_str(&args, 0))),
1427            );
1428            Ok(recv.clone())
1429        }
1430        "pause" | "resume" | "read" => Ok(recv.clone()),
1431        "destroy" | "close" => {
1432            let _ =
1433                super::events::instance_call(recv, "emit", vec![with_host(|h| h.new_str("close"))]);
1434            Ok(recv.clone())
1435        }
1436        _ => Err(crate::host::type_error(&format!(
1437            "stream.{method} is not a function"
1438        ))),
1439    }
1440}
1441
1442fn create_write_stream(args: &[Value]) -> Result<Value, String> {
1443    let path = arg_str(args, 0);
1444    let flags = match encoding_flag(args, "flags") {
1445        Some(f) => f,
1446        None => "w".to_string(),
1447    };
1448    let file = open_options(&flags)
1449        .open(&path)
1450        .map_err(|e| err_str("open", &path, &e))?;
1451    let fd = register_fd(file);
1452    let stream = with_host(|h| {
1453        let mut extra = IndexMap::new();
1454        extra.insert("path".into(), h.new_str(path));
1455        extra.insert("@@wfd".into(), Value::Float(fd as f64));
1456        extra.insert("bytesWritten".into(), Value::Float(0.0));
1457        extra
1458    });
1459    let stream = super::net::new_emitter_object("FSWriteStream", stream);
1460    with_host(|h| h.incr_handle());
1461    let recv = stream.clone();
1462    with_host(|h| {
1463        h.queue_micro_native(Box::new(move || {
1464            let open = with_host(|h| h.new_str("open"));
1465            let _ =
1466                super::events::instance_call(&recv, "emit", vec![open, Value::Float(fd as f64)]);
1467            let _ = super::events::instance_call(
1468                &recv,
1469                "emit",
1470                vec![with_host(|h| h.new_str("ready"))],
1471            );
1472            Ok(())
1473        }))
1474    });
1475    Ok(stream)
1476}
1477
1478pub const WRITE_STREAM_METHODS: &[&str] = &[
1479    "write",
1480    "end",
1481    "destroy",
1482    "close",
1483    "cork",
1484    "uncork",
1485    "setDefaultEncoding",
1486];
1487
1488/// `fs.WriteStream` instance dispatch (tag `FSWriteStream`), with the
1489/// EventEmitter methods delegated to `events` as for a read stream.
1490pub fn write_stream_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
1491    if !WRITE_STREAM_METHODS.contains(&method) && super::events::METHODS.contains(&method) {
1492        return super::events::instance_call(recv, method, args);
1493    }
1494    match method {
1495        "write" => {
1496            write_stream_bytes(recv, args.first());
1497            // The write callback follows the call, with `null` for success.
1498            if let Some(cb) = args.iter().find(|v| is_fn(v)).cloned() {
1499                with_host(|h| {
1500                    h.queue_micro_native(Box::new(move || {
1501                        let null = with_host(|h| h.null());
1502                        invoke(&cb, vec![null], None).map(|_| ())
1503                    }))
1504                });
1505            }
1506            Ok(Value::Bool(true))
1507        }
1508        "end" => {
1509            if let Some(chunk) = args
1510                .first()
1511                .filter(|v| !matches!(v, Value::Undef) && !is_fn(v))
1512            {
1513                write_stream_bytes(recv, Some(chunk));
1514            }
1515            if let Some(fd) = get_prop(recv, "@@wfd").map(|v| with_host(|h| h.to_number(&v)) as i32)
1516            {
1517                close_fd(fd);
1518            }
1519            with_host(|h| h.decr_handle());
1520            // `finish`, the `end` callback and `close` follow the call rather
1521            // than happening inside it: node finishes a write stream once its
1522            // pending writes have drained, so code after `end()` runs first.
1523            let target = recv.clone();
1524            let cb = args.iter().find(|v| is_fn(v)).cloned();
1525            with_host(|h| {
1526                h.queue_micro_native(Box::new(move || {
1527                    let ev = |name: &str| {
1528                        let n = with_host(|h| h.new_str(name));
1529                        super::events::instance_call(&target, "emit", vec![n]).map(|_| ())
1530                    };
1531                    // Node runs the `end` callbacks, with `null`, before it
1532                    // emits `finish`.
1533                    if let Some(cb) = cb {
1534                        let null = with_host(|h| h.null());
1535                        invoke(&cb, vec![null], None)?;
1536                    }
1537                    ev("finish")?;
1538                    ev("close")
1539                }))
1540            });
1541            Ok(recv.clone())
1542        }
1543
1544        "destroy" | "close" => {
1545            if let Some(fd) = get_prop(recv, "@@wfd").map(|v| with_host(|h| h.to_number(&v)) as i32)
1546            {
1547                close_fd(fd);
1548            }
1549            let _ =
1550                super::events::instance_call(recv, "emit", vec![with_host(|h| h.new_str("close"))]);
1551            with_host(|h| h.decr_handle());
1552            Ok(recv.clone())
1553        }
1554        "cork" | "uncork" | "setDefaultEncoding" => Ok(recv.clone()),
1555        _ => Err(crate::host::type_error(&format!(
1556            "stream.{method} is not a function"
1557        ))),
1558    }
1559}
1560
1561fn write_stream_bytes(recv: &Value, chunk: Option<&Value>) {
1562    let Some(chunk) = chunk else { return };
1563    let data = value_bytes(chunk);
1564    if let Some(fd) = get_prop(recv, "@@wfd").map(|v| with_host(|h| h.to_number(&v)) as i32) {
1565        let written = with_file(fd, |file| {
1566            let mut fr: &File = file;
1567            fr.write(&data).unwrap_or(0)
1568        })
1569        .unwrap_or(0);
1570        let prev = get_prop(recv, "bytesWritten")
1571            .map(|v| with_host(|h| h.to_number(&v)))
1572            .unwrap_or(0.0);
1573        set_prop(recv, "bytesWritten", Value::Float(prev + written as f64));
1574    }
1575}
1576
1577// ── Stats / Dirent / Dir instance surfaces ───────────────────────────────────
1578
1579/// `fs.Stats` method dispatch (`isFile`/`isDirectory`/…).
1580pub fn stats_call(recv: &Value, method: &str) -> Result<Value, String> {
1581    type_test(recv, method, "stats")
1582}
1583
1584pub const DIRENT_METHODS: &[&str] = &[
1585    "isFile",
1586    "isDirectory",
1587    "isSymbolicLink",
1588    "isBlockDevice",
1589    "isCharacterDevice",
1590    "isFIFO",
1591    "isSocket",
1592];
1593
1594/// `fs.Dirent` method dispatch (tag `Dirent`); `name`/`parentPath`/`path` are
1595/// plain data properties resolved by the generic object path.
1596pub fn dirent_call(recv: &Value, method: &str) -> Result<Value, String> {
1597    type_test(recv, method, "dirent")
1598}
1599
1600/// Shared `is*` predicates for `Stats` and `Dirent` (both carry `@@isFile`/
1601/// `@@isDir`/`@@isSymlink`; the device/FIFO/socket predicates are always false —
1602/// node-js does not model those file types).
1603fn type_test(recv: &Value, method: &str, what: &str) -> Result<Value, String> {
1604    let read = |key: &str| {
1605        with_host(|h| match h.get(recv) {
1606            Some(JsObj::Object(p)) => matches!(p.get(key), Some(Value::Bool(true))),
1607            _ => false,
1608        })
1609    };
1610    match method {
1611        "isFile" => Ok(Value::Bool(read("@@isFile"))),
1612        "isDirectory" => Ok(Value::Bool(read("@@isDir"))),
1613        "isSymbolicLink" => Ok(Value::Bool(read("@@isSymlink"))),
1614        "isBlockDevice" | "isCharacterDevice" | "isFIFO" | "isSocket" => Ok(Value::Bool(false)),
1615        _ => Err(crate::host::type_error(&format!(
1616            "{what}.{method} is not a function"
1617        ))),
1618    }
1619}
1620
1621pub const DIR_METHODS: &[&str] = &["read", "readSync", "close", "closeSync"];
1622
1623/// `fs.Dir` method dispatch (tag `Dir`); `path` is a plain data property.
1624pub fn dir_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
1625    match method {
1626        "readSync" => Ok(dir_next(recv)),
1627        "read" => {
1628            let v = dir_next(recv);
1629            if let Some(cb) = args.first().filter(|c| is_fn(c)).cloned() {
1630                with_host(|h| {
1631                    let n = h.null();
1632                    h.queue_micro(cb, vec![n, v]);
1633                });
1634                Ok(Value::Undef)
1635            } else {
1636                Ok(settled_ok(v))
1637            }
1638        }
1639        "closeSync" => Ok(Value::Undef),
1640        "close" => {
1641            if let Some(cb) = args.first().filter(|c| is_fn(c)).cloned() {
1642                with_host(|h| {
1643                    let n = h.null();
1644                    h.queue_micro(cb, vec![n]);
1645                });
1646                Ok(Value::Undef)
1647            } else {
1648                Ok(settled_ok(Value::Undef))
1649            }
1650        }
1651        _ => Err(crate::host::type_error(&format!(
1652            "dir.{method} is not a function"
1653        ))),
1654    }
1655}
1656
1657/// Advance the `Dir` cursor and return the next `Dirent`, or `null` when drained.
1658fn dir_next(recv: &Value) -> Value {
1659    with_host(|h| {
1660        let (entries, pos) = match h.get(recv) {
1661            Some(JsObj::Object(p)) => (
1662                p.get("@@entries").cloned(),
1663                p.get("@@pos").map(|v| h.to_number(v) as usize).unwrap_or(0),
1664            ),
1665            _ => (None, 0),
1666        };
1667        let item = match entries.as_ref().and_then(|e| h.get(e)) {
1668            Some(JsObj::Array(items)) => items.get(pos).cloned(),
1669            _ => None,
1670        };
1671        if item.is_some() {
1672            if let Some(JsObj::Object(p)) = h.get_mut(recv) {
1673                p.insert("@@pos".into(), Value::Float((pos + 1) as f64));
1674            }
1675        }
1676        item.unwrap_or_else(|| h.null())
1677    })
1678}
1679
1680fn build_dirent(
1681    h: &mut crate::host::JsHost,
1682    name: String,
1683    parent: &str,
1684    ft: std::fs::FileType,
1685) -> Value {
1686    let mut m = IndexMap::new();
1687    m.insert("@@native".into(), h.new_str("Dirent"));
1688    m.insert("name".into(), h.new_str(name));
1689    let pp = h.new_str(parent.to_string());
1690    m.insert("parentPath".into(), pp.clone());
1691    m.insert("path".into(), pp);
1692    m.insert("@@isFile".into(), Value::Bool(ft.is_file()));
1693    m.insert("@@isDir".into(), Value::Bool(ft.is_dir()));
1694    m.insert("@@isSymlink".into(), Value::Bool(ft.is_symlink()));
1695    h.new_object(m)
1696}
1697
1698// ── glob ─────────────────────────────────────────────────────────────────────
1699
1700fn glob_impl(args: &[Value]) -> Result<Value, String> {
1701    let pattern = arg_str(args, 0);
1702    let absolute = pattern.starts_with('/');
1703    let base = if absolute {
1704        PathBuf::from("/")
1705    } else {
1706        std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1707    };
1708    let segs: Vec<String> = pattern
1709        .split('/')
1710        .filter(|s| !s.is_empty())
1711        .map(String::from)
1712        .collect();
1713    let mut out: Vec<String> = Vec::new();
1714    let prefix = if absolute {
1715        "/".to_string()
1716    } else {
1717        String::new()
1718    };
1719    glob_walk(&base, &segs, 0, &prefix, &mut out);
1720    out.sort();
1721    out.dedup();
1722    Ok(with_host(|h| {
1723        let items: Vec<Value> = out.into_iter().map(|s| h.new_str(s)).collect();
1724        h.new_array(items)
1725    }))
1726}
1727
1728fn glob_walk(dir: &Path, segs: &[String], idx: usize, prefix: &str, out: &mut Vec<String>) {
1729    if idx >= segs.len() {
1730        if !prefix.is_empty() && prefix != "/" {
1731            out.push(prefix.trim_end_matches('/').to_string());
1732        }
1733        return;
1734    }
1735    let seg = &segs[idx];
1736    if seg == "**" {
1737        glob_walk(dir, segs, idx + 1, prefix, out);
1738        if let Ok(rd) = std::fs::read_dir(dir) {
1739            for e in rd.flatten() {
1740                if e.path().is_dir() {
1741                    let name = e.file_name().to_string_lossy().into_owned();
1742                    let np = join_glob(prefix, &name);
1743                    glob_walk(&e.path(), segs, idx, &np, out);
1744                }
1745            }
1746        }
1747        return;
1748    }
1749    let last = idx + 1 == segs.len();
1750    if let Ok(rd) = std::fs::read_dir(dir) {
1751        for e in rd.flatten() {
1752            let name = e.file_name().to_string_lossy().into_owned();
1753            if name.starts_with('.') && !seg.starts_with('.') {
1754                continue;
1755            }
1756            if wildcard_match(seg, &name) {
1757                let np = join_glob(prefix, &name);
1758                if last {
1759                    out.push(np);
1760                } else if e.path().is_dir() {
1761                    glob_walk(&e.path(), segs, idx + 1, &np, out);
1762                }
1763            }
1764        }
1765    }
1766}
1767
1768fn join_glob(prefix: &str, name: &str) -> String {
1769    if prefix.is_empty() {
1770        name.to_string()
1771    } else if prefix.ends_with('/') {
1772        format!("{prefix}{name}")
1773    } else {
1774        format!("{prefix}/{name}")
1775    }
1776}
1777
1778/// Match a single path segment against a `*`/`?` glob (no character classes).
1779fn wildcard_match(pat: &str, name: &str) -> bool {
1780    let p: Vec<char> = pat.chars().collect();
1781    let n: Vec<char> = name.chars().collect();
1782    let (mut pi, mut ni) = (0usize, 0usize);
1783    let (mut star, mut mark) = (None, 0usize);
1784    while ni < n.len() {
1785        if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
1786            pi += 1;
1787            ni += 1;
1788        } else if pi < p.len() && p[pi] == '*' {
1789            star = Some(pi);
1790            mark = ni;
1791            pi += 1;
1792        } else if let Some(s) = star {
1793            pi = s + 1;
1794            mark += 1;
1795            ni = mark;
1796        } else {
1797            return false;
1798        }
1799    }
1800    while pi < p.len() && p[pi] == '*' {
1801        pi += 1;
1802    }
1803    pi == p.len()
1804}
1805
1806// ── small helpers ────────────────────────────────────────────────────────────
1807
1808fn settled_ok(v: Value) -> Value {
1809    let p = with_host(|h| h.new_promise());
1810    let id = with_host(|h| h.promise_id(&p).unwrap_or(0));
1811    crate::host::resolve_promise_val(id, v);
1812    p
1813}
1814
1815fn get_prop(recv: &Value, key: &str) -> Option<Value> {
1816    with_host(|h| match h.get(recv) {
1817        Some(JsObj::Object(p)) => p.get(key).cloned(),
1818        _ => None,
1819    })
1820}
1821
1822fn set_prop(recv: &Value, key: &str, val: Value) {
1823    with_host(|h| {
1824        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
1825            p.insert(key.to_string(), val);
1826        }
1827    });
1828}
1829
1830/// Raw bytes of a byte-view arg, or the utf-8 bytes of anything else.
1831fn value_bytes(v: &Value) -> Vec<u8> {
1832    match super::buffer::view_bytes(v) {
1833        Some(b) => b,
1834        None => with_host(|h| h.str_of(v)).into_bytes(),
1835    }
1836}
1837
1838fn buf_bytes(v: &Value) -> Vec<u8> {
1839    with_host(|h| match h.get(v) {
1840        Some(JsObj::Object(p)) => match p.get("@@bytes").and_then(|b| h.get(b)) {
1841            Some(JsObj::Array(items)) => items.iter().map(|x| h.to_number(x) as u8).collect(),
1842            _ => Vec::new(),
1843        },
1844        _ => Vec::new(),
1845    })
1846}
1847
1848fn buf_len(v: &Value) -> usize {
1849    with_host(|h| match h.get(v) {
1850        Some(JsObj::Object(p)) => match p.get("@@bytes").and_then(|b| h.get(b)) {
1851            Some(JsObj::Array(items)) => items.len(),
1852            _ => 0,
1853        },
1854        _ => 0,
1855    })
1856}
1857
1858/// Write `data` into a Buffer's backing byte array at `offset`; returns the count
1859/// actually written (bounded by the buffer capacity).
1860fn write_into_buffer(buf: &Value, offset: usize, data: &[u8]) -> usize {
1861    let Some(arr) = get_prop(buf, "@@bytes") else {
1862        return 0;
1863    };
1864    with_host(|h| {
1865        if let Some(JsObj::Array(items)) = h.get_mut(&arr) {
1866            let mut n = 0;
1867            for (i, b) in data.iter().enumerate() {
1868                let idx = offset + i;
1869                if idx >= items.len() {
1870                    break;
1871                }
1872                items[idx] = Value::Float(*b as f64);
1873                n += 1;
1874            }
1875            n
1876        } else {
1877            0
1878        }
1879    })
1880}
1881
1882fn array_items(v: Option<&Value>) -> Vec<Value> {
1883    match v {
1884        Some(v) => with_host(|h| match h.get(v) {
1885            Some(JsObj::Array(items)) => items.clone(),
1886            _ => Vec::new(),
1887        }),
1888        None => Vec::new(),
1889    }
1890}
1891
1892fn opt_flag(args: &[Value], key: &str) -> bool {
1893    with_host(|h| {
1894        args.iter().any(|v| {
1895            matches!(h.get(v), Some(JsObj::Object(p)) if matches!(p.get(key), Some(Value::Bool(true))))
1896        })
1897    })
1898}
1899
1900fn encoding_flag(args: &[Value], key: &str) -> Option<String> {
1901    with_host(|h| {
1902        for v in args {
1903            if let Some(JsObj::Object(p)) = h.get(v) {
1904                if let Some(val) = p.get(key) {
1905                    return Some(h.str_of(val));
1906                }
1907            }
1908        }
1909        None
1910    })
1911}
1912
1913fn interval_opt(args: &[Value]) -> Option<f64> {
1914    with_host(|h| {
1915        for v in args {
1916            if let Some(JsObj::Object(p)) = h.get(v) {
1917                if let Some(val) = p.get("interval") {
1918                    return Some(h.to_number(val));
1919                }
1920            }
1921        }
1922        None
1923    })
1924}
1925
1926fn num_or(args: &[Value], i: usize, default: f64) -> f64 {
1927    match args.get(i) {
1928        Some(v) if !matches!(v, Value::Undef) => {
1929            let n = with_host(|h| h.to_number(v));
1930            if n.is_nan() {
1931                default
1932            } else {
1933                n
1934            }
1935        }
1936        _ => default,
1937    }
1938}
1939
1940/// A byte `position` argument: `null`/`undefined` means "current file offset".
1941fn position_arg(args: &[Value], i: usize) -> Option<u64> {
1942    match args.get(i) {
1943        Some(Value::Undef) | None => None,
1944        Some(v) if with_host(|h| h.is_null(v)) => None,
1945        Some(v) => {
1946            let n = with_host(|h| h.to_number(v));
1947            if n.is_nan() || n < 0.0 {
1948                None
1949            } else {
1950                Some(n as u64)
1951            }
1952        }
1953    }
1954}
1955
1956/// A timestamp argument in **seconds**. A `Date` argument stores milliseconds, so
1957/// divide; a bare number is already seconds (Node's `fs.utimes` convention).
1958fn time_secs(args: &[Value], i: usize) -> f64 {
1959    match args.get(i) {
1960        Some(v) if native_tag(v).as_deref() == Some("Date") => arg_num(args, i) / 1000.0,
1961        _ => arg_num(args, i),
1962    }
1963}
1964
1965fn to_timeval(secs: f64) -> libc::timeval {
1966    let s = secs.floor();
1967    let us = ((secs - s) * 1_000_000.0).round();
1968    libc::timeval {
1969        tv_sec: s as libc::time_t,
1970        tv_usec: us as libc::suseconds_t,
1971    }
1972}
1973
1974fn cpath(path: &str, op: &str) -> Result<CString, String> {
1975    CString::new(path).map_err(|_| format!("Error: EINVAL: invalid argument, {op} '{path}'"))
1976}
1977
1978fn ok_or_errno(rc: libc::c_int, op: &str, path: &str) -> Result<Value, String> {
1979    if rc == 0 {
1980        Ok(Value::Undef)
1981    } else {
1982        Err(err_str(op, path, &std::io::Error::last_os_error()))
1983    }
1984}
1985
1986/// The encoding an fs call was given, from either the string form
1987/// (`readFileSync(p, 'hex')`) or the options-object form
1988/// (`readFileSync(p, { encoding: 'hex' })`).
1989///
1990/// The object form used to be discarded — it was detected by its
1991/// `"[object Object]"` stringification and treated as absent — so
1992/// `{ encoding: 'utf8' }` returned a Buffer. `{ encoding: null }` really does
1993/// mean "no encoding", and so returns `None`.
1994fn encoding_arg(args: &[Value], i: usize) -> Option<String> {
1995    match args.get(i) {
1996        Some(Value::Undef) | None => None,
1997        Some(v) => {
1998            if with_host(|h| matches!(h.get(v), Some(JsObj::Object(_)))) {
1999                let e = crate::builtins::get_property(v, "encoding").ok()?;
2000                if matches!(e, Value::Undef) || with_host(|h| h.is_null(&e)) {
2001                    return None;
2002                }
2003                return Some(with_host(|h| h.str_of(&e)));
2004            }
2005            let s = with_host(|h| h.str_of(v));
2006            if s == "undefined" || s == "null" {
2007                None
2008            } else {
2009                Some(s)
2010            }
2011        }
2012    }
2013}
2014
2015/// The failing *syscall* Node names in a filesystem error, for the JS-level
2016/// entry points whose API name differs from it. `fs.readFileSync('/nope')`
2017/// reports `open`, not `readFileSync`, because `open(2)` is what failed —
2018/// and `err.syscall` is load-bearing for packages that branch on it.
2019fn syscall_name(op: &str) -> &str {
2020    match op {
2021        "readFileSync" | "readFile" | "writeFile" | "writeFileSync" | "appendFile"
2022        | "appendFileSync" | "createReadStream" | "createWriteStream" => "open",
2023        "readdir" | "readdirSync" | "opendir" => "scandir",
2024        // The rest name the C call, which for most of these is the JS name
2025        // minus its `Sync` suffix — `fs.statSync('/nope')` reports `stat`. Only
2026        // the ones whose spelling ALSO changes need an entry: libuv writes
2027        // `copyfile` in one word, and `realpath` fails inside the `lstat` it
2028        // walks the path with.
2029        "copyFile" | "copyFileSync" => "copyfile",
2030        "realpath" | "realpathSync" => "lstat",
2031        other => other.strip_suffix("Sync").unwrap_or(other),
2032    }
2033}
2034
2035/// The second path a two-argument call names in its message: node renders
2036/// `rename '/a' -> '/b'`, and a message with only the source reads as though
2037/// the destination were never part of the operation.
2038fn err_str2(op: &str, from: &str, to: &str, e: &std::io::Error) -> String {
2039    format!("{} -> '{to}'", err_str(op, from, e))
2040}
2041
2042/// The `CODE: reason` half of a libuv error message (`ENOENT: no such file or
2043/// directory`), shared by every syscall wrapper that has to render one.
2044///
2045/// Deliberately NOT Rust's `io::Error` `Display`: Rust capitalizes and appends
2046/// its own `(os error 2)`, so `No such file or directory (os error 2)` is a
2047/// string no Node has ever printed. `process.chdir` was assembling its message
2048/// that way.
2049/// The libuv error CODE for an `io::Error` — `ENOENT`, `EACCES` and the rest.
2050/// Node exposes it as `err.code`, and every error it renders leads with it.
2051pub(crate) fn libuv_code(e: &std::io::Error) -> &'static str {
2052    use std::io::ErrorKind::*;
2053    match e.kind() {
2054        NotFound => "ENOENT",
2055        PermissionDenied => "EACCES",
2056        AlreadyExists => "EEXIST",
2057        NotADirectory => "ENOTDIR",
2058        IsADirectory => "EISDIR",
2059        DirectoryNotEmpty => "ENOTEMPTY",
2060        InvalidInput => "EINVAL",
2061        BrokenPipe => "EPIPE",
2062        _ => "EIO",
2063    }
2064}
2065
2066pub(crate) fn libuv_message(e: &std::io::Error) -> String {
2067    let code = libuv_code(e);
2068    format!("{code}: {}", libuv_reason(code))
2069}
2070
2071/// libuv's own lowercase reason string for a code.
2072fn libuv_reason(code: &str) -> &'static str {
2073    match code {
2074        "ENOENT" => "no such file or directory",
2075        "EACCES" => "permission denied",
2076        "EEXIST" => "file already exists",
2077        "ENOTDIR" => "not a directory",
2078        "EISDIR" => "illegal operation on a directory",
2079        "ENOTEMPTY" => "directory not empty",
2080        "EINVAL" => "invalid argument",
2081        "EPIPE" => "broken pipe",
2082        _ => "i/o error",
2083    }
2084}
2085
2086fn err_str(op: &str, path: &str, e: &std::io::Error) -> String {
2087    let code = libuv_code(e);
2088    // libuv's own reason strings, not Rust's `io::Error` `Display` — Rust
2089    // capitalizes ("No such file or directory") where libuv (and therefore
2090    // Node's `err.message`) does not.
2091    let reason = libuv_reason(code);
2092    let op = syscall_name(op);
2093    if path.is_empty() {
2094        format!("Error: {code}: {reason}, {op}")
2095    } else {
2096        format!("Error: {code}: {reason}, {op} '{path}'")
2097    }
2098}