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}