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