Skip to main content

monty_fs/
error.rs

1//! Error types for filesystem mount operations.
2
3use std::{
4    error::Error,
5    fmt,
6    io::{self, ErrorKind},
7};
8
9use monty_types::{ExcData, ExcType, MontyException, StringRepr, unicode_decode_error_msg};
10
11/// Errors from mount configuration or filesystem operations.
12#[derive(Debug)]
13pub enum MountError {
14    /// The virtual path does not fall under any configured mount point.
15    NoMountPoint(String),
16
17    /// Path traversal or symlink escape detected. The resolved host path is
18    /// intentionally NOT included to avoid leaking host filesystem information.
19    PathEscape {
20        /// The virtual path that the sandbox code attempted to access.
21        virtual_path: String,
22    },
23
24    /// A write operation was attempted on a read-only mount.
25    ReadOnly(String),
26
27    /// A rename was attempted across different mount points (EXDEV).
28    CrossMountRename {
29        /// The source virtual path.
30        src: String,
31        /// The destination virtual path.
32        dst: String,
33    },
34
35    /// An I/O error from the host filesystem.
36    Io(io::Error, String),
37
38    /// A file contained bytes that could not be decoded as UTF-8. Carries the
39    /// details needed to reproduce CPython's `UnicodeDecodeError` wording
40    /// exactly (see [`monty_types::unicode_decode_error_msg`]).
41    InvalidUtf8 {
42        /// Byte offset of the first invalid byte.
43        start: usize,
44        /// End of the invalid byte range (exclusive); `start + 1` for a
45        /// single bad byte, further for truncated multi-byte sequences.
46        end: usize,
47        /// The first invalid byte value, shown in the single-byte message form.
48        first_byte: u8,
49        /// CPython's reason wording, from [`monty_types::utf8_error_reason`].
50        reason: &'static str,
51        /// Structured exception fields including the undecodable file bytes
52        /// (omitted for files above `UnicodeErrorData::MAX_OBJECT_LEN`), so
53        /// hosts can build a real `UnicodeDecodeError`.
54        data: ExcData,
55    },
56
57    /// Invalid mount configuration (e.g., host path doesn't exist or isn't a directory).
58    InvalidMount(String),
59
60    /// Cumulative write bytes exceeded the configured per-mount limit.
61    /// The configured byte limit that was exceeded.
62    WriteLimitExceeded(u64),
63
64    /// An operation would exceed the mount's aggregate memory budget.
65    /// The configured byte limit that was exceeded.
66    MemoryUsageLimitExceeded(u64),
67}
68
69impl MountError {
70    /// Converts this error into a [`MontyException`] for returning to the sandbox.
71    #[must_use]
72    pub fn into_exception(self) -> MontyException {
73        match self {
74            Self::NoMountPoint(path) => MontyException::new(
75                ExcType::PermissionError,
76                Some(format!("[Errno 13] Permission denied: {}", StringRepr(&path))),
77            ),
78            Self::PathEscape { virtual_path } => MontyException::new(
79                ExcType::PermissionError,
80                Some(format!("[Errno 13] Permission denied: {}", StringRepr(&virtual_path))),
81            ),
82            Self::ReadOnly(path) => MontyException::new(
83                ExcType::PermissionError,
84                Some(format!("[Errno 30] Read-only file system: {}", StringRepr(&path))),
85            ),
86            Self::CrossMountRename { src, dst } => MontyException::new(
87                ExcType::OSError,
88                Some(format!(
89                    "[Errno 18] Invalid cross-device link: {} -> {}",
90                    StringRepr(&src),
91                    StringRepr(&dst)
92                )),
93            ),
94            // Use hardcoded POSIX errno values rather than `raw_os_error()` so
95            // sandboxed code sees consistent error codes regardless of host OS.
96            // Windows uses different native codes (e.g. 3 for ERROR_PATH_NOT_FOUND
97            // vs POSIX 2 for ENOENT).
98            Self::Io(err, path) => match err.kind() {
99                ErrorKind::NotFound => MontyException::new(
100                    ExcType::FileNotFoundError,
101                    Some(format!("[Errno 2] No such file or directory: {}", StringRepr(&path))),
102                ),
103                ErrorKind::AlreadyExists => MontyException::new(
104                    ExcType::FileExistsError,
105                    Some(format!("[Errno 17] File exists: {}", StringRepr(&path))),
106                ),
107                ErrorKind::PermissionDenied => MontyException::new(
108                    ExcType::PermissionError,
109                    Some(format!("[Errno 13] Permission denied: {}", StringRepr(&path))),
110                ),
111                ErrorKind::IsADirectory => MontyException::new(
112                    ExcType::IsADirectoryError,
113                    Some(format!("[Errno 21] Is a directory: {}", StringRepr(&path))),
114                ),
115                ErrorKind::NotADirectory => MontyException::new(
116                    ExcType::NotADirectoryError,
117                    Some(format!("[Errno 20] Not a directory: {}", StringRepr(&path))),
118                ),
119                ErrorKind::DirectoryNotEmpty => MontyException::new(
120                    ExcType::OSError,
121                    Some(format!("[Errno 39] Directory not empty: {}", StringRepr(&path))),
122                ),
123                ErrorKind::InvalidFilename => MontyException::new(
124                    ExcType::OSError,
125                    Some(format!("[Errno 36] File name too long: {}", StringRepr(&path))),
126                ),
127                _ => MontyException::new(ExcType::OSError, Some(format!("{err}: {}", StringRepr(&path)))),
128            },
129            Self::InvalidUtf8 {
130                start,
131                end,
132                first_byte,
133                reason,
134                data,
135            } => MontyException::new(
136                ExcType::UnicodeDecodeError,
137                Some(unicode_decode_error_msg("utf-8", first_byte, start, end, reason)),
138            )
139            .with_data(data),
140            Self::InvalidMount(msg) => MontyException::new(ExcType::TypeError, Some(msg)),
141            Self::WriteLimitExceeded(limit) => MontyException::new(
142                ExcType::OSError,
143                Some(format!("disk write limit of {} exceeded", format_bytes_pretty(limit))),
144            ),
145            Self::MemoryUsageLimitExceeded(limit) => MontyException::new(
146                ExcType::MemoryError,
147                Some(format!(
148                    "mount memory usage limit of {} exceeded",
149                    format_bytes_pretty(limit)
150                )),
151            ),
152        }
153    }
154
155    /// Creates a `MountError::Io` with a constructed `io::Error`.
156    pub(super) fn io_err(kind: ErrorKind, msg: &str, vpath: &str) -> Self {
157        Self::Io(io::Error::new(kind, msg), vpath.to_owned())
158    }
159
160    /// Shorthand for a "not found" error.
161    pub(super) fn not_found(vpath: &str) -> Self {
162        Self::io_err(ErrorKind::NotFound, "No such file or directory", vpath)
163    }
164}
165
166impl fmt::Display for MountError {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        match self {
169            Self::NoMountPoint(path) => write!(f, "no mount point for path: {path}"),
170            Self::PathEscape { virtual_path } => write!(f, "path escape detected: {virtual_path}"),
171            Self::ReadOnly(path) => write!(f, "read-only mount: {path}"),
172            Self::CrossMountRename { src, dst } => write!(f, "cross-mount rename: {src} -> {dst}"),
173            Self::Io(err, path) => write!(f, "I/O error on {path}: {err}"),
174            Self::InvalidUtf8 { start, first_byte, .. } => {
175                write!(f, "invalid UTF-8 byte 0x{first_byte:02x} at position {start}")
176            }
177            Self::InvalidMount(msg) => write!(f, "invalid mount: {msg}"),
178            Self::WriteLimitExceeded(limit) => {
179                write!(f, "disk write limit of {} exceeded", format_bytes_pretty(*limit))
180            }
181            Self::MemoryUsageLimitExceeded(limit) => {
182                write!(
183                    f,
184                    "mount memory usage limit of {} exceeded",
185                    format_bytes_pretty(*limit)
186                )
187            }
188        }
189    }
190}
191
192impl Error for MountError {
193    fn source(&self) -> Option<&(dyn Error + 'static)> {
194        match self {
195            Self::Io(err, _) => Some(err),
196            _ => None,
197        }
198    }
199}
200
201/// Formats a byte count as a human-readable string using decimal SI units.
202///
203/// Uses KB (1,000), MB (1,000,000), GB (1,000,000,000) to match common disk
204/// size conventions. Values below 1 KB are displayed as whole bytes. Larger
205/// values use one decimal place (e.g. `"1.5 MB"`), dropping the decimal when
206/// it would be `.0`.
207fn format_bytes_pretty(bytes: u64) -> String {
208    const KB: u64 = 1_000;
209    const MB: u64 = 1_000_000;
210    const GB: u64 = 1_000_000_000;
211    const TB: u64 = 1_000_000_000_000;
212
213    if bytes < KB {
214        return format!("{bytes} bytes");
215    }
216
217    let (value, unit) = if bytes < MB {
218        (bytes as f64 / KB as f64, "KB")
219    } else if bytes < GB {
220        (bytes as f64 / MB as f64, "MB")
221    } else if bytes < TB {
222        (bytes as f64 / GB as f64, "GB")
223    } else {
224        (bytes as f64 / TB as f64, "TB")
225    };
226
227    // Drop the decimal place when it rounds to `.0` for cleaner display.
228    let tenths = (value * 10.0).round() % 10.0;
229    if tenths < f64::EPSILON {
230        format!("{value:.0} {unit}")
231    } else {
232        format!("{value:.1} {unit}")
233    }
234}