Skip to main content

zerofs_client/
error.rs

1use ninep_client::ClientError;
2use ninep_proto::P9_ENOTLEADER;
3
4/// The single error type: flat and exhaustive (deliberately NOT
5/// `#[non_exhaustive]`, so `zerofs-ffi` can apply uniffi remote derives and
6/// match exhaustively; a new variant must break its build, not silently
7/// degrade to a catch-all).
8///
9/// `path`/`name` payloads are lossy display strings (invalid bytes become
10/// U+FFFD), never round-trippable inputs.
11///
12/// The variant↔errno table is strict and 1:1; every server errno without a
13/// dedicated variant surfaces as [`ZeroFsError::Io`] verbatim, so
14/// [`ZeroFsError::to_errno`] is lossless by construction.
15#[derive(Debug, Clone)]
16pub enum ZeroFsError {
17    /// No entry at the path (ENOENT).
18    NotFound {
19        /// The path the operation targeted (lossy display).
20        path: String,
21    },
22    /// Access denied by permission bits (EACCES).
23    PermissionDenied {
24        /// The path the operation targeted (lossy display).
25        path: String,
26    },
27    /// EPERM, distinct from EACCES: the operation requires ownership or
28    /// privilege (e.g. chown by a non-owner).
29    NotPermitted {
30        /// The path the operation targeted (lossy display).
31        path: String,
32    },
33    /// The target already exists (EEXIST).
34    AlreadyExists {
35        /// The path the operation targeted (lossy display).
36        path: String,
37    },
38    /// A path component is not a directory (ENOTDIR).
39    NotADirectory {
40        /// The path the operation targeted (lossy display).
41        path: String,
42    },
43    /// The target is a directory where a non-directory was required (EISDIR).
44    IsADirectory {
45        /// The path the operation targeted (lossy display).
46        path: String,
47    },
48    /// A directory removal found the directory non-empty (ENOTEMPTY).
49    DirectoryNotEmpty {
50        /// The path the operation targeted (lossy display).
51        path: String,
52    },
53    /// Name exceeds 255 bytes.
54    NameTooLong {
55        /// The offending name (lossy display).
56        name: String,
57    },
58    /// Bad input detected client-side (e.g. `..` component, conflicting open
59    /// options) or EINVAL from the server.
60    InvalidArgument {
61        /// What was wrong with the input.
62        message: String,
63    },
64    /// Symlink resolution exceeded the 40-hop cap (cycle or pathological chain).
65    TooManySymlinks {
66        /// The path whose resolution looped (lossy display).
67        path: String,
68    },
69    /// Handle or client used after `close()`.
70    Closed,
71    /// The initial connection or attach failed (including connect timeout
72    /// expiry). Connectivity errors only ever surface here: after a successful
73    /// connect, calls block through outages instead of failing.
74    ConnectFailed {
75        /// What failed during connect/attach.
76        message: String,
77    },
78    /// The node served the op but is no longer the HA leader (P9_ENOTLEADER): its
79    /// lease has lapsed or it was fenced by a takeover. Re-routing to the current
80    /// leader and retrying is safe; a [`crate::FailoverClient`] does so transparently.
81    NotLeader {
82        /// The path the operation targeted (lossy display).
83        path: String,
84    },
85    /// Stale handle (ESTALE). From [`crate::File::sync_all`]/[`crate::File::sync_data`]
86    /// this is the durability signal: the `.zerofs4` lineage broke (a failover the
87    /// surviving node could not prove it inherited), so every write acked on this handle
88    /// before the fsync may not be durable. Treat those writes as lost, redo them, and
89    /// fsync again. From other operations it is a plain stale inode/handle (re-open the
90    /// path).
91    Stale {
92        /// The path the operation targeted (lossy display).
93        path: String,
94    },
95    /// Any other server errno, preserved verbatim.
96    Io {
97        /// The Linux errno the server returned.
98        errno: i32,
99        /// The path the operation targeted (lossy display).
100        path: String,
101        /// Human-readable rendering of the errno.
102        message: String,
103    },
104    /// Wire-level failure: codec error, unexpected reply type, failed negotiation.
105    Protocol {
106        /// What went wrong on the wire.
107        message: String,
108    },
109}
110
111impl std::fmt::Display for ZeroFsError {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::NotFound { path } => write!(f, "not found: {path}"),
115            Self::PermissionDenied { path } => write!(f, "permission denied: {path}"),
116            Self::NotPermitted { path } => write!(f, "operation not permitted: {path}"),
117            Self::AlreadyExists { path } => write!(f, "already exists: {path}"),
118            Self::NotADirectory { path } => write!(f, "not a directory: {path}"),
119            Self::IsADirectory { path } => write!(f, "is a directory: {path}"),
120            Self::DirectoryNotEmpty { path } => write!(f, "directory not empty: {path}"),
121            Self::NameTooLong { name } => write!(f, "name too long: {name}"),
122            Self::InvalidArgument { message } => write!(f, "invalid argument: {message}"),
123            Self::TooManySymlinks { path } => {
124                write!(f, "too many levels of symbolic links: {path}")
125            }
126            Self::Closed => write!(f, "handle is closed"),
127            Self::ConnectFailed { message } => write!(f, "connection failed: {message}"),
128            Self::NotLeader { path } => write!(f, "not the leader (re-route): {path}"),
129            Self::Stale { path } => {
130                write!(
131                    f,
132                    "stale handle (fsync: prior writes may not be durable): {path}"
133                )
134            }
135            Self::Io {
136                errno,
137                path,
138                message,
139            } => write!(f, "i/o error (errno {errno}): {path}: {message}"),
140            Self::Protocol { message } => write!(f, "protocol error: {message}"),
141        }
142    }
143}
144
145impl std::error::Error for ZeroFsError {}
146
147/// Lossless conversion for interop with std/tokio I/O: the errno round-trips
148/// through `from_raw_os_error` (so `ErrorKind` is set), and the original
149/// `ZeroFsError` is preserved as the source (recoverable via `downcast`).
150impl From<ZeroFsError> for std::io::Error {
151    fn from(e: ZeroFsError) -> Self {
152        let kind = std::io::Error::from_raw_os_error(e.to_errno()).kind();
153        std::io::Error::new(kind, e)
154    }
155}
156
157impl ZeroFsError {
158    /// Linux errno per the strict 1:1 table; `Io` returns its errno unchanged.
159    pub fn to_errno(&self) -> i32 {
160        match self {
161            Self::NotFound { .. } => libc::ENOENT,
162            Self::PermissionDenied { .. } => libc::EACCES,
163            Self::NotPermitted { .. } => libc::EPERM,
164            Self::AlreadyExists { .. } => libc::EEXIST,
165            Self::NotADirectory { .. } => libc::ENOTDIR,
166            Self::IsADirectory { .. } => libc::EISDIR,
167            Self::DirectoryNotEmpty { .. } => libc::ENOTEMPTY,
168            Self::NameTooLong { .. } => libc::ENAMETOOLONG,
169            Self::InvalidArgument { .. } => libc::EINVAL,
170            Self::TooManySymlinks { .. } => libc::ELOOP,
171            Self::Closed => libc::EBADF,
172            Self::ConnectFailed { .. } => libc::EIO,
173            Self::NotLeader { .. } => P9_ENOTLEADER as i32,
174            Self::Stale { .. } => libc::ESTALE,
175            Self::Io { errno, .. } => *errno,
176            Self::Protocol { .. } => libc::EIO,
177        }
178    }
179
180    /// Map a server errno onto the variant table, keeping `path` as context.
181    pub(crate) fn from_errno(errno: i32, path: &str) -> Self {
182        let path = path.to_string();
183        match errno {
184            libc::ENOENT => Self::NotFound { path },
185            libc::EACCES => Self::PermissionDenied { path },
186            libc::EPERM => Self::NotPermitted { path },
187            libc::EEXIST => Self::AlreadyExists { path },
188            libc::ENOTDIR => Self::NotADirectory { path },
189            libc::EISDIR => Self::IsADirectory { path },
190            libc::ENOTEMPTY => Self::DirectoryNotEmpty { path },
191            libc::ENAMETOOLONG => Self::NameTooLong { name: path },
192            libc::EINVAL => Self::InvalidArgument {
193                message: format!("{path}: invalid argument"),
194            },
195            libc::ELOOP => Self::TooManySymlinks { path },
196            libc::ESTALE => Self::Stale { path },
197            c if c == P9_ENOTLEADER as i32 => Self::NotLeader { path },
198            errno => Self::Io {
199                message: std::io::Error::from_raw_os_error(errno).to_string(),
200                errno,
201                path,
202            },
203        }
204    }
205
206    /// Map a transport-level result onto the public error surface. Server
207    /// errnos go through the variant table; everything else (codec errors,
208    /// unexpected replies) is a wire-level failure.
209    pub(crate) fn from_client(e: &ClientError, path: &str) -> Self {
210        match e {
211            ClientError::Errno(code) => Self::from_errno(*code as i32, path),
212            other => Self::Protocol {
213                message: format!("{path}: {other}"),
214            },
215        }
216    }
217}
218
219/// Attach path context while mapping a transport result onto the public error.
220pub(crate) trait ClientResultExt<T> {
221    fn ctx(self, path: &str) -> Result<T, ZeroFsError>;
222}
223
224impl<T> ClientResultExt<T> for Result<T, ClientError> {
225    fn ctx(self, path: &str) -> Result<T, ZeroFsError> {
226        self.map_err(|e| ZeroFsError::from_client(&e, path))
227    }
228}