Skip to main content

dbmcp_sql/
error.rs

1//! SQL-layer error types for validation, timeout, and query failures.
2
3use rmcp::model::ErrorData;
4
5/// `ErrorData::data` marker identifying [`SqlError::Connection`] failures.
6const CONNECTION_KIND: &str = "connection";
7
8/// Errors produced by SQL validation, identifier checking, and query execution.
9#[derive(Debug, thiserror::Error)]
10pub enum SqlError {
11    /// Query blocked by read-only mode.
12    #[error("Query blocked: only SELECT, SHOW, DESC, DESCRIBE, USE queries are allowed in read-only mode")]
13    ReadOnlyViolation,
14
15    /// `LOAD_FILE()` function blocked for security.
16    #[error("Operation forbidden: LOAD_FILE() is not allowed for security reasons")]
17    LoadFileBlocked,
18
19    /// Dangerous function (file access, large objects, sequences, extensions) blocked.
20    #[error("Operation forbidden: {0}() is not allowed for security reasons")]
21    DangerousFunctionBlocked(String),
22
23    /// INTO OUTFILE/DUMPFILE blocked for security.
24    #[error("Operation forbidden: SELECT INTO OUTFILE/DUMPFILE is not allowed for security reasons")]
25    IntoOutfileBlocked,
26
27    /// Multiple SQL statements blocked.
28    #[error("Query blocked: only single statements are allowed")]
29    MultiStatement,
30
31    /// Invalid database or table name identifier.
32    #[error("Invalid identifier '{0}': must not be empty, whitespace-only, or contain control characters")]
33    InvalidIdentifier(String),
34
35    /// Query exceeded the configured timeout.
36    #[error("Query timed out after {elapsed_secs:.1}s: {sql}")]
37    QueryTimeout {
38        /// Wall-clock seconds elapsed before cancellation.
39        elapsed_secs: f64,
40        /// The SQL statement that was cancelled.
41        sql: String,
42    },
43
44    /// Database query execution failed.
45    #[error("Database error: {0}")]
46    Query(String),
47
48    /// The database could not be reached (I/O, TLS, protocol, or pool failure).
49    ///
50    /// Renders like [`SqlError::Query`]; only the classification differs.
51    #[error("Database error: {0}")]
52    Connection(String),
53
54    /// Table not found in database.
55    #[error("Table not found: {0}")]
56    TableNotFound(String),
57}
58
59impl From<SqlError> for ErrorData {
60    /// Converts to an internal error; connection failures carry a `kind` marker.
61    fn from(e: SqlError) -> Self {
62        let data = matches!(e, SqlError::Connection(_)).then(|| serde_json::json!({ "kind": CONNECTION_KIND }));
63        Self::internal_error(e.to_string(), data)
64    }
65}
66
67/// Returns whether `error` came from a [`SqlError::Connection`].
68#[must_use]
69pub fn is_connection_error(error: &ErrorData) -> bool {
70    error
71        .data
72        .as_ref()
73        .and_then(|data| data.get("kind"))
74        .is_some_and(|kind| kind == CONNECTION_KIND)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn only_connection_errors_are_marked() {
83        let connection = ErrorData::from(SqlError::Connection("reset".into()));
84        assert_eq!(connection.message, "Database error: reset");
85        assert!(is_connection_error(&connection));
86
87        for error in [SqlError::Query("syntax".into()), SqlError::ReadOnlyViolation] {
88            assert!(!is_connection_error(&ErrorData::from(error)));
89        }
90        assert!(!is_connection_error(&ErrorData::internal_error("other", None)));
91    }
92}