Skip to main content

dtact_util/fs/
uring_linux.rs

1//! Linux native filesystem backend: real `io_uring` opcodes
2//! (`OpenAt`/`Read`/`Write`/`Fsync`/`Close`) submitted to a single
3//! dedicated ring, instead of `native.rs`'s thread-pool fallback (which on
4//! Linux is not even compiled in — see `fs::mod`'s `cfg` gates).
5//!
6//! **Not every op actually goes through the ring.** [`DtactFile::read`]/
7//! [`write`](DtactFile::write)/[`read_at`](DtactFile::read_at)/
8//! [`write_at`](DtactFile::write_at) first try a direct, synchronous
9//! `pread(2)`/`pwrite(2)` on the *calling* thread ([`try_pread`]/
10//! [`try_pwrite`]) and only fall back to submitting an SQE on `EAGAIN`
11//! (which in practice only happens for a non-regular fd, e.g. a pipe —
12//! regular-file `pread`/`pwrite` essentially never returns `EAGAIN`). This
13//! is a deliberate latency optimization (skips the `MpmcStack` push /
14//! worker unpark / `submit_and_wait` / wake round-trip entirely for the
15//! common case), but it means the "submitted to a single dedicated ring"
16//! framing above is only strictly true for [`DtactFile::open`]/
17//! [`create`](DtactFile::create)/[`sync_all`](DtactFile::sync_all)/
18//! [`close`](DtactFile::close) plus non-regular-fd reads/writes — every
19//! other read/write is a **blocking syscall run inline on whatever OS
20//! thread is currently polling the future**. For page-cache-hot regular
21//! files that's negligible; for a cache-miss read from slow local disk, or
22//! any file actually backed by a network filesystem (NFS, etc., where
23//! `pread`/`pwrite` can block for the network round-trip rather than
24//! returning `EAGAIN`), that blocking syscall stalls the entire OS thread
25//! it runs on — and with it, every other fiber/task currently scheduled
26//! onto that thread — for the syscall's full duration. Not a correctness
27//! bug, but a real latency-tail risk this module's earlier doc didn't call
28//! out; workloads sensitive to that tail should route such reads/writes
29//! elsewhere (e.g. a dedicated blocking-thread pool) rather than assume
30//! this backend is uniformly non-blocking just because it's "the
31//! `io_uring` backend".
32//!
33//! **Per-op state is a preallocated slot, not a fresh allocation.**
34//! [`init_fs`] carves out a fixed `Box<[OpState]>` arena (sized by
35//! `ring_depth`) up front, handed out/reclaimed via a
36//! [`crate::lockfree::TreiberStack`] free-list, mirroring
37//! `fs::iocp_windows`'s pool and (like it) `io::native`'s `BufferPool`
38//! before that. Because a pooled slot's address is stable for the whole
39//! process (never individually freed), `user_data` can just be the raw
40//! slot pointer — no `Arc`/refcount bookkeeping needed for the common
41//! case at all, which is a further simplification over this file's first
42//! pass (which `Arc`-heap-allocated every single op). A slot is only
43//! returned to the pool once its result has actually been observed
44//! (`Drop for IoOp` checks `result != PENDING`); if a future is dropped
45//! while its op is still in flight, the slot is deliberately leaked
46//! rather than risked for reuse (reclaiming it safely needs an
47//! `IORING_OP_ASYNC_CANCEL` submitted for it and waiting on *that*
48//! completion first — not implemented here, same caveat as the Windows
49//! backend's module doc).
50
51use crate::lockfree::{AtomicWakerSlot, MpmcStack, TreiberStack};
52use std::ffi::CString;
53use std::future::Future;
54use std::io;
55use std::os::unix::ffi::OsStrExt;
56use std::path::{Path, PathBuf};
57use std::pin::Pin;
58use std::sync::OnceLock;
59use std::sync::atomic::{AtomicI64, Ordering};
60use std::task::{Context, Poll};
61use std::thread::Thread;
62
63use io_uring::{IoUring, opcode, squeue, types};
64
65/// Sentinel for `OpState::result` meaning "not yet completed" — mirrors
66/// `fs::iocp_windows::PENDING`. `>= 0` after completion is the raw
67/// `io_uring` cqe result (bytes transferred / fd); `< 0` and `!= PENDING`
68/// is `-errno`, exactly `io_uring`'s own convention, so no decode step is
69/// needed beyond checking the sign.
70const PENDING: i64 = i64::MIN;
71
72#[repr(align(64))]
73struct OpState {
74    result: AtomicI64,
75    waker: AtomicWakerSlot,
76}
77
78impl OpState {
79    const fn fresh() -> Self {
80        Self {
81            result: AtomicI64::new(PENDING),
82            waker: AtomicWakerSlot::new(),
83        }
84    }
85}
86
87// =============================================================================
88// Preallocated slot pool — see module doc for the reuse/leak-on-cancel policy.
89// =============================================================================
90
91#[repr(align(64))]
92struct SlotPool {
93    slots: Box<[OpState]>,
94    free: TreiberStack,
95}
96
97static RING_DEPTH: OnceLock<usize> = OnceLock::new();
98static SLOT_POOL: OnceLock<SlotPool> = OnceLock::new();
99
100fn slot_pool() -> &'static SlotPool {
101    SLOT_POOL.get_or_init(|| {
102        let depth = *RING_DEPTH.get_or_init(|| 256);
103        let mut slots = Vec::with_capacity(depth);
104        for _ in 0..depth {
105            slots.push(OpState::fresh());
106        }
107        let free = TreiberStack::new(depth);
108        for i in 0..depth as u32 {
109            free.push(i);
110        }
111        SlotPool {
112            slots: slots.into_boxed_slice(),
113            free,
114        }
115    })
116}
117
118/// Which allocation a given `IoOp`'s [`OpState`] lives in: a checked-out
119/// pool slot (common case, no allocation), or a one-off heap fallback if
120/// the pool was exhausted.
121#[repr(align(64))]
122enum Slot {
123    Pooled(u32),
124    Heap(Box<OpState>),
125}
126
127fn acquire_slot() -> Slot {
128    let pool = slot_pool();
129    pool.free.pop().map_or_else(
130        || Slot::Heap(Box::new(OpState::fresh())),
131        |idx| {
132            pool.slots[idx as usize]
133                .result
134                .store(PENDING, Ordering::Relaxed);
135            Slot::Pooled(idx)
136        },
137    )
138}
139
140/// Wraps a raw `squeue::Entry` so it can cross the pending-submit queue to
141/// the single worker thread that owns the `IoUring` instance. Sound because
142/// every pointer baked into the entry (path `CString`, buffer, the slot
143/// itself) is kept alive by its owning allocation — the pool's arena for
144/// pooled slots (never freed), the `IoOp`'s `Box` for heap-fallback slots
145/// (kept alive across the whole `.await`) — until the matching completion
146/// is processed in `worker_loop`.
147struct SendEntry(squeue::Entry);
148unsafe impl Send for SendEntry {}
149
150#[repr(align(64))]
151struct Ring {
152    /// Lock-free MPMC handoff (many task threads push, the single worker
153    /// thread drains) — not a `Mutex<Vec<SendEntry>>`.
154    pending: MpmcStack<SendEntry>,
155    worker: OnceLock<Thread>,
156}
157
158static RING: OnceLock<Ring> = OnceLock::new();
159
160fn ring() -> &'static Ring {
161    RING.get_or_init(|| {
162        let r = Ring {
163            pending: MpmcStack::new(),
164            worker: OnceLock::new(),
165        };
166        let handle = std::thread::Builder::new()
167            .name("dtact-fs-uring".into())
168            .spawn(worker_loop)
169            .expect("failed to spawn dtact-fs-uring worker thread");
170        let _ = r.worker.set(handle.thread().clone());
171        r
172    })
173}
174
175/// Configure and eagerly start the fs-io_uring subsystem.
176///
177/// Preallocates `ring_depth` op slots (see module doc) and starts the
178/// submit-queue worker thread. `workers`/`buffer_pool_size`/`chunk_size`/
179/// `pin_cpus` mirror `crate::io::native::init_runtime`'s signature for
180/// consistency across this crate's native backends but are unused here
181/// today: submission is single-worker-thread by design, and there's no
182/// `IORING_REGISTER_BUFFERS`-backed buffer pool yet (see the module doc's
183/// "zero-copy" note).
184pub fn init_fs(
185    _workers: usize,
186    ring_depth: u32,
187    _buffer_pool_size: usize,
188    _chunk_size: usize,
189    _pin_cpus: &[usize],
190) {
191    let _ = RING_DEPTH.set(ring_depth.max(1) as usize);
192    let _ = slot_pool();
193    let _ = ring();
194}
195
196/// Simple-signature convenience wrapper: `init_fs(workers, 256, 0, 0, &[])`.
197pub fn init(workers: usize) {
198    init_fs(workers, 256, 0, 0, &[]);
199}
200
201fn worker_loop() {
202    let mut io_uring = IoUring::new(256).expect("dtact-fs: IoUring::new failed");
203    let r = RING
204        .get()
205        .expect("ring() must be called before worker_loop starts");
206    loop {
207        if r.pending.is_empty() {
208            // `park_timeout` rather than an unbounded `park()`: closes the
209            // (rare) race where a new entry is pushed and this thread
210            // unparked *just before* it actually calls park — worst case
211            // we wake up to nothing and loop back around within 5ms.
212            std::thread::park_timeout(std::time::Duration::from_millis(5));
213            continue;
214        }
215        let batch = r.pending.drain_all();
216        if batch.is_empty() {
217            continue;
218        }
219
220        {
221            let mut sq = io_uring.submission();
222            for entry in &batch {
223                // SAFETY: buffers/paths referenced by each entry are kept
224                // alive by their owning allocations until the matching
225                // completion is processed below, so they're still valid
226                // at submit time.
227                unsafe {
228                    let _ = sq.push(&entry.0);
229                }
230            }
231            sq.sync();
232        }
233
234        if let Err(e) = io_uring.submit_and_wait(batch.len()) {
235            eprintln!("dtact-fs-uring: submit_and_wait failed: {e}");
236            continue;
237        }
238
239        let mut cq = io_uring.completion();
240        cq.sync();
241        for cqe in &mut cq {
242            let user_data = cqe.user_data();
243            if user_data == 0 {
244                continue;
245            }
246            // No ownership transfer needed here (unlike the earlier
247            // Arc-per-op version): pooled slots live in the arena for the
248            // whole process, heap-fallback slots are kept alive by the
249            // `IoOp` across its `.await`, so this is just a borrow.
250            let state = unsafe { &*(user_data as *const OpState) };
251            let res = cqe.result();
252            state.result.store(i64::from(res), Ordering::Release);
253            state.waker.take_and_wake();
254        }
255    }
256}
257
258fn submit(entry: squeue::Entry) -> IoOp {
259    let slot = acquire_slot();
260    let ptr: *const OpState = match &slot {
261        Slot::Pooled(idx) => &raw const slot_pool().slots[*idx as usize],
262        Slot::Heap(b) => b.as_ref(),
263    };
264    let entry = entry.user_data(ptr as u64);
265    let r = ring();
266    r.pending.push(SendEntry(entry));
267    if let Some(t) = r.worker.get() {
268        t.unpark();
269    }
270    IoOp { slot }
271}
272
273struct IoOp {
274    slot: Slot,
275}
276
277impl IoOp {
278    #[inline]
279    fn state(&self) -> &OpState {
280        match &self.slot {
281            Slot::Pooled(idx) => &slot_pool().slots[*idx as usize],
282            Slot::Heap(b) => b,
283        }
284    }
285}
286
287impl Future for IoOp {
288    type Output = io::Result<i32>;
289
290    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<i32>> {
291        let r = self.state().result.load(Ordering::Acquire);
292        if r != PENDING {
293            return Poll::Ready(decode(r));
294        }
295        self.state().waker.register(cx.waker());
296        let r = self.state().result.load(Ordering::Acquire);
297        if r != PENDING {
298            return Poll::Ready(decode(r));
299        }
300        Poll::Pending
301    }
302}
303
304impl Drop for IoOp {
305    fn drop(&mut self) {
306        if let Slot::Pooled(idx) = self.slot {
307            let pool = slot_pool();
308            let done = pool.slots[idx as usize].result.load(Ordering::Acquire) != PENDING;
309            if done {
310                pool.free.push(idx);
311            }
312            // Else: leak this slot — see module doc's cancellation caveat.
313        }
314    }
315}
316
317/// Direct positional read on the calling thread.
318///
319/// Regular files served from the page cache complete a `pread(2)`
320/// immediately without ever blocking, so issuing the syscall inline avoids
321/// the entire cross-thread ring round-trip — `MpmcStack` push, worker
322/// unpark, `submit_and_wait`, and the wake back — that otherwise dominates
323/// small/medium file-op latency (a single `dtact-fs-uring` worker also
324/// serializes every op, which the direct path sidesteps). Returns `None`
325/// only if the syscall would block (`EAGAIN`, possible only for a
326/// non-regular fd such as a pipe), signalling the caller to fall back to
327/// the ring; `EINTR` is retried transparently.
328#[inline]
329fn try_pread(fd: i32, buf: &mut [u8], offset: u64) -> Option<io::Result<usize>> {
330    loop {
331        let n = unsafe {
332            libc::pread(
333                fd,
334                buf.as_mut_ptr().cast::<libc::c_void>(),
335                buf.len(),
336                offset as libc::off_t,
337            )
338        };
339        if n >= 0 {
340            return Some(Ok(n as usize));
341        }
342        let err = io::Error::last_os_error();
343        match err.raw_os_error() {
344            // Interrupted before any transfer — retry the syscall.
345            Some(libc::EINTR) => {}
346            Some(libc::EAGAIN) => return None,
347            _ => return Some(Err(err)),
348        }
349    }
350}
351
352/// Direct positional write on the calling thread — the write-side twin of
353/// [`try_pread`]; see its doc for the rationale. Returns `None` on `EAGAIN`
354/// to fall back to the ring.
355#[inline]
356fn try_pwrite(fd: i32, buf: &[u8], offset: u64) -> Option<io::Result<usize>> {
357    loop {
358        let n = unsafe {
359            libc::pwrite(
360                fd,
361                buf.as_ptr().cast::<libc::c_void>(),
362                buf.len(),
363                offset as libc::off_t,
364            )
365        };
366        if n >= 0 {
367            return Some(Ok(n as usize));
368        }
369        let err = io::Error::last_os_error();
370        match err.raw_os_error() {
371            // Interrupted before any transfer — retry the syscall.
372            Some(libc::EINTR) => {}
373            Some(libc::EAGAIN) => return None,
374            _ => return Some(Err(err)),
375        }
376    }
377}
378
379fn decode(res: i64) -> io::Result<i32> {
380    if res < 0 {
381        Err(io::Error::from_raw_os_error(-res as i32))
382    } else {
383        Ok(res as i32)
384    }
385}
386
387fn path_cstring(path: &Path) -> io::Result<CString> {
388    CString::new(path.as_os_str().as_bytes())
389        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))
390}
391
392/// An open file whose ops are submitted as real `io_uring` SQEs.
393pub struct DtactFile {
394    fd: i32,
395    cursor: AtomicI64,
396}
397
398unsafe impl Send for DtactFile {}
399unsafe impl Sync for DtactFile {}
400
401async fn open_impl(path: &Path, flags: i32, mode: u32) -> io::Result<DtactFile> {
402    let cpath = path_cstring(path)?;
403    let cpath_ptr = cpath.as_ptr();
404    let entry = opcode::OpenAt::new(types::Fd(libc::AT_FDCWD), cpath_ptr)
405        .flags(flags)
406        .mode(mode)
407        .build();
408    let op = submit(entry);
409    let fd = op.await?;
410    // `cpath` must outlive the point where the kernel has actually
411    // dereferenced the path, i.e. until `submit_and_wait` returns for this
412    // SQE, which is exactly when our `IoOp` resolves — safe to drop now.
413    drop(cpath);
414    Ok(DtactFile {
415        fd,
416        cursor: AtomicI64::new(0),
417    })
418}
419
420impl DtactFile {
421    /// Open an existing file for reading via a ring-submitted `Openat`.
422    ///
423    /// # Errors
424    ///
425    /// Returns an `io::Error` if the underlying `Openat` completion
426    /// reports one — most commonly `NotFound` if `path` doesn't exist, or
427    /// `PermissionDenied` if it exists but isn't readable.
428    pub async fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
429        let path = path.into();
430        open_impl(&path, libc::O_RDONLY, 0).await
431    }
432
433    /// Create (truncating if it already exists) a file for reading and
434    /// writing via a ring-submitted `Openat`.
435    ///
436    /// # Errors
437    ///
438    /// Returns an `io::Error` if the underlying `Openat` completion
439    /// reports one, e.g. `PermissionDenied` if the containing directory
440    /// isn't writable.
441    pub async fn create(path: impl Into<PathBuf>) -> io::Result<Self> {
442        let path = path.into();
443        open_impl(&path, libc::O_RDWR | libc::O_CREAT | libc::O_TRUNC, 0o644).await
444    }
445
446    /// Generic open honoring an arbitrary [`std::fs::OpenOptions`]. See
447    /// the doc comment inline below for why this falls back to a
448    /// synchronous `open()` rather than a pure-uring one.
449    ///
450    /// # Errors
451    ///
452    /// Returns whatever `std::fs::OpenOptions::open` returns for `path`
453    /// with `opts` applied (e.g. `NotFound`, `PermissionDenied`,
454    /// `AlreadyExists` depending on which `OpenOptions` flags are set).
455    pub async fn open_with(
456        path: impl Into<PathBuf>,
457        opts: std::fs::OpenOptions,
458    ) -> io::Result<Self> {
459        // `std::fs::OpenOptions` has no public flag getters; delegate to
460        // its own (synchronous) `open()` for flag resolution, then hand
461        // the resulting fd off to the ring for all subsequent async ops.
462        // This costs one blocking `openat(2)` on the calling thread for
463        // the *open* only — reads/writes on the returned handle are still
464        // fully io_uring-async. A pure-uring open would need to duplicate
465        // `OpenOptions`' private flag-computation logic here instead.
466        use std::os::unix::io::IntoRawFd;
467        let path = path.into();
468        let file = opts.open(&path)?;
469        let fd = file.into_raw_fd();
470        Ok(Self {
471            fd,
472            cursor: AtomicI64::new(0),
473        })
474    }
475
476    /// Read at the file's shared cursor, advancing it by the number of
477    /// bytes actually read. `buf` is handed back (resized to what was
478    /// filled) so the caller can reuse its allocation.
479    ///
480    /// # Errors
481    ///
482    /// Returns an `io::Error` if the underlying `Read` completion reports
483    /// one; a short read (including `0` at EOF) is a normal `Ok`, not an
484    /// error.
485    pub async fn read(&self, mut buf: Vec<u8>) -> io::Result<(usize, Vec<u8>)> {
486        let offset = self.cursor.load(Ordering::Relaxed) as u64;
487        // Fast path: direct pread on the calling thread (see `try_pread`).
488        if let Some(res) = try_pread(self.fd, &mut buf, offset) {
489            let n = res?;
490            self.cursor.fetch_add(n as i64, Ordering::Relaxed);
491            return Ok((n, buf));
492        }
493        let entry = opcode::Read::new(types::Fd(self.fd), buf.as_mut_ptr(), buf.len() as u32)
494            .offset(offset)
495            .build();
496        let n = submit(entry).await?;
497        self.cursor.fetch_add(i64::from(n), Ordering::Relaxed);
498        Ok((n as usize, buf))
499    }
500
501    /// Write at the file's shared cursor, advancing it by the number of
502    /// bytes actually written. `buf` is handed back so the caller can
503    /// reuse its allocation.
504    ///
505    /// # Errors
506    ///
507    /// Returns an `io::Error` if the underlying `Write` completion
508    /// reports one (e.g. disk full, or the fd was closed concurrently).
509    pub async fn write(&self, buf: Vec<u8>) -> io::Result<(usize, Vec<u8>)> {
510        let offset = self.cursor.load(Ordering::Relaxed) as u64;
511        // Fast path: direct pwrite on the calling thread (see `try_pwrite`).
512        if let Some(res) = try_pwrite(self.fd, &buf, offset) {
513            let n = res?;
514            self.cursor.fetch_add(n as i64, Ordering::Relaxed);
515            return Ok((n, buf));
516        }
517        let entry = opcode::Write::new(types::Fd(self.fd), buf.as_ptr(), buf.len() as u32)
518            .offset(offset)
519            .build();
520        let n = submit(entry).await?;
521        self.cursor.fetch_add(i64::from(n), Ordering::Relaxed);
522        Ok((n as usize, buf))
523    }
524
525    /// Positional read: submits its own SQE with an explicit offset, so
526    /// concurrent `read_at`/`write_at` calls on the same handle are safe
527    /// (no shared cursor involved).
528    ///
529    /// # Errors
530    ///
531    /// Same as [`Self::read`].
532    pub async fn read_at(&self, mut buf: Vec<u8>, offset: u64) -> io::Result<(usize, Vec<u8>)> {
533        // Fast path: direct pread on the calling thread (see `try_pread`).
534        if let Some(res) = try_pread(self.fd, &mut buf, offset) {
535            let n = res?;
536            return Ok((n, buf));
537        }
538        let entry = opcode::Read::new(types::Fd(self.fd), buf.as_mut_ptr(), buf.len() as u32)
539            .offset(offset)
540            .build();
541        let n = submit(entry).await?;
542        Ok((n as usize, buf))
543    }
544
545    /// Positional write: submits its own SQE with an explicit offset, so
546    /// concurrent `read_at`/`write_at` calls on the same handle are safe.
547    ///
548    /// # Errors
549    ///
550    /// Same as [`Self::write`].
551    pub async fn write_at(&self, buf: Vec<u8>, offset: u64) -> io::Result<(usize, Vec<u8>)> {
552        // Fast path: direct pwrite on the calling thread (see `try_pwrite`).
553        if let Some(res) = try_pwrite(self.fd, &buf, offset) {
554            let n = res?;
555            return Ok((n, buf));
556        }
557        let entry = opcode::Write::new(types::Fd(self.fd), buf.as_ptr(), buf.len() as u32)
558            .offset(offset)
559            .build();
560        let n = submit(entry).await?;
561        Ok((n as usize, buf))
562    }
563
564    /// Flush all buffered writes to disk via a ring-submitted `Fsync`.
565    ///
566    /// # Errors
567    ///
568    /// Returns an `io::Error` if the underlying `Fsync` completion
569    /// reports one (e.g. the underlying device was removed).
570    pub async fn sync_all(&self) -> io::Result<()> {
571        let entry = opcode::Fsync::new(types::Fd(self.fd)).build();
572        submit(entry).await?;
573        Ok(())
574    }
575
576    /// File metadata (size, timestamps, permissions, ...).
577    ///
578    /// # Errors
579    ///
580    /// Returns an `io::Error` if the underlying `fstat` fails (e.g. the
581    /// fd was closed concurrently).
582    pub async fn metadata(&self) -> io::Result<std::fs::Metadata> {
583        // `Statx` needs a scratch `statx` buffer plus a conversion to
584        // `std::fs::Metadata`, which has no public constructor from raw
585        // `statx` fields. Fall back to a direct `fstat` via a borrowed
586        // `std::fs::File` (fd not taken, just observed) rather than faking
587        // a `Metadata` — same "cheap enough to not need the ring" judgment
588        // call as `fs::iocp_windows::metadata`.
589        use std::os::unix::io::{AsRawFd, FromRawFd};
590        let file = unsafe { std::fs::File::from_raw_fd(self.fd) };
591        let file = std::mem::ManuallyDrop::new(file);
592        let meta = file.metadata();
593        let _ = file.as_raw_fd();
594        meta
595    }
596
597    /// Explicitly close this file via a ring-submitted `Close`, rather
598    /// than waiting for `Drop` (which closes synchronously instead).
599    ///
600    /// # Errors
601    ///
602    /// Returns an `io::Error` if the underlying `Close` completion
603    /// reports one.
604    pub async fn close(self) -> io::Result<()> {
605        let entry = opcode::Close::new(types::Fd(self.fd)).build();
606        submit(entry).await?;
607        std::mem::forget(self); // fd already closed by the kernel via the op above
608        Ok(())
609    }
610}
611
612impl Drop for DtactFile {
613    fn drop(&mut self) {
614        unsafe {
615            libc::close(self.fd);
616        }
617    }
618}
619
620/// Metadata for the file/directory at `path`. Delegates to
621/// `std::fs::metadata` (a single synchronous syscall — not worth
622/// dispatching through the ring).
623///
624/// # Errors
625///
626/// Returns whatever `std::fs::metadata` returns, e.g. `NotFound` if
627/// `path` doesn't exist.
628pub async fn metadata(path: impl Into<PathBuf>) -> io::Result<std::fs::Metadata> {
629    let path = path.into();
630    std::fs::metadata(&path)
631}
632
633/// List the entries of directory `path`. Delegates to
634/// `std::fs::read_dir`, eagerly collecting all entries.
635///
636/// # Errors
637///
638/// Returns whatever `std::fs::read_dir` returns for opening the
639/// directory, or whatever the first failing entry's `io::Result` returns
640/// while collecting.
641pub async fn read_dir(path: impl Into<PathBuf>) -> io::Result<Vec<std::fs::DirEntry>> {
642    let path: PathBuf = path.into();
643    std::fs::read_dir(&path)?.collect()
644}
645
646/// Recursively create `path` and any missing parent directories.
647/// Delegates to `std::fs::create_dir_all`.
648///
649/// # Errors
650///
651/// Returns whatever `std::fs::create_dir_all` returns, e.g.
652/// `PermissionDenied`.
653pub async fn create_dir_all(path: impl Into<PathBuf>) -> io::Result<()> {
654    let path = path.into();
655    std::fs::create_dir_all(&path)
656}
657
658/// Remove the file at `path`. Delegates to `std::fs::remove_file`.
659///
660/// # Errors
661///
662/// Returns whatever `std::fs::remove_file` returns, e.g. `NotFound`.
663pub async fn remove_file(path: impl Into<PathBuf>) -> io::Result<()> {
664    let path = path.into();
665    std::fs::remove_file(&path)
666}
667
668/// Resolve `path` to an absolute path with all intermediate components
669/// (`.`, `..`, symlinks) resolved.
670///
671/// Delegates to `std::fs::canonicalize` — same "one syscall, not worth
672/// the ring" judgment call as [`metadata`].
673///
674/// # Errors
675///
676/// Returns whatever `std::fs::canonicalize` returns, e.g. `NotFound`.
677pub async fn canonicalize(path: impl Into<PathBuf>) -> io::Result<PathBuf> {
678    let path = path.into();
679    std::fs::canonicalize(&path)
680}
681
682/// Copy the contents (and permission bits) of the file at `from` to `to`,
683/// returning the byte count copied. Delegates to `std::fs::copy`.
684///
685/// # Errors
686///
687/// Returns whatever `std::fs::copy` returns, e.g. `NotFound` if `from`
688/// doesn't exist.
689pub async fn copy(from: impl Into<PathBuf>, to: impl Into<PathBuf>) -> io::Result<u64> {
690    let from = from.into();
691    let to = to.into();
692    std::fs::copy(&from, &to)
693}
694
695/// Create a single new directory. Unlike [`create_dir_all`], fails if any
696/// parent component doesn't already exist. Delegates to
697/// `std::fs::create_dir`.
698///
699/// # Errors
700///
701/// Returns whatever `std::fs::create_dir` returns, e.g. `AlreadyExists`.
702pub async fn create_dir(path: impl Into<PathBuf>) -> io::Result<()> {
703    let path = path.into();
704    std::fs::create_dir(&path)
705}
706
707/// Create a hard link at `dst` pointing at the same inode as `src`.
708/// Delegates to `std::fs::hard_link`.
709///
710/// # Errors
711///
712/// Returns whatever `std::fs::hard_link` returns, e.g. `NotFound` if
713/// `src` doesn't exist.
714pub async fn hard_link(src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> io::Result<()> {
715    let src = src.into();
716    let dst = dst.into();
717    std::fs::hard_link(&src, &dst)
718}
719
720/// Read the entire contents of the file at `path` into a `Vec<u8>`.
721///
722/// Delegates to `std::fs::read` — a whole-file read isn't worth routing
723/// through the ring op-by-op the way [`DtactFile::read`] is for
724/// caller-managed partial reads.
725///
726/// # Errors
727///
728/// Returns whatever `std::fs::read` returns, e.g. `NotFound`.
729pub async fn read(path: impl Into<PathBuf>) -> io::Result<Vec<u8>> {
730    let path = path.into();
731    std::fs::read(&path)
732}
733
734/// Read the target of the symbolic link at `path`. Delegates to
735/// `std::fs::read_link`.
736///
737/// # Errors
738///
739/// Returns whatever `std::fs::read_link` returns, e.g. `NotFound`, or an
740/// error if `path` isn't actually a symlink.
741pub async fn read_link(path: impl Into<PathBuf>) -> io::Result<PathBuf> {
742    let path = path.into();
743    std::fs::read_link(&path)
744}
745
746/// Read the entire contents of the file at `path` into a `String`.
747/// Delegates to `std::fs::read_to_string`.
748///
749/// # Errors
750///
751/// Returns whatever `std::fs::read_to_string` returns, e.g. an
752/// `InvalidData` error if the file isn't valid UTF-8.
753pub async fn read_to_string(path: impl Into<PathBuf>) -> io::Result<String> {
754    let path = path.into();
755    std::fs::read_to_string(&path)
756}
757
758/// Remove an empty directory. Fails if `path` is non-empty — see
759/// [`remove_dir_all`] for the recursive version. Delegates to
760/// `std::fs::remove_dir`.
761///
762/// # Errors
763///
764/// Returns whatever `std::fs::remove_dir` returns, e.g. `NotFound`, or an
765/// error if the directory isn't empty.
766pub async fn remove_dir(path: impl Into<PathBuf>) -> io::Result<()> {
767    let path = path.into();
768    std::fs::remove_dir(&path)
769}
770
771/// Recursively remove a directory and everything under it. Delegates to
772/// `std::fs::remove_dir_all`.
773///
774/// # Errors
775///
776/// Returns whatever `std::fs::remove_dir_all` returns, e.g. `NotFound`.
777pub async fn remove_dir_all(path: impl Into<PathBuf>) -> io::Result<()> {
778    let path = path.into();
779    std::fs::remove_dir_all(&path)
780}
781
782/// Rename (move) the file or directory at `from` to `to`, replacing `to`
783/// if it already exists.
784///
785/// Delegates to `std::fs::rename` — see its own documentation for
786/// cross-platform caveats (e.g. renaming across filesystems).
787///
788/// # Errors
789///
790/// Returns whatever `std::fs::rename` returns.
791pub async fn rename(from: impl Into<PathBuf>, to: impl Into<PathBuf>) -> io::Result<()> {
792    let from = from.into();
793    let to = to.into();
794    std::fs::rename(&from, &to)
795}
796
797/// Set `path`'s permission bits to `perm`. Delegates to
798/// `std::fs::set_permissions`.
799///
800/// # Errors
801///
802/// Returns whatever `std::fs::set_permissions` returns, e.g. `NotFound`.
803pub async fn set_permissions(
804    path: impl Into<PathBuf>,
805    perm: std::fs::Permissions,
806) -> io::Result<()> {
807    let path = path.into();
808    std::fs::set_permissions(&path, perm)
809}
810
811/// Create a symbolic link at `dst` pointing at `src`. Delegates to
812/// `std::os::unix::fs::symlink`.
813///
814/// # Errors
815///
816/// Returns whatever `std::os::unix::fs::symlink` returns, e.g.
817/// `AlreadyExists` if `dst` already exists.
818pub async fn symlink(src: impl Into<PathBuf>, dst: impl Into<PathBuf>) -> io::Result<()> {
819    let src = src.into();
820    let dst = dst.into();
821    std::os::unix::fs::symlink(&src, &dst)
822}
823
824/// Query `path`'s metadata *without* following a trailing symlink (unlike
825/// [`metadata`], which does). Delegates to `std::fs::symlink_metadata`.
826///
827/// # Errors
828///
829/// Returns whatever `std::fs::symlink_metadata` returns, e.g. `NotFound`.
830pub async fn symlink_metadata(path: impl Into<PathBuf>) -> io::Result<std::fs::Metadata> {
831    let path = path.into();
832    std::fs::symlink_metadata(&path)
833}
834
835/// Check whether `path` exists, following symlinks — a permission error
836/// while checking is propagated as `Err` rather than silently read as
837/// "doesn't exist". Delegates to `std::fs::exists`.
838///
839/// # Errors
840///
841/// Returns an `io::Error` for any failure *other than* "doesn't exist",
842/// e.g. `PermissionDenied` on a parent directory.
843pub async fn try_exists(path: impl Into<PathBuf>) -> io::Result<bool> {
844    let path = path.into();
845    std::fs::exists(&path)
846}
847
848/// Write `contents` to the file at `path`, creating it if it doesn't
849/// exist and truncating it if it does. Delegates to `std::fs::write`.
850///
851/// # Errors
852///
853/// Returns whatever `std::fs::write` returns, e.g. `PermissionDenied`.
854pub async fn write(path: impl Into<PathBuf>, contents: impl AsRef<[u8]>) -> io::Result<()> {
855    let path = path.into();
856    std::fs::write(&path, contents)
857}