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, path_bytes, path_from_bytes, 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, Target};
12use ninep_proto::Stat;
13use std::collections::VecDeque;
14use std::future::Future;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::sync::atomic::Ordering;
18use std::time::Duration;
19
20const MULTI_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(500);
21
22/// Symlink resolution cap, mirroring Linux's SYMLOOP_MAX headroom.
23const MAX_SYMLINK_HOPS: u32 = 40;
24
25/// One concurrent ZeroFS session and identity. Calls wait during reconnect.
26/// Cancelling an unsettled fid-state request retires the connection that carried
27/// it; the session reconnects and replays if that connection is still current.
28/// Cancelling a dispatched mutation leaves its outcome ambiguous.
29///
30/// Paths are bytes, as on POSIX and the 9P wire: every path parameter is
31/// `impl AsRef<Path>`, so `&str`, `PathBuf`, and `OsStr::from_bytes(..)` for
32/// non-UTF-8 names all work.
33pub struct Client {
34    session: Arc<Session>,
35}
36
37impl std::fmt::Debug for Client {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("Client")
40            .field("closed", &self.session.closed.load(Ordering::Relaxed))
41            .finish_non_exhaustive()
42    }
43}
44
45impl Client {
46    /// Connect with defaults. Native targets: `"unix:/sock"`,
47    /// `"tcp://host:port"`, `"host:port"`, `"host"` (port 5564), or a bare
48    /// filesystem path (unix socket). A comma-separated string forms an HA
49    /// target set. Browser WASM targets are `"ws://..."` and `"wss://..."`.
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 targets = parse_targets([target])?;
60        connect_before(
61            opts.connect_timeout_ms,
62            Self::establish(targets, target, &opts),
63            |ms| format!("connecting to {target}: timed out after {ms} ms"),
64        )
65        .await
66    }
67
68    /// Connect across a target set, probing for the serving leader on reconnect.
69    pub async fn connect_multi(targets: &[String]) -> Result<Arc<Client>, ZeroFsError> {
70        if targets.is_empty() {
71            return Err(ZeroFsError::ConnectFailed {
72                message: "no targets given".into(),
73            });
74        }
75        let opts = ConnectOptions::default();
76        let label = targets.join(", ");
77        let targets = parse_targets(targets.iter().map(String::as_str))?;
78        let connect = async {
79            loop {
80                match Self::establish(targets.clone(), &label, &opts).await {
81                    Ok(client) => return Ok(client),
82                    Err(_) => crate::runtime::sleep(MULTI_CONNECT_RETRY_DELAY).await,
83                }
84            }
85        };
86        connect_before(opts.connect_timeout_ms, connect, |ms| {
87            format!("no serving leader found among [{label}] within {ms} ms")
88        })
89        .await
90    }
91
92    async fn establish(
93        targets: Vec<Target>,
94        target: &str,
95        opts: &ConnectOptions,
96    ) -> Result<Arc<Client>, ZeroFsError> {
97        let client = NinePClient::connect_multi(targets, opts.msize)
98            .await
99            .map_err(|error| connect_failed(format!("9P target set: {error}")))?;
100        #[cfg(not(target_arch = "wasm32"))]
101        let uid = opts.uid.unwrap_or_else(|| unsafe { libc::geteuid() });
102        #[cfg(target_arch = "wasm32")]
103        let uid = opts.uid.unwrap_or(0);
104        #[cfg(not(target_arch = "wasm32"))]
105        let gid = opts.gid.unwrap_or_else(|| unsafe { libc::getegid() });
106        #[cfg(target_arch = "wasm32")]
107        let gid = opts.gid.unwrap_or(0);
108        let uname = match &opts.uname {
109            Some(u) => u.clone(),
110            None => {
111                #[cfg(not(target_arch = "wasm32"))]
112                {
113                    std::env::var("USER").unwrap_or_else(|_| uid.to_string())
114                }
115                #[cfg(target_arch = "wasm32")]
116                {
117                    uid.to_string()
118                }
119            }
120        };
121        let root_fid = client.alloc_fid();
122        client
123            .attach(root_fid, NOFID, &uname, &opts.aname, uid)
124            .await
125            .map_err(|e| ZeroFsError::ConnectFailed {
126                message: format!("attach to {target} failed: {e}"),
127            })?;
128        let session = Session::new(client, root_fid, gid);
129        Ok(Arc::new(Client { session }))
130    }
131
132    /// Negotiated properties, fixed for the lifetime of this logical session.
133    pub fn capabilities(&self) -> Capabilities {
134        let c = &self.session.client;
135        Capabilities {
136            msize: c.msize(),
137            max_read_chunk: c.max_io(),
138            max_write_chunk: c.max_write_payload(),
139        }
140    }
141
142    /// Whether the underlying session is currently live. During transparent
143    /// reconnect this is false while operations wait for replay to finish.
144    pub fn is_connected(&self) -> bool {
145        self.session.client.is_connected()
146    }
147
148    /// Cumulative 9P wire traffic for this client, including reconnects.
149    pub fn traffic_stats(&self) -> ninep_client::TrafficStats {
150        self.session.client.traffic_stats()
151    }
152
153    /// Number of server fids this client currently holds: the root, open
154    /// `File`/`Dir` handles, and any in-flight operations. A diagnostic hook for
155    /// leak tests, not part of the stable surface.
156    #[doc(hidden)]
157    pub fn outstanding_fids(&self) -> usize {
158        self.session.client.outstanding_fids()
159    }
160
161    /// Marks the client closed and schedules the root fid for release. This call
162    /// is idempotent and non-blocking. Existing `File` and `Dir` handles remain open.
163    pub async fn close(&self) {
164        if self.session.closed.swap(true, Ordering::AcqRel) {
165            return;
166        }
167        self.session.enqueue_clunk(self.session.root_fid);
168    }
169
170    /// Read the entire file into memory. Returns [`Bytes`]: a whole file that
171    /// fits in one round trip comes back with no copy.
172    pub async fn read(&self, path: impl AsRef<Path>) -> Result<Bytes, ZeroFsError> {
173        let path = path.as_ref();
174        let pd = display(path);
175        let (guard, stat) = self.open_read(path, &pd).await?;
176        let max = self.session.client.max_io().max(1);
177        // Return the single received chunk without copying.
178        let first = self
179            .session
180            .client
181            .read_bytes(guard.fid(), 0, max)
182            .await
183            .ctx(&pd)?;
184        if (first.len() as u32) < max {
185            return Ok(first);
186        }
187        // Bound allocation derived from server-provided size.
188        let cap = (stat.size as usize).min((max as usize).saturating_mul(2));
189        let mut out = BytesMut::with_capacity(cap);
190        out.extend_from_slice(&first);
191        loop {
192            let data = self
193                .session
194                .client
195                .read_bytes(guard.fid(), out.len() as u64, max)
196                .await
197                .ctx(&pd)?;
198            let got = data.len();
199            out.extend_from_slice(&data);
200            if (got as u32) < max {
201                return Ok(out.freeze());
202            }
203        }
204    }
205
206    /// Read up to `len` bytes at `offset`; a shorter result means EOF.
207    pub async fn read_range(
208        &self,
209        path: impl AsRef<Path>,
210        offset: u64,
211        len: u32,
212    ) -> Result<Bytes, ZeroFsError> {
213        let path = path.as_ref();
214        let pd = display(path);
215        let (guard, _) = self.open_read(path, &pd).await?;
216        self.session
217            .client
218            .read_bytes(guard.fid(), offset, len)
219            .await
220            .ctx(&pd)
221    }
222
223    /// Create-or-truncate `path` (mode 0o644) and write all of `data`
224    /// (composed client-side: open/create + truncate + write).
225    pub async fn write(&self, path: impl AsRef<Path>, data: &[u8]) -> Result<(), ZeroFsError> {
226        let path = path.as_ref();
227        let pd = display(path);
228        let opts = OpenOptions::write_only().create(true).truncate(true);
229        let guard = self.open_relative_path(path, &pd, &opts).await?;
230        self.session.write_all(guard.fid(), 0, data, &pd).await
231    }
232
233    /// Append `data` at end-of-file (open-or-create + fstat + positioned
234    /// write, composed client-side); returns the offset where the data landed.
235    /// Last-writer-wins under concurrent appenders.
236    pub async fn append(&self, path: impl AsRef<Path>, data: &[u8]) -> Result<u64, ZeroFsError> {
237        let path = path.as_ref();
238        let pd = display(path);
239        let opts = OpenOptions::write_only().create(true);
240        let guard = self.open_relative_path(path, &pd, &opts).await?;
241        let stat = self.session.stat_fid(guard.fid(), &pd).await?;
242        self.session
243            .write_all(guard.fid(), stat.size, data, &pd)
244            .await?;
245        Ok(stat.size)
246    }
247
248    /// Resolves a namespace operation's parent and final component.
249    async fn parent_of<'a>(
250        &self,
251        path: &'a Path,
252        pd: &str,
253    ) -> Result<(FidGuard, &'a [u8]), ZeroFsError> {
254        self.session.check_open()?;
255        let names = components(path)?;
256        let (parents, name) = split_parent(&names, pd)?;
257        let (guard, _) = self.session.walk(parents, pd).await?;
258        Ok((guard, name))
259    }
260
261    /// Opens `path` read-only and returns its guard and stat.
262    async fn open_read(&self, path: &Path, pd: &str) -> Result<(FidGuard, Stat), ZeroFsError> {
263        self.session.check_open()?;
264        let names = components(path)?;
265        let (guard, stat) = self.session.walk(&names, pd).await?;
266        self.session
267            .lopen(guard.fid(), crate::linux::O_RDONLY, pd)
268            .await?;
269        Ok((guard, stat))
270    }
271
272    /// Walk to the parent and open/create the final component with `opts`.
273    async fn open_relative_path(
274        &self,
275        path: &Path,
276        pd: &str,
277        opts: &OpenOptions,
278    ) -> Result<FidGuard, ZeroFsError> {
279        let (dir_guard, name) = self.parent_of(path, pd).await?;
280        self.session
281            .open_relative(dir_guard.fid(), name, opts, pd)
282            .await
283    }
284
285    /// Report the entry at `path` without following symlinks; anywhere: a
286    /// path THROUGH a symlinked directory fails `NotADirectory` (9P walks are
287    /// literal; this applies to every path-taking method). [`Self::metadata`]
288    /// and [`Self::canonicalize`] are the only resolvers.
289    pub async fn stat(&self, path: impl AsRef<Path>) -> Result<Metadata, ZeroFsError> {
290        let path = path.as_ref();
291        let pd = display(path);
292        self.session.check_open()?;
293        let names = components(path)?;
294        let (_guard, stat) = self.session.walk(&names, &pd).await?;
295        Ok(Metadata::from_stat(&stat))
296    }
297
298    /// Like [`Self::stat`] but resolves symlinks (final AND intermediate
299    /// components) client-side (readlink + re-walk), capped at 40 hops.
300    pub async fn metadata(&self, path: impl AsRef<Path>) -> Result<Metadata, ZeroFsError> {
301        let path = path.as_ref();
302        let pd = display(path);
303        self.session.check_open()?;
304        let (_, stat) = self.resolve(path, &pd).await?;
305        Ok(Metadata::from_stat(&stat))
306    }
307
308    /// Resolve every symlink in `path` (40-hop cap) and return the canonical
309    /// path, for use with any other method. Lossless: paths are bytes, so a
310    /// non-UTF-8 component survives the round trip.
311    pub async fn canonicalize(&self, path: impl AsRef<Path>) -> Result<PathBuf, ZeroFsError> {
312        let path = path.as_ref();
313        let pd = display(path);
314        self.session.check_open()?;
315        let (stack, _) = self.resolve(path, &pd).await?;
316        let mut buf = Vec::new();
317        for comp in &stack {
318            buf.push(b'/');
319            buf.extend_from_slice(comp);
320        }
321        if buf.is_empty() {
322            buf.push(b'/');
323        }
324        path_from_bytes(buf)
325    }
326
327    /// True if the path exists (any file type, no symlink following);
328    /// `NotFound` becomes `false`.
329    pub async fn exists(&self, path: impl AsRef<Path>) -> Result<bool, ZeroFsError> {
330        match self.stat(path).await {
331            Ok(_) => Ok(true),
332            Err(ZeroFsError::NotFound { .. }) => Ok(false),
333            Err(e) => Err(e),
334        }
335    }
336
337    /// Apply any combination of metadata changes; returns post-change metadata.
338    pub async fn set_attr(
339        &self,
340        path: impl AsRef<Path>,
341        attrs: SetAttrs,
342    ) -> Result<Metadata, ZeroFsError> {
343        let path = path.as_ref();
344        let pd = display(path);
345        self.session.check_open()?;
346        let names = components(path)?;
347        let (guard, _) = self.session.walk(&names, &pd).await?;
348        let stat = self.session.setattr_fid(guard.fid(), &attrs, &pd).await?;
349        Ok(Metadata::from_stat(&stat))
350    }
351
352    /// Change permission bits.
353    pub async fn chmod(&self, path: impl AsRef<Path>, mode: u32) -> Result<Metadata, ZeroFsError> {
354        self.set_attr(
355            path,
356            SetAttrs {
357                mode: Some(mode),
358                ..Default::default()
359            },
360        )
361        .await
362    }
363
364    /// Change owner and/or group (`None` leaves a field untouched).
365    pub async fn chown(
366        &self,
367        path: impl AsRef<Path>,
368        uid: Option<u32>,
369        gid: Option<u32>,
370    ) -> Result<Metadata, ZeroFsError> {
371        self.set_attr(
372            path,
373            SetAttrs {
374                uid,
375                gid,
376                ..Default::default()
377            },
378        )
379        .await
380    }
381
382    /// Truncate or extend a file to `size` bytes.
383    pub async fn truncate(
384        &self,
385        path: impl AsRef<Path>,
386        size: u64,
387    ) -> Result<Metadata, ZeroFsError> {
388        self.set_attr(
389            path,
390            SetAttrs {
391                size: Some(size),
392                ..Default::default()
393            },
394        )
395        .await
396    }
397
398    /// Set access/modification times (utimens; `None` leaves a field untouched).
399    pub async fn set_times(
400        &self,
401        path: impl AsRef<Path>,
402        atime: Option<SetTime>,
403        mtime: Option<SetTime>,
404    ) -> Result<Metadata, ZeroFsError> {
405        self.set_attr(
406            path,
407            SetAttrs {
408                atime,
409                mtime,
410                ..Default::default()
411            },
412        )
413        .await
414    }
415
416    /// Filesystem-wide usage and limits.
417    pub async fn statfs(&self) -> Result<StatFs, ZeroFsError> {
418        self.session.check_open()?;
419        let r = self
420            .session
421            .client
422            .statfs(self.session.root_fid)
423            .await
424            .ctx("/")?;
425        Ok(StatFs::from_wire(&r))
426    }
427
428    /// Flush to durable (S3-backed) storage. On ZeroFS the server-side flush
429    /// is filesystem-global, so this is the durability endpoint for
430    /// write→rename sequences.
431    pub async fn sync(&self) -> Result<(), ZeroFsError> {
432        self.session.check_open()?;
433        self.session
434            .client
435            .fsync_all(self.session.root_fid, 0)
436            .await
437            .ctx("/")
438    }
439
440    /// Create a directory; the parent must exist.
441    pub async fn create_dir(
442        &self,
443        path: impl AsRef<Path>,
444        mode: u32,
445    ) -> Result<Metadata, ZeroFsError> {
446        let path = path.as_ref();
447        let pd = display(path);
448        let (dir_guard, name) = self.parent_of(path, &pd).await?;
449        self.session
450            .mkdir_at(dir_guard.fid(), name, mode, &pd)
451            .await
452    }
453
454    /// Create a directory and any missing ancestors.
455    pub async fn create_dir_all(
456        &self,
457        path: impl AsRef<Path>,
458        mode: u32,
459    ) -> Result<(), ZeroFsError> {
460        let path = path.as_ref();
461        self.session.check_open()?;
462        let names = components(path)?;
463        for depth in 1..=names.len() {
464            let prefix = &names[..depth];
465            let pd = display_path(prefix);
466            let (parents, name) = split_parent(prefix, &pd)?;
467            let (dir_guard, _) = self.session.walk(parents, &pd).await?;
468            match self
469                .session
470                .mkdir_at(dir_guard.fid(), name, mode, &pd)
471                .await
472            {
473                Ok(_) | Err(ZeroFsError::AlreadyExists { .. }) => {}
474                Err(e) => return Err(e),
475            }
476        }
477        // `AlreadyExists` was tolerated along the way; the call only succeeds if
478        // the final path resolves to a directory. Resolve symlinks (metadata,
479        // not stat) so an existing symlink-to-directory counts, as `std::fs` does.
480        if !names.is_empty() {
481            let meta = self.metadata(path).await?;
482            if !meta.is_dir() {
483                return Err(ZeroFsError::NotADirectory {
484                    path: display(path),
485                });
486            }
487        }
488        Ok(())
489    }
490
491    /// Remove a file, symlink, or device node.
492    pub async fn remove_file(&self, path: impl AsRef<Path>) -> Result<(), ZeroFsError> {
493        let path = path.as_ref();
494        let pd = display(path);
495        let (dir_guard, name) = self.parent_of(path, &pd).await?;
496        self.session
497            .client
498            .unlinkat(dir_guard.fid(), name, 0)
499            .await
500            .ctx(&pd)
501    }
502
503    /// Remove an empty directory.
504    pub async fn remove_dir(&self, path: impl AsRef<Path>) -> Result<(), ZeroFsError> {
505        let path = path.as_ref();
506        let pd = display(path);
507        let (dir_guard, name) = self.parent_of(path, &pd).await?;
508        self.session
509            .client
510            .unlinkat(dir_guard.fid(), name, crate::linux::AT_REMOVEDIR)
511            .await
512            .ctx(&pd)
513    }
514
515    /// Remove a directory and all its contents, recursively (client-side
516    /// walk, not atomic).
517    pub async fn remove_dir_all(&self, path: impl AsRef<Path>) -> Result<(), ZeroFsError> {
518        let path = path.as_ref();
519        self.session.check_open()?;
520        if components(path)?.is_empty() {
521            return Err(ZeroFsError::InvalidArgument {
522                message: "refusing to remove the attach root".to_string(),
523            });
524        }
525        let dir = self.open_dir(path).await?;
526        let result = remove_dir_contents(&dir).await;
527        dir.close().await;
528        result?;
529        self.remove_dir(path).await
530    }
531
532    /// Atomically rename/move within the filesystem; replaces an existing
533    /// target.
534    pub async fn rename(
535        &self,
536        from: impl AsRef<Path>,
537        to: impl AsRef<Path>,
538    ) -> Result<(), ZeroFsError> {
539        let (from, to) = (from.as_ref(), to.as_ref());
540        let (fd, td) = (display(from), display(to));
541        let (from_guard, from_name) = self.parent_of(from, &fd).await?;
542        let (to_guard, to_name) = self.parent_of(to, &td).await?;
543        self.session
544            .client
545            .renameat(from_guard.fid(), from_name, to_guard.fid(), to_name)
546            .await
547            .ctx(&fd)
548    }
549
550    /// Create a hard link at `link` pointing to the inode of `original`.
551    pub async fn hard_link(
552        &self,
553        original: impl AsRef<Path>,
554        link: impl AsRef<Path>,
555    ) -> Result<Metadata, ZeroFsError> {
556        let (original, link) = (original.as_ref(), link.as_ref());
557        let (od, ld) = (display(original), display(link));
558        let (dir_guard, link_name) = self.parent_of(link, &ld).await?;
559        let orig_names = components(original)?;
560        let (orig_guard, _) = self.session.walk(&orig_names, &od).await?;
561        self.session
562            .link_at(dir_guard.fid(), orig_guard.fid(), link_name, &ld)
563            .await
564    }
565
566    /// Create a symlink at `link_path` containing `target` (stored verbatim,
567    /// bytes included).
568    pub async fn symlink(
569        &self,
570        target: impl AsRef<Path>,
571        link_path: impl AsRef<Path>,
572    ) -> Result<Metadata, ZeroFsError> {
573        let link_path = link_path.as_ref();
574        let ld = display(link_path);
575        let (dir_guard, name) = self.parent_of(link_path, &ld).await?;
576        let target = path_bytes(target.as_ref())?;
577        self.session
578            .symlink_at(dir_guard.fid(), name, target, &ld)
579            .await
580    }
581
582    /// Read a symlink target. Lossless: the target is returned byte-for-byte.
583    pub async fn read_link(&self, path: impl AsRef<Path>) -> Result<PathBuf, ZeroFsError> {
584        let path = path.as_ref();
585        let pd = display(path);
586        self.session.check_open()?;
587        let names = components(path)?;
588        let (guard, _) = self.session.walk(&names, &pd).await?;
589        let target = self.session.client.readlink(guard.fid()).await.ctx(&pd)?;
590        path_from_bytes(target)
591    }
592
593    /// Create a fifo, socket, or device node; `mode` carries permission bits
594    /// only; the type (and device numbers) come from `kind`.
595    pub async fn mknod(
596        &self,
597        path: impl AsRef<Path>,
598        kind: NodeKind,
599        mode: u32,
600    ) -> Result<Metadata, ZeroFsError> {
601        let path = path.as_ref();
602        let pd = display(path);
603        let (dir_guard, name) = self.parent_of(path, &pd).await?;
604        self.session
605            .mknod_at(dir_guard.fid(), name, kind, mode, &pd)
606            .await
607    }
608
609    /// List a whole directory (`.`/`..` excluded), with metadata inline via
610    /// readdirplus.
611    pub async fn read_dir(&self, path: impl AsRef<Path>) -> Result<Vec<DirEntry>, ZeroFsError> {
612        let dir = self.open_dir(path).await?;
613        let mut out = Vec::new();
614        let result = loop {
615            match dir.next_batch(None).await {
616                Ok(batch) if batch.is_empty() => break Ok(out),
617                Ok(batch) => out.extend(batch),
618                Err(e) => break Err(e),
619            }
620        };
621        dir.close().await;
622        result
623    }
624
625    /// Open a directory for incremental listing and byte-exact child
626    /// operations.
627    pub async fn open_dir(&self, path: impl AsRef<Path>) -> Result<Arc<Dir>, ZeroFsError> {
628        let path = path.as_ref();
629        let pd = display(path);
630        self.session.check_open()?;
631        let names = components(path)?;
632        let (guard, stat) = self.session.walk(&names, &pd).await?;
633        if FileType::from_mode(stat.mode) != FileType::Dir {
634            return Err(ZeroFsError::NotADirectory { path: pd });
635        }
636        Ok(Dir::new(
637            Arc::clone(&self.session),
638            guard,
639            display_path(&names),
640        ))
641    }
642
643    /// Open (and optionally create) a file for positioned I/O.
644    pub async fn open(
645        &self,
646        path: impl AsRef<Path>,
647        opts: OpenOptions,
648    ) -> Result<Arc<File>, ZeroFsError> {
649        let path = path.as_ref();
650        let pd = display(path);
651        self.session.check_open()?;
652        let guard = self.open_relative_path(path, &pd, &opts).await?;
653        Ok(File::new(Arc::clone(&self.session), guard, pd))
654    }
655
656    /// Shorthand: open read-write with create+truncate, mode 0o644.
657    pub async fn create(&self, path: impl AsRef<Path>) -> Result<Arc<File>, ZeroFsError> {
658        let path = path.as_ref();
659        let pd = display(path);
660        self.session.check_open()?;
661        let opts = OpenOptions::read_write().create(true).truncate(true);
662        let guard = self.open_relative_path(path, &pd, &opts).await?;
663        Ok(File::new(Arc::clone(&self.session), guard, pd))
664    }
665
666    /// Resolve symlinks in `path` (final and intermediate), returning the
667    /// canonical components and the final stat. Relative targets resolve
668    /// against the link's parent, absolute targets against the attach root
669    /// (the client cannot see outside its attach).
670    async fn resolve(&self, path: &Path, pd: &str) -> Result<(Vec<Vec<u8>>, Stat), ZeroFsError> {
671        let session = &self.session;
672
673        // Fast path: the literal walk succeeds and the final node is not a
674        // symlink; done in one round trip. Any failure falls back to the
675        // component-wise resolver to find the offending symlink.
676        let literal: Vec<&[u8]> = components(path)?;
677        if let Ok((_guard, stat)) = session.walk(&literal, pd).await
678            && FileType::from_mode(stat.mode) != FileType::Symlink
679        {
680            return Ok((literal.iter().map(|c| c.to_vec()).collect(), stat));
681        }
682
683        let mut todo: VecDeque<Vec<u8>> = literal.iter().map(|c| c.to_vec()).collect();
684        let mut stack: Vec<Vec<u8>> = Vec::new();
685        let mut hops = 0u32;
686        // A fid pinned at the directory `stack` denotes, advanced one
687        // component at a time.
688        let (mut cur, mut cur_stat) = session.walk(&[], pd).await?;
689
690        while let Some(name) = todo.pop_front() {
691            if name == b".." {
692                // Only reachable via a symlink target; resolve against the
693                // canonical stack ("/.." stays at the root, like POSIX).
694                stack.pop();
695                let refs: Vec<&[u8]> = stack.iter().map(|c| c.as_slice()).collect();
696                let (guard, stat) = session.walk(&refs, pd).await?;
697                cur = guard;
698                cur_stat = stat;
699                continue;
700            }
701
702            let (guard, stat) = session.walk_from(cur.fid(), &[name.as_slice()], pd).await?;
703            if FileType::from_mode(stat.mode) == FileType::Symlink {
704                hops += 1;
705                if hops > MAX_SYMLINK_HOPS {
706                    return Err(ZeroFsError::TooManySymlinks {
707                        path: pd.to_string(),
708                    });
709                }
710                let target = session.client.readlink(guard.fid()).await.ctx(pd)?;
711                if target.first() == Some(&b'/') {
712                    stack.clear();
713                    let (root_clone, root_stat) = session.walk(&[], pd).await?;
714                    cur = root_clone;
715                    cur_stat = root_stat;
716                }
717                // Prepend the target's components ahead of the remaining path.
718                for comp in target
719                    .split(|&b| b == b'/')
720                    .filter(|c| !c.is_empty() && *c != b".")
721                    .rev()
722                {
723                    todo.push_front(comp.to_vec());
724                }
725            } else {
726                stack.push(name);
727                cur = guard;
728                cur_stat = stat;
729            }
730        }
731
732        Ok((stack, cur_stat))
733    }
734}
735
736/// Removes directory contents in repeated listing rounds.
737#[cfg(not(target_arch = "wasm32"))]
738type RemoveDirFuture<'a> =
739    std::pin::Pin<Box<dyn Future<Output = Result<(), ZeroFsError>> + Send + 'a>>;
740#[cfg(target_arch = "wasm32")]
741type RemoveDirFuture<'a> = std::pin::Pin<Box<dyn Future<Output = Result<(), ZeroFsError>> + 'a>>;
742
743fn remove_dir_contents<'a>(dir: &'a Dir) -> RemoveDirFuture<'a> {
744    Box::pin(async move {
745        loop {
746            dir.rewind().await?;
747            let batch = dir.next_batch(None).await?;
748            if batch.is_empty() {
749                return Ok(());
750            }
751            for entry in batch {
752                if entry.file_type == FileType::Dir {
753                    let child = dir.open_dir_at(&entry.name_bytes).await?;
754                    let result = remove_dir_contents(&child).await;
755                    child.close().await;
756                    result?;
757                    dir.remove_dir_at(&entry.name_bytes).await?;
758                } else {
759                    dir.remove_file_at(&entry.name_bytes).await?;
760                }
761            }
762        }
763    })
764}
765
766fn parse_targets<'a>(specs: impl IntoIterator<Item = &'a str>) -> Result<Vec<Target>, ZeroFsError> {
767    specs.into_iter().try_fold(Vec::new(), |mut targets, spec| {
768        targets.extend(Target::parse_list(spec).map_err(connect_failed)?);
769        Ok(targets)
770    })
771}
772
773fn connect_failed(message: String) -> ZeroFsError {
774    ZeroFsError::ConnectFailed { message }
775}
776
777async fn connect_before<T>(
778    timeout_ms: Option<u32>,
779    future: impl Future<Output = Result<T, ZeroFsError>>,
780    timeout_message: impl FnOnce(u32) -> String,
781) -> Result<T, ZeroFsError> {
782    match timeout_ms {
783        Some(ms) => crate::runtime::timeout(Duration::from_millis(ms.into()), future)
784            .await
785            .unwrap_or_else(|_| Err(connect_failed(timeout_message(ms)))),
786        None => future.await,
787    }
788}