Skip to main content

zerofs_client/
client.rs

1use crate::dir::Dir;
2use crate::error::{ClientResultExt, ZeroFsError};
3use crate::file::File;
4use crate::path::{components, display, display_path, split_parent};
5use crate::session::{FidGuard, Session};
6use crate::types::{
7    Capabilities, ConnectOptions, DirEntry, FileType, Metadata, NodeKind, OpenOptions, SetAttrs,
8    SetTime, StatFs,
9};
10use bytes::{Bytes, BytesMut};
11use ninep_client::{NOFID, NinePClient};
12use ninep_proto::Stat;
13use std::collections::VecDeque;
14use std::ffi::OsString;
15use std::os::unix::ffi::{OsStrExt, OsStringExt};
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::Ordering;
19use std::time::Duration;
20
21const DEFAULT_9P_PORT: u16 = 5564;
22
23/// Symlink resolution cap, mirroring Linux's SYMLOOP_MAX headroom.
24const MAX_SYMLINK_HOPS: u32 = 40;
25
26/// One ZeroFS session, one identity. Share via `Arc`; every method takes
27/// `&self` and is safe to call concurrently. The underlying connection
28/// reconnects transparently, blocking calls through outages; bound waits with
29/// your async runtime's timeout facilities (every future is cancel-safe).
30///
31/// Paths are bytes, as on POSIX and the 9P wire: every path parameter is
32/// `impl AsRef<Path>`, so `&str`, `PathBuf`, and `OsStr::from_bytes(..)` for
33/// non-UTF-8 names all work.
34pub struct Client {
35    session: Arc<Session>,
36}
37
38impl std::fmt::Debug for Client {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("Client")
41            .field("closed", &self.session.closed.load(Ordering::Relaxed))
42            .finish_non_exhaustive()
43    }
44}
45
46impl Client {
47    /// Connect with defaults. Targets: `"unix:/sock"`, `"tcp://host:port"`,
48    /// `"host:port"`, `"host"` (port 5564), or a bare filesystem path (unix
49    /// socket).
50    pub async fn connect(target: &str) -> Result<Arc<Client>, ZeroFsError> {
51        Self::connect_with(target, ConnectOptions::default()).await
52    }
53
54    /// Connect with explicit identity, timeout, and tuning.
55    pub async fn connect_with(
56        target: &str,
57        opts: ConnectOptions,
58    ) -> Result<Arc<Client>, ZeroFsError> {
59        let fut = Self::establish(target, &opts);
60        match opts.connect_timeout_ms {
61            Some(ms) => match tokio::time::timeout(Duration::from_millis(ms as u64), fut).await {
62                Ok(result) => result,
63                Err(_) => Err(ZeroFsError::ConnectFailed {
64                    message: format!("connecting to {target}: timed out after {ms} ms"),
65                }),
66            },
67            None => fut.await,
68        }
69    }
70
71    async fn establish(target: &str, opts: &ConnectOptions) -> Result<Arc<Client>, ZeroFsError> {
72        let client = dial(target, opts.msize).await?;
73        let uid = opts.uid.unwrap_or_else(|| unsafe { libc::geteuid() });
74        let gid = opts.gid.unwrap_or_else(|| unsafe { libc::getegid() });
75        let uname = match &opts.uname {
76            Some(u) => u.clone(),
77            None => std::env::var("USER").unwrap_or_else(|_| uid.to_string()),
78        };
79        let root_fid = client.alloc_fid();
80        client
81            .attach(root_fid, NOFID, &uname, &opts.aname, uid)
82            .await
83            .map_err(|e| ZeroFsError::ConnectFailed {
84                message: format!("attach to {target} failed: {e}"),
85            })?;
86        let session = Session::new(client, root_fid, gid);
87        Ok(Arc::new(Client { session }))
88    }
89
90    /// Snapshot of currently negotiated session properties (may change across
91    /// transparent reconnects).
92    pub fn capabilities(&self) -> Capabilities {
93        let c = &self.session.client;
94        Capabilities {
95            extensions_v1: c.extensions_enabled(),
96            extensions_v2: c.extensions_v2_enabled(),
97            msize: c.msize(),
98            max_read_chunk: c.max_io(),
99            max_write_chunk: c.max_write_payload(),
100        }
101    }
102
103    /// Number of server fids this client currently holds: the root, open
104    /// `File`/`Dir` handles, and any in-flight operations. A diagnostic hook for
105    /// leak tests, not part of the stable surface.
106    #[doc(hidden)]
107    pub fn outstanding_fids(&self) -> usize {
108        self.session.client.outstanding_fids()
109    }
110
111    /// Mark the client closed (later calls return `Closed`), then hand the root
112    /// fid to the janitor for a background clunk + recycle. Always succeeds,
113    /// idempotent, and never blocks (no await), so a cancelled close still
114    /// reclaims the root fid. Outstanding `File`/`Dir` handles keep working until
115    /// individually closed.
116    pub async fn close(&self) {
117        if self.session.closed.swap(true, Ordering::AcqRel) {
118            return;
119        }
120        self.session.enqueue_clunk(self.session.root_fid);
121    }
122
123    /// Read the entire file into memory. Returns [`Bytes`]: a whole file that
124    /// fits in one round trip comes back with no copy.
125    pub async fn read(&self, path: impl AsRef<Path>) -> Result<Bytes, ZeroFsError> {
126        let path = path.as_ref();
127        let pd = display(path);
128        let (guard, stat) = self.open_read(path, &pd).await?;
129        let max = self.session.client.max_io().max(1);
130        // A short first chunk means the whole file fit; hand it back uncopied.
131        let first = self
132            .session
133            .client
134            .read_bytes(guard.fid(), 0, max)
135            .await
136            .ctx(&pd)?;
137        if (first.len() as u32) < max {
138            return Ok(first);
139        }
140        // Cap the up-front reservation: `stat.size` is server-reported and may
141        // be wildly large (or hostile); the loop grows `out` as it fills.
142        let cap = stat
143            .as_ref()
144            .map_or(0, |s| s.size as usize)
145            .min((max as usize).saturating_mul(2));
146        let mut out = BytesMut::with_capacity(cap);
147        out.extend_from_slice(&first);
148        loop {
149            let data = self
150                .session
151                .client
152                .read_bytes(guard.fid(), out.len() as u64, max)
153                .await
154                .ctx(&pd)?;
155            let got = data.len();
156            out.extend_from_slice(&data);
157            if (got as u32) < max {
158                return Ok(out.freeze());
159            }
160        }
161    }
162
163    /// Read up to `len` bytes at `offset`; a shorter result means EOF.
164    pub async fn read_range(
165        &self,
166        path: impl AsRef<Path>,
167        offset: u64,
168        len: u32,
169    ) -> Result<Bytes, ZeroFsError> {
170        let path = path.as_ref();
171        let pd = display(path);
172        let (guard, _) = self.open_read(path, &pd).await?;
173        self.session
174            .client
175            .read_bytes(guard.fid(), offset, len)
176            .await
177            .ctx(&pd)
178    }
179
180    /// Create-or-truncate `path` (mode 0o644) and write all of `data`
181    /// (composed client-side: open/create + truncate + write).
182    pub async fn write(&self, path: impl AsRef<Path>, data: &[u8]) -> Result<(), ZeroFsError> {
183        let path = path.as_ref();
184        let pd = display(path);
185        let opts = OpenOptions::write_only().create(true).truncate(true);
186        let guard = self.open_relative_path(path, &pd, &opts).await?;
187        self.session.write_all(guard.fid(), 0, data, &pd).await
188    }
189
190    /// Append `data` at end-of-file (open-or-create + fstat + positioned
191    /// write, composed client-side); returns the offset where the data landed.
192    /// Last-writer-wins under concurrent appenders.
193    pub async fn append(&self, path: impl AsRef<Path>, data: &[u8]) -> Result<u64, ZeroFsError> {
194        let path = path.as_ref();
195        let pd = display(path);
196        let opts = OpenOptions::write_only().create(true);
197        let guard = self.open_relative_path(path, &pd, &opts).await?;
198        let stat = self.session.stat_fid(guard.fid(), &pd).await?;
199        self.session
200            .write_all(guard.fid(), stat.size, data, &pd)
201            .await?;
202        Ok(stat.size)
203    }
204
205    /// Shared namespace-op preamble: closed check, then walk to the parent of
206    /// `path`, returning the parent's guard and the final name component.
207    async fn parent_of<'a>(
208        &self,
209        path: &'a Path,
210        pd: &str,
211    ) -> Result<(FidGuard, &'a [u8]), ZeroFsError> {
212        self.session.check_open()?;
213        let names = components(path)?;
214        let (parents, name) = split_parent(&names, pd)?;
215        let (guard, _) = self.session.walk(parents, pd).await?;
216        Ok((guard, name))
217    }
218
219    /// Walk to `path` and open it read-only, returning the opened guard plus
220    /// the stat when the walk made it free.
221    async fn open_read(
222        &self,
223        path: &Path,
224        pd: &str,
225    ) -> Result<(FidGuard, Option<Stat>), ZeroFsError> {
226        self.session.check_open()?;
227        let names = components(path)?;
228        let (guard, stat) = self.session.walk(&names, pd).await?;
229        self.session
230            .lopen(guard.fid(), libc::O_RDONLY as u32, pd)
231            .await?;
232        Ok((guard, stat))
233    }
234
235    /// Walk to the parent and open/create the final component with `opts`.
236    async fn open_relative_path(
237        &self,
238        path: &Path,
239        pd: &str,
240        opts: &OpenOptions,
241    ) -> Result<FidGuard, ZeroFsError> {
242        self.open_relative_path_op_id(path, pd, opts, [0u8; 16])
243            .await
244    }
245
246    /// [`Self::open_relative_path`] threading an op-id to the create step (only
247    /// relevant when `opts` creates the file).
248    async fn open_relative_path_op_id(
249        &self,
250        path: &Path,
251        pd: &str,
252        opts: &OpenOptions,
253        op_id: [u8; 16],
254    ) -> Result<FidGuard, ZeroFsError> {
255        let (dir_guard, name) = self.parent_of(path, pd).await?;
256        self.session
257            .open_relative_op_id(dir_guard.fid(), name, opts, pd, op_id)
258            .await
259    }
260
261    /// Report the entry at `path` without following symlinks; anywhere: a
262    /// path THROUGH a symlinked directory fails `NotADirectory` (9P walks are
263    /// literal; this applies to every path-taking method). [`Self::metadata`]
264    /// and [`Self::canonicalize`] are the only resolvers.
265    pub async fn stat(&self, path: impl AsRef<Path>) -> Result<Metadata, ZeroFsError> {
266        let path = path.as_ref();
267        let pd = display(path);
268        self.session.check_open()?;
269        let names = components(path)?;
270        let (_guard, stat) = self
271            .session
272            .walk_stat_from(self.session.root_fid, &names, &pd)
273            .await?;
274        Ok(Metadata::from_stat(&stat))
275    }
276
277    /// Like [`Self::stat`] but resolves symlinks (final AND intermediate
278    /// components) client-side (readlink + re-walk), capped at 40 hops.
279    pub async fn metadata(&self, path: impl AsRef<Path>) -> Result<Metadata, ZeroFsError> {
280        let path = path.as_ref();
281        let pd = display(path);
282        self.session.check_open()?;
283        let (_, stat) = self.resolve(path, &pd).await?;
284        Ok(Metadata::from_stat(&stat))
285    }
286
287    /// Resolve every symlink in `path` (40-hop cap) and return the canonical
288    /// path, for use with any other method. Lossless: paths are bytes, so a
289    /// non-UTF-8 component survives the round trip.
290    pub async fn canonicalize(&self, path: impl AsRef<Path>) -> Result<PathBuf, ZeroFsError> {
291        let path = path.as_ref();
292        let pd = display(path);
293        self.session.check_open()?;
294        let (stack, _) = self.resolve(path, &pd).await?;
295        let mut buf = Vec::new();
296        for comp in &stack {
297            buf.push(b'/');
298            buf.extend_from_slice(comp);
299        }
300        if buf.is_empty() {
301            buf.push(b'/');
302        }
303        Ok(PathBuf::from(OsString::from_vec(buf)))
304    }
305
306    /// True if the path exists (any file type, no symlink following);
307    /// `NotFound` becomes `false`.
308    pub async fn exists(&self, path: impl AsRef<Path>) -> Result<bool, ZeroFsError> {
309        match self.stat(path).await {
310            Ok(_) => Ok(true),
311            Err(ZeroFsError::NotFound { .. }) => Ok(false),
312            Err(e) => Err(e),
313        }
314    }
315
316    /// Apply any combination of metadata changes; returns post-change metadata.
317    pub async fn set_attr(
318        &self,
319        path: impl AsRef<Path>,
320        attrs: SetAttrs,
321    ) -> Result<Metadata, ZeroFsError> {
322        let path = path.as_ref();
323        let pd = display(path);
324        self.session.check_open()?;
325        let names = components(path)?;
326        let (guard, _) = self.session.walk(&names, &pd).await?;
327        let stat = self.session.setattr_fid(guard.fid(), &attrs, &pd).await?;
328        Ok(Metadata::from_stat(&stat))
329    }
330
331    /// Change permission bits.
332    pub async fn chmod(&self, path: impl AsRef<Path>, mode: u32) -> Result<Metadata, ZeroFsError> {
333        self.set_attr(
334            path,
335            SetAttrs {
336                mode: Some(mode),
337                ..Default::default()
338            },
339        )
340        .await
341    }
342
343    /// Change owner and/or group (`None` leaves a field untouched).
344    pub async fn chown(
345        &self,
346        path: impl AsRef<Path>,
347        uid: Option<u32>,
348        gid: Option<u32>,
349    ) -> Result<Metadata, ZeroFsError> {
350        self.set_attr(
351            path,
352            SetAttrs {
353                uid,
354                gid,
355                ..Default::default()
356            },
357        )
358        .await
359    }
360
361    /// Truncate or extend a file to `size` bytes.
362    pub async fn truncate(
363        &self,
364        path: impl AsRef<Path>,
365        size: u64,
366    ) -> Result<Metadata, ZeroFsError> {
367        self.set_attr(
368            path,
369            SetAttrs {
370                size: Some(size),
371                ..Default::default()
372            },
373        )
374        .await
375    }
376
377    /// Set access/modification times (utimens; `None` leaves a field untouched).
378    pub async fn set_times(
379        &self,
380        path: impl AsRef<Path>,
381        atime: Option<SetTime>,
382        mtime: Option<SetTime>,
383    ) -> Result<Metadata, ZeroFsError> {
384        self.set_attr(
385            path,
386            SetAttrs {
387                atime,
388                mtime,
389                ..Default::default()
390            },
391        )
392        .await
393    }
394
395    /// Filesystem-wide usage and limits.
396    pub async fn statfs(&self) -> Result<StatFs, ZeroFsError> {
397        self.session.check_open()?;
398        let r = self
399            .session
400            .client
401            .statfs(self.session.root_fid)
402            .await
403            .ctx("/")?;
404        Ok(StatFs::from_wire(&r))
405    }
406
407    /// Flush to durable (S3-backed) storage. On ZeroFS the server-side flush
408    /// is filesystem-global, so this is the durability endpoint for
409    /// write→rename sequences.
410    pub async fn sync(&self) -> Result<(), ZeroFsError> {
411        self.session.check_open()?;
412        self.session
413            .client
414            .fsync(self.session.root_fid, 0)
415            .await
416            .ctx("/")
417    }
418
419    /// Create a directory; the parent must exist.
420    pub async fn create_dir(
421        &self,
422        path: impl AsRef<Path>,
423        mode: u32,
424    ) -> Result<Metadata, ZeroFsError> {
425        self.create_dir_op_id(path, mode, [0u8; 16]).await
426    }
427
428    /// [`Self::create_dir`] with a caller-supplied idempotency op-id (all-zero to
429    /// opt out), passed through to the create step. Generated by the failover
430    /// layer, never here (so it stays stable across retries).
431    pub async fn create_dir_op_id(
432        &self,
433        path: impl AsRef<Path>,
434        mode: u32,
435        op_id: [u8; 16],
436    ) -> Result<Metadata, ZeroFsError> {
437        let path = path.as_ref();
438        let pd = display(path);
439        let (dir_guard, name) = self.parent_of(path, &pd).await?;
440        self.session
441            .mkdir_at_op_id(dir_guard.fid(), name, mode, &pd, op_id)
442            .await
443    }
444
445    /// Create a directory and any missing ancestors.
446    pub async fn create_dir_all(
447        &self,
448        path: impl AsRef<Path>,
449        mode: u32,
450    ) -> Result<(), ZeroFsError> {
451        let path = path.as_ref();
452        self.session.check_open()?;
453        let names = components(path)?;
454        for depth in 1..=names.len() {
455            let prefix = &names[..depth];
456            let pd = display_path(prefix);
457            let (parents, name) = split_parent(prefix, &pd)?;
458            let (dir_guard, _) = self.session.walk(parents, &pd).await?;
459            match self
460                .session
461                .mkdir_at(dir_guard.fid(), name, mode, &pd)
462                .await
463            {
464                Ok(_) | Err(ZeroFsError::AlreadyExists { .. }) => {}
465                Err(e) => return Err(e),
466            }
467        }
468        // `AlreadyExists` was tolerated along the way; the call only succeeds if
469        // the final path resolves to a directory. Resolve symlinks (metadata,
470        // not stat) so an existing symlink-to-directory counts, as `std::fs` does.
471        if !names.is_empty() {
472            let meta = self.metadata(path).await?;
473            if !meta.is_dir() {
474                return Err(ZeroFsError::NotADirectory {
475                    path: display(path),
476                });
477            }
478        }
479        Ok(())
480    }
481
482    /// Remove a file, symlink, or device node.
483    pub async fn remove_file(&self, path: impl AsRef<Path>) -> Result<(), ZeroFsError> {
484        self.remove_file_op_id(path, [0u8; 16]).await
485    }
486
487    /// [`Self::remove_file`] with a caller-supplied idempotency op-id (all-zero to
488    /// opt out). See [`Self::create_dir_op_id`].
489    pub async fn remove_file_op_id(
490        &self,
491        path: impl AsRef<Path>,
492        op_id: [u8; 16],
493    ) -> Result<(), ZeroFsError> {
494        let path = path.as_ref();
495        let pd = display(path);
496        let (dir_guard, name) = self.parent_of(path, &pd).await?;
497        self.session
498            .client
499            .unlinkat_op_id(dir_guard.fid(), name, 0, op_id)
500            .await
501            .ctx(&pd)
502    }
503
504    /// Remove an empty directory.
505    pub async fn remove_dir(&self, path: impl AsRef<Path>) -> Result<(), ZeroFsError> {
506        self.remove_dir_op_id(path, [0u8; 16]).await
507    }
508
509    /// [`Self::remove_dir`] with a caller-supplied idempotency op-id (all-zero to
510    /// opt out). See [`Self::create_dir_op_id`].
511    pub async fn remove_dir_op_id(
512        &self,
513        path: impl AsRef<Path>,
514        op_id: [u8; 16],
515    ) -> Result<(), ZeroFsError> {
516        let path = path.as_ref();
517        let pd = display(path);
518        let (dir_guard, name) = self.parent_of(path, &pd).await?;
519        self.session
520            .client
521            .unlinkat_op_id(dir_guard.fid(), name, libc::AT_REMOVEDIR as u32, op_id)
522            .await
523            .ctx(&pd)
524    }
525
526    /// Remove a directory and all its contents, recursively (client-side
527    /// walk, not atomic).
528    pub async fn remove_dir_all(&self, path: impl AsRef<Path>) -> Result<(), ZeroFsError> {
529        let path = path.as_ref();
530        self.session.check_open()?;
531        if components(path)?.is_empty() {
532            return Err(ZeroFsError::InvalidArgument {
533                message: "refusing to remove the attach root".to_string(),
534            });
535        }
536        let dir = self.open_dir(path).await?;
537        let result = remove_dir_contents(&dir).await;
538        dir.close().await;
539        result?;
540        self.remove_dir(path).await
541    }
542
543    /// Atomically rename/move within the filesystem; replaces an existing
544    /// target.
545    pub async fn rename(
546        &self,
547        from: impl AsRef<Path>,
548        to: impl AsRef<Path>,
549    ) -> Result<(), ZeroFsError> {
550        self.rename_op_id(from, to, [0u8; 16]).await
551    }
552
553    /// [`Self::rename`] with a caller-supplied idempotency op-id (all-zero to opt
554    /// out). See [`Self::create_dir_op_id`].
555    pub async fn rename_op_id(
556        &self,
557        from: impl AsRef<Path>,
558        to: impl AsRef<Path>,
559        op_id: [u8; 16],
560    ) -> Result<(), ZeroFsError> {
561        let (from, to) = (from.as_ref(), to.as_ref());
562        let (fd, td) = (display(from), display(to));
563        let (from_guard, from_name) = self.parent_of(from, &fd).await?;
564        let (to_guard, to_name) = self.parent_of(to, &td).await?;
565        self.session
566            .client
567            .renameat_op_id(from_guard.fid(), from_name, to_guard.fid(), to_name, op_id)
568            .await
569            .ctx(&fd)
570    }
571
572    /// Create a hard link at `link` pointing to the inode of `original`.
573    pub async fn hard_link(
574        &self,
575        original: impl AsRef<Path>,
576        link: impl AsRef<Path>,
577    ) -> Result<Metadata, ZeroFsError> {
578        self.hard_link_op_id(original, link, [0u8; 16]).await
579    }
580
581    /// [`Self::hard_link`] with a caller-supplied idempotency op-id (all-zero to
582    /// opt out). See [`Self::create_dir_op_id`].
583    pub async fn hard_link_op_id(
584        &self,
585        original: impl AsRef<Path>,
586        link: impl AsRef<Path>,
587        op_id: [u8; 16],
588    ) -> Result<Metadata, ZeroFsError> {
589        let (original, link) = (original.as_ref(), link.as_ref());
590        let (od, ld) = (display(original), display(link));
591        let (dir_guard, link_name) = self.parent_of(link, &ld).await?;
592        let orig_names = components(original)?;
593        let (orig_guard, _) = self.session.walk(&orig_names, &od).await?;
594        self.session
595            .link_at_op_id(dir_guard.fid(), orig_guard.fid(), link_name, &ld, op_id)
596            .await
597    }
598
599    /// Create a symlink at `link_path` containing `target` (stored verbatim,
600    /// bytes included).
601    pub async fn symlink(
602        &self,
603        target: impl AsRef<Path>,
604        link_path: impl AsRef<Path>,
605    ) -> Result<Metadata, ZeroFsError> {
606        self.symlink_op_id(target, link_path, [0u8; 16]).await
607    }
608
609    /// [`Self::symlink`] with a caller-supplied idempotency op-id (all-zero to opt
610    /// out). See [`Self::create_dir_op_id`].
611    pub async fn symlink_op_id(
612        &self,
613        target: impl AsRef<Path>,
614        link_path: impl AsRef<Path>,
615        op_id: [u8; 16],
616    ) -> Result<Metadata, ZeroFsError> {
617        let link_path = link_path.as_ref();
618        let ld = display(link_path);
619        let (dir_guard, name) = self.parent_of(link_path, &ld).await?;
620        self.session
621            .symlink_at_op_id(
622                dir_guard.fid(),
623                name,
624                target.as_ref().as_os_str().as_bytes(),
625                &ld,
626                op_id,
627            )
628            .await
629    }
630
631    /// Read a symlink target. Lossless: the target is returned byte-for-byte.
632    pub async fn read_link(&self, path: impl AsRef<Path>) -> Result<PathBuf, ZeroFsError> {
633        let path = path.as_ref();
634        let pd = display(path);
635        self.session.check_open()?;
636        let names = components(path)?;
637        let (guard, _) = self.session.walk(&names, &pd).await?;
638        let target = self.session.client.readlink(guard.fid()).await.ctx(&pd)?;
639        Ok(PathBuf::from(OsString::from_vec(target)))
640    }
641
642    /// Create a fifo, socket, or device node; `mode` carries permission bits
643    /// only; the type (and device numbers) come from `kind`.
644    pub async fn mknod(
645        &self,
646        path: impl AsRef<Path>,
647        kind: NodeKind,
648        mode: u32,
649    ) -> Result<Metadata, ZeroFsError> {
650        self.mknod_op_id(path, kind, mode, [0u8; 16]).await
651    }
652
653    /// [`Self::mknod`] with a caller-supplied idempotency op-id (all-zero to opt
654    /// out). See [`Self::create_dir_op_id`].
655    pub async fn mknod_op_id(
656        &self,
657        path: impl AsRef<Path>,
658        kind: NodeKind,
659        mode: u32,
660        op_id: [u8; 16],
661    ) -> Result<Metadata, ZeroFsError> {
662        let path = path.as_ref();
663        let pd = display(path);
664        let (dir_guard, name) = self.parent_of(path, &pd).await?;
665        self.session
666            .mknod_at_op_id(dir_guard.fid(), name, kind, mode, &pd, op_id)
667            .await
668    }
669
670    /// List a whole directory (`.`/`..` excluded); metadata inline when the
671    /// server supports readdirplus.
672    pub async fn read_dir(&self, path: impl AsRef<Path>) -> Result<Vec<DirEntry>, ZeroFsError> {
673        let dir = self.open_dir(path).await?;
674        let mut out = Vec::new();
675        let result = loop {
676            match dir.next_batch(None).await {
677                Ok(batch) if batch.is_empty() => break Ok(out),
678                Ok(batch) => out.extend(batch),
679                Err(e) => break Err(e),
680            }
681        };
682        dir.close().await;
683        result
684    }
685
686    /// Open a directory for incremental listing and byte-exact child
687    /// operations.
688    pub async fn open_dir(&self, path: impl AsRef<Path>) -> Result<Arc<Dir>, ZeroFsError> {
689        let path = path.as_ref();
690        let pd = display(path);
691        self.session.check_open()?;
692        let names = components(path)?;
693        let (guard, stat) = self.session.walk(&names, &pd).await?;
694        if let Some(stat) = &stat
695            && FileType::from_mode(stat.mode) != FileType::Dir
696        {
697            return Err(ZeroFsError::NotADirectory { path: pd });
698        }
699        Ok(Dir::new(
700            Arc::clone(&self.session),
701            guard,
702            display_path(&names),
703        ))
704    }
705
706    /// Open (and optionally create) a file for positioned I/O.
707    pub async fn open(
708        &self,
709        path: impl AsRef<Path>,
710        opts: OpenOptions,
711    ) -> Result<Arc<File>, ZeroFsError> {
712        let path = path.as_ref();
713        let pd = display(path);
714        self.session.check_open()?;
715        let guard = self.open_relative_path(path, &pd, &opts).await?;
716        Ok(File::new(Arc::clone(&self.session), guard, pd))
717    }
718
719    /// Shorthand: open read-write with create+truncate, mode 0o644.
720    pub async fn create(&self, path: impl AsRef<Path>) -> Result<Arc<File>, ZeroFsError> {
721        self.create_op_id(path, [0u8; 16]).await
722    }
723
724    /// [`Self::create`] with a caller-supplied idempotency op-id (all-zero to opt
725    /// out), passed through to the create step. See [`Self::create_dir_op_id`].
726    pub async fn create_op_id(
727        &self,
728        path: impl AsRef<Path>,
729        op_id: [u8; 16],
730    ) -> Result<Arc<File>, ZeroFsError> {
731        let path = path.as_ref();
732        let pd = display(path);
733        self.session.check_open()?;
734        let opts = OpenOptions::read_write().create(true).truncate(true);
735        let guard = self
736            .open_relative_path_op_id(path, &pd, &opts, op_id)
737            .await?;
738        Ok(File::new(Arc::clone(&self.session), guard, pd))
739    }
740
741    /// Open `path` with `opts` and return the raw (session, fid) binding instead
742    /// of a [`File`] wrapper, so a failover-aware handle can re-bind to a freshly
743    /// probed leader after a failover.
744    pub(crate) async fn open_guard(
745        &self,
746        path: &Path,
747        opts: &OpenOptions,
748    ) -> Result<(Arc<Session>, FidGuard), ZeroFsError> {
749        self.open_guard_op_id(path, opts, [0u8; 16]).await
750    }
751
752    /// [`Self::open_guard`] threading an op-id to the create step.
753    pub(crate) async fn open_guard_op_id(
754        &self,
755        path: &Path,
756        opts: &OpenOptions,
757        op_id: [u8; 16],
758    ) -> Result<(Arc<Session>, FidGuard), ZeroFsError> {
759        let pd = display(path);
760        self.session.check_open()?;
761        let guard = self
762            .open_relative_path_op_id(path, &pd, opts, op_id)
763            .await?;
764        Ok((Arc::clone(&self.session), guard))
765    }
766
767    /// Resolve symlinks in `path` (final and intermediate), returning the
768    /// canonical components and the final stat. Relative targets resolve
769    /// against the link's parent, absolute targets against the attach root
770    /// (the client cannot see outside its attach).
771    async fn resolve(&self, path: &Path, pd: &str) -> Result<(Vec<Vec<u8>>, Stat), ZeroFsError> {
772        let session = &self.session;
773
774        // Fast path: the literal walk succeeds and the final node is not a
775        // symlink; done in one round trip. Any failure falls back to the
776        // component-wise resolver to find the offending symlink.
777        let literal: Vec<&[u8]> = components(path)?;
778        if let Ok((_guard, stat)) = session.walk_stat_from(session.root_fid, &literal, pd).await
779            && FileType::from_mode(stat.mode) != FileType::Symlink
780        {
781            return Ok((literal.iter().map(|c| c.to_vec()).collect(), stat));
782        }
783
784        let mut todo: VecDeque<Vec<u8>> = literal.iter().map(|c| c.to_vec()).collect();
785        let mut stack: Vec<Vec<u8>> = Vec::new();
786        let mut hops = 0u32;
787        // A fid pinned at the directory `stack` denotes, advanced one
788        // component at a time.
789        let (mut cur, _) = session.walk(&[], pd).await?;
790        let mut cur_stat = session.stat_fid(cur.fid(), pd).await?;
791
792        while let Some(name) = todo.pop_front() {
793            if name == b".." {
794                // Only reachable via a symlink target; resolve against the
795                // canonical stack ("/.." stays at the root, like POSIX).
796                stack.pop();
797                let refs: Vec<&[u8]> = stack.iter().map(|c| c.as_slice()).collect();
798                let (guard, stat) = session.walk_stat_from(session.root_fid, &refs, pd).await?;
799                cur = guard;
800                cur_stat = stat;
801                continue;
802            }
803
804            let (guard, stat) = session
805                .walk_stat_from(cur.fid(), &[name.as_slice()], pd)
806                .await?;
807            if FileType::from_mode(stat.mode) == FileType::Symlink {
808                hops += 1;
809                if hops > MAX_SYMLINK_HOPS {
810                    return Err(ZeroFsError::TooManySymlinks {
811                        path: pd.to_string(),
812                    });
813                }
814                let target = session.client.readlink(guard.fid()).await.ctx(pd)?;
815                if target.first() == Some(&b'/') {
816                    stack.clear();
817                    let (root_clone, _) = session.walk(&[], pd).await?;
818                    cur = root_clone;
819                    cur_stat = session.stat_fid(cur.fid(), pd).await?;
820                }
821                // Prepend the target's components ahead of the remaining path.
822                for comp in target
823                    .split(|&b| b == b'/')
824                    .filter(|c| !c.is_empty() && *c != b".")
825                    .rev()
826                {
827                    todo.push_front(comp.to_vec());
828                }
829            } else {
830                stack.push(name);
831                cur = guard;
832                cur_stat = stat;
833            }
834        }
835
836        Ok((stack, cur_stat))
837    }
838}
839
840/// Empty a directory recursively: lists in rounds (rewinding between them so
841/// deletion never races the cursor) until nothing is left.
842fn remove_dir_contents<'a>(
843    dir: &'a Dir,
844) -> std::pin::Pin<Box<dyn Future<Output = Result<(), ZeroFsError>> + Send + 'a>> {
845    Box::pin(async move {
846        loop {
847            dir.rewind().await?;
848            let batch = dir.next_batch(None).await?;
849            if batch.is_empty() {
850                return Ok(());
851            }
852            for entry in batch {
853                if entry.file_type == FileType::Dir {
854                    let child = dir.open_dir_at(&entry.name_bytes).await?;
855                    let result = remove_dir_contents(&child).await;
856                    child.close().await;
857                    result?;
858                    dir.remove_dir_at(&entry.name_bytes).await?;
859                } else {
860                    dir.remove_file_at(&entry.name_bytes).await?;
861                }
862            }
863        }
864    })
865}
866
867/// Open a transport to `target`. Mirrors the FUSE mount's target grammar.
868async fn dial(target: &str, msize: u32) -> Result<Arc<NinePClient>, ZeroFsError> {
869    let connect_failed = |message: String| ZeroFsError::ConnectFailed { message };
870
871    if let Some(rest) = target.strip_prefix("unix:") {
872        let path = rest.strip_prefix("//").unwrap_or(rest);
873        return NinePClient::connect_unix(path, msize)
874            .await
875            .map_err(|e| connect_failed(format!("9P unix socket {path}: {e}")));
876    }
877
878    let hostport = target.strip_prefix("tcp://").unwrap_or(target);
879
880    // A path-like target without a scheme is treated as a unix socket.
881    if hostport.starts_with('/') || hostport.starts_with('.') {
882        return NinePClient::connect_unix(hostport, msize)
883            .await
884            .map_err(|e| connect_failed(format!("9P unix socket {hostport}: {e}")));
885    }
886
887    let addr = resolve_addr(hostport).await?;
888    NinePClient::connect_tcp(addr, msize)
889        .await
890        .map_err(|e| connect_failed(format!("9P server {addr}: {e}")))
891}
892
893async fn resolve_addr(s: &str) -> Result<std::net::SocketAddr, ZeroFsError> {
894    if let Ok(addr) = s.parse::<std::net::SocketAddr>() {
895        return Ok(addr);
896    }
897    let with_port = if s.contains(':') {
898        s.to_string()
899    } else {
900        format!("{s}:{DEFAULT_9P_PORT}")
901    };
902    tokio::net::lookup_host(&with_port)
903        .await
904        .map_err(|e| ZeroFsError::ConnectFailed {
905            message: format!("resolving {with_port}: {e}"),
906        })?
907        .next()
908        .ok_or_else(|| ZeroFsError::ConnectFailed {
909            message: format!("no addresses resolved for {with_port}"),
910        })
911}