Skip to main content

sysprims_core/
error.rs

1//! Error types for sysprims operations.
2//!
3//! This module defines the error taxonomy per ADR-0008:
4//! - [`SysprimsError`] - Canonical error type for all sysprims operations
5//!
6//! ## Design Principles
7//!
8//! - **Structured**: Errors carry typed context (pid, operation) not just messages
9//! - **FFI-friendly**: Maps cleanly to error codes for C-ABI
10//! - **ABI-aligned**: Uses `u32` for PIDs (unsigned for cross-platform consistency)
11//! - **Secure**: No sensitive information (paths, credentials) in error messages
12//!
13//! See ADR-0008 for the full error handling strategy.
14
15use std::io;
16use thiserror::Error;
17
18// ============================================================================
19// Canonical Error Type (per ADR-0008)
20// ============================================================================
21
22/// Canonical error type for all sysprims operations.
23///
24/// This is the single error type used across the sysprims ecosystem. It maps
25/// cleanly to FFI error codes and provides structured context for programmatic
26/// handling.
27///
28/// ## FFI Error Code Mapping
29///
30/// | Variant | FFI Code |
31/// |---------|----------|
32/// | `InvalidArgument` | `SYSPRIMS_ERR_INVALID_ARGUMENT` (1) |
33/// | `SpawnFailed` | `SYSPRIMS_ERR_SPAWN_FAILED` (2) |
34/// | `Timeout` | `SYSPRIMS_ERR_TIMEOUT` (3) |
35/// | `PermissionDenied` | `SYSPRIMS_ERR_PERMISSION_DENIED` (4) |
36/// | `NotFound` | `SYSPRIMS_ERR_NOT_FOUND` (5) |
37/// | `NotSupported` | `SYSPRIMS_ERR_NOT_SUPPORTED` (6) |
38/// | `GroupCreationFailed` | `SYSPRIMS_ERR_GROUP_CREATION_FAILED` (7) |
39/// | `System` | `SYSPRIMS_ERR_SYSTEM` (8) |
40/// | `Internal` | `SYSPRIMS_ERR_INTERNAL` (99) |
41#[derive(Debug, Error)]
42pub enum SysprimsError {
43    /// Invalid argument provided.
44    ///
45    /// Returned when input validation fails (e.g., pid = 0, empty command).
46    #[error("Invalid argument: {message}")]
47    InvalidArgument {
48        /// Description of what was invalid.
49        message: String,
50    },
51
52    /// Failed to spawn a child process.
53    ///
54    /// Wraps the underlying IO error from process creation.
55    #[error("Failed to spawn process: {source}")]
56    SpawnFailed {
57        /// The underlying IO error.
58        #[source]
59        source: io::Error,
60    },
61
62    /// Operation timed out.
63    ///
64    /// The child process did not complete within the deadline.
65    #[error("Operation timed out")]
66    Timeout,
67
68    /// Permission denied for the operation.
69    ///
70    /// Typically returned when signaling a process owned by another user.
71    #[error("Permission denied for '{operation}' on PID {pid}")]
72    PermissionDenied {
73        /// The process ID we attempted to operate on.
74        pid: u32,
75        /// The operation that was denied (e.g., "terminate", "signal").
76        operation: String,
77    },
78
79    /// Target process not found.
80    ///
81    /// The specified PID does not exist or has already exited.
82    #[error("Process {pid} not found")]
83    NotFound {
84        /// The process ID that was not found.
85        pid: u32,
86    },
87
88    /// Command not found.
89    ///
90    /// The specified command could not be found in PATH.
91    #[error("Command '{command}' not found")]
92    NotFoundCommand {
93        /// The command that was not found.
94        command: String,
95    },
96
97    /// Permission denied for command execution.
98    ///
99    /// The specified command exists but cannot be executed (e.g., not executable).
100    #[error("Permission denied: cannot execute '{command}'")]
101    PermissionDeniedCommand {
102        /// The command that could not be executed.
103        command: String,
104    },
105
106    /// Permission denied for a filesystem path.
107    ///
108    /// Used when opening caller-provided paths such as nohup output targets.
109    #[error("Permission denied for '{operation}' on path '{path}'")]
110    PermissionDeniedPath {
111        /// The path that could not be accessed.
112        path: String,
113        /// The operation that was denied.
114        operation: String,
115    },
116
117    /// Operation not supported on the current platform.
118    ///
119    /// Some operations are platform-specific (e.g., `killpg` on Windows).
120    #[error("Operation '{feature}' not supported on {platform}")]
121    NotSupported {
122        /// The feature that is not supported.
123        feature: String,
124        /// The platform where it's not supported.
125        platform: String,
126    },
127
128    /// Failed to create process group or job object.
129    ///
130    /// On Unix, this means `setpgid()` failed.
131    /// On Windows, this means Job Object creation failed.
132    #[error("Failed to create process group: {message}")]
133    GroupCreationFailed {
134        /// Description of what failed.
135        message: String,
136    },
137
138    /// System-level error with errno/GetLastError context.
139    ///
140    /// Used when a syscall fails with an unexpected error code.
141    #[error("System error: {message} (errno: {errno})")]
142    System {
143        /// Description of the error.
144        message: String,
145        /// The errno value (Unix) or GetLastError (Windows).
146        errno: i32,
147    },
148
149    /// Internal error (should not happen in normal operation).
150    ///
151    /// Indicates a bug in sysprims or unexpected system state.
152    #[error("Internal error: {message}")]
153    Internal {
154        /// Description of the internal error.
155        message: String,
156    },
157}
158
159impl SysprimsError {
160    /// Get the FFI error code for this error.
161    ///
162    /// Maps to `SysprimsErrorCode` enum in C-ABI.
163    pub fn error_code(&self) -> i32 {
164        match self {
165            SysprimsError::InvalidArgument { .. } => 1,
166            SysprimsError::SpawnFailed { .. } => 2,
167            SysprimsError::Timeout => 3,
168            SysprimsError::PermissionDenied { .. } => 4,
169            SysprimsError::PermissionDeniedCommand { .. } => 4,
170            SysprimsError::PermissionDeniedPath { .. } => 4,
171            SysprimsError::NotFound { .. } => 5,
172            SysprimsError::NotFoundCommand { .. } => 5,
173            SysprimsError::NotSupported { .. } => 6,
174            SysprimsError::GroupCreationFailed { .. } => 7,
175            SysprimsError::System { .. } => 8,
176            SysprimsError::Internal { .. } => 99,
177        }
178    }
179}
180
181// ============================================================================
182// Convenience Constructors
183// ============================================================================
184
185impl SysprimsError {
186    /// Create an `InvalidArgument` error.
187    pub fn invalid_argument(message: impl Into<String>) -> Self {
188        SysprimsError::InvalidArgument {
189            message: message.into(),
190        }
191    }
192
193    /// Create a `SpawnFailed` error from an IO error.
194    pub fn spawn_failed_io(source: io::Error) -> Self {
195        SysprimsError::SpawnFailed { source }
196    }
197
198    /// Create a `PermissionDenied` error.
199    pub fn permission_denied(pid: u32, operation: impl Into<String>) -> Self {
200        SysprimsError::PermissionDenied {
201            pid,
202            operation: operation.into(),
203        }
204    }
205
206    /// Create a `NotFound` error.
207    pub fn not_found(pid: u32) -> Self {
208        SysprimsError::NotFound { pid }
209    }
210
211    /// Create a `NotFoundCommand` error.
212    pub fn not_found_command(command: impl Into<String>) -> Self {
213        SysprimsError::NotFoundCommand {
214            command: command.into(),
215        }
216    }
217
218    /// Create a `PermissionDeniedCommand` error.
219    pub fn permission_denied_command(command: impl Into<String>) -> Self {
220        SysprimsError::PermissionDeniedCommand {
221            command: command.into(),
222        }
223    }
224
225    /// Create a path-oriented permission denied error.
226    pub fn permission_denied_path(path: impl Into<String>, operation: impl Into<String>) -> Self {
227        SysprimsError::PermissionDeniedPath {
228            path: path.into(),
229            operation: operation.into(),
230        }
231    }
232
233    /// Create a `SpawnFailed` error with a command and reason.
234    pub fn spawn_failed(command: impl Into<String>, reason: impl Into<String>) -> Self {
235        let msg = format!("{}: {}", command.into(), reason.into());
236        SysprimsError::SpawnFailed {
237            source: io::Error::other(msg),
238        }
239    }
240
241    /// Create a `NotSupported` error.
242    pub fn not_supported(feature: impl Into<String>, platform: impl Into<String>) -> Self {
243        SysprimsError::NotSupported {
244            feature: feature.into(),
245            platform: platform.into(),
246        }
247    }
248
249    /// Create a `GroupCreationFailed` error.
250    pub fn group_creation_failed(message: impl Into<String>) -> Self {
251        SysprimsError::GroupCreationFailed {
252            message: message.into(),
253        }
254    }
255
256    /// Create a `System` error.
257    pub fn system(message: impl Into<String>, errno: i32) -> Self {
258        SysprimsError::System {
259            message: message.into(),
260            errno,
261        }
262    }
263
264    /// Create an `Internal` error.
265    pub fn internal(message: impl Into<String>) -> Self {
266        SysprimsError::Internal {
267            message: message.into(),
268        }
269    }
270}
271
272// ============================================================================
273// Conversions
274// ============================================================================
275
276impl From<io::Error> for SysprimsError {
277    fn from(source: io::Error) -> Self {
278        // Map common IO errors to structured variants
279        match source.kind() {
280            io::ErrorKind::NotFound => SysprimsError::Internal {
281                message: format!("IO not found: {}", source),
282            },
283            io::ErrorKind::PermissionDenied => SysprimsError::Internal {
284                message: format!("IO permission denied: {}", source),
285            },
286            _ => SysprimsError::SpawnFailed { source },
287        }
288    }
289}
290
291// ============================================================================
292// Result Type Alias
293// ============================================================================
294
295/// Result type alias for sysprims operations.
296pub type SysprimsResult<T> = Result<T, SysprimsError>;
297
298// ============================================================================
299// Tests
300// ============================================================================
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_error_display() {
308        let err = SysprimsError::invalid_argument("pid must be > 0");
309        assert_eq!(err.to_string(), "Invalid argument: pid must be > 0");
310
311        let err = SysprimsError::permission_denied(1234, "terminate");
312        assert_eq!(
313            err.to_string(),
314            "Permission denied for 'terminate' on PID 1234"
315        );
316
317        let err = SysprimsError::not_found(5678);
318        assert_eq!(err.to_string(), "Process 5678 not found");
319
320        let err = SysprimsError::not_supported("killpg", "windows");
321        assert_eq!(
322            err.to_string(),
323            "Operation 'killpg' not supported on windows"
324        );
325
326        let err = SysprimsError::Timeout;
327        assert_eq!(err.to_string(), "Operation timed out");
328    }
329
330    #[test]
331    fn test_error_codes() {
332        assert_eq!(SysprimsError::invalid_argument("").error_code(), 1);
333        assert_eq!(
334            SysprimsError::spawn_failed_io(io::Error::other("test")).error_code(),
335            2
336        );
337        assert_eq!(SysprimsError::Timeout.error_code(), 3);
338        assert_eq!(SysprimsError::permission_denied(0, "").error_code(), 4);
339        assert_eq!(SysprimsError::not_found(0).error_code(), 5);
340        assert_eq!(SysprimsError::not_supported("", "").error_code(), 6);
341        assert_eq!(SysprimsError::group_creation_failed("").error_code(), 7);
342        assert_eq!(SysprimsError::system("", 0).error_code(), 8);
343        assert_eq!(SysprimsError::internal("").error_code(), 99);
344    }
345
346    #[test]
347    fn test_spawn_failed_source() {
348        let io_err = io::Error::new(io::ErrorKind::NotFound, "command not found");
349        let err = SysprimsError::spawn_failed_io(io_err);
350
351        // Verify source is accessible
352        match err {
353            SysprimsError::SpawnFailed { ref source } => {
354                assert_eq!(source.kind(), io::ErrorKind::NotFound);
355            }
356            _ => panic!("Expected SpawnFailed"),
357        }
358    }
359
360    #[test]
361    fn test_pid_is_u32() {
362        // Verify PIDs are unsigned (ABI alignment per ADR-0008)
363        let err = SysprimsError::permission_denied(u32::MAX, "signal");
364        match err {
365            SysprimsError::PermissionDenied { pid, .. } => {
366                assert_eq!(pid, u32::MAX);
367            }
368            _ => panic!("Expected PermissionDenied"),
369        }
370
371        let err = SysprimsError::not_found(u32::MAX);
372        match err {
373            SysprimsError::NotFound { pid } => {
374                assert_eq!(pid, u32::MAX);
375            }
376            _ => panic!("Expected NotFound"),
377        }
378    }
379
380    #[test]
381    fn test_io_error_conversion() {
382        let io_err = io::Error::other("test error");
383        let sysprims_err: SysprimsError = io_err.into();
384
385        match sysprims_err {
386            SysprimsError::SpawnFailed { source } => {
387                assert_eq!(source.kind(), io::ErrorKind::Other);
388            }
389            _ => panic!("Expected SpawnFailed from IO error"),
390        }
391    }
392}