Skip to main content

lit/
errors.rs

1/// Centralized Error Types for Lit
2/// Provides sanitized error messages that don't leak sensitive information
3/// and machine-readable error codes for agentic consumption
4use std::fmt;
5use std::io;
6
7/// Machine-readable error codes for structured output
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum ErrorCode {
10    RepoNotFound,
11    RepoCorrupt,
12    RefNotFound,
13    RefConflict,
14    MergeConflict,
15    IndexLocked,
16    AuthFailed,
17    TransportDenied,
18    CryptoError,
19    ObjectNotFound,
20    InvalidInput,
21    NotImplemented,
22    IoError,
23    ConfigError,
24    GeneralError,
25}
26
27impl ErrorCode {
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            ErrorCode::RepoNotFound => "REPO_NOT_FOUND",
31            ErrorCode::RepoCorrupt => "REPO_CORRUPT",
32            ErrorCode::RefNotFound => "REF_NOT_FOUND",
33            ErrorCode::RefConflict => "REF_CONFLICT",
34            ErrorCode::MergeConflict => "MERGE_CONFLICT",
35            ErrorCode::IndexLocked => "INDEX_LOCKED",
36            ErrorCode::AuthFailed => "AUTH_FAILED",
37            ErrorCode::TransportDenied => "TRANSPORT_DENIED",
38            ErrorCode::CryptoError => "CRYPTO_ERROR",
39            ErrorCode::ObjectNotFound => "OBJECT_NOT_FOUND",
40            ErrorCode::InvalidInput => "INVALID_INPUT",
41            ErrorCode::NotImplemented => "NOT_IMPLEMENTED",
42            ErrorCode::IoError => "IO_ERROR",
43            ErrorCode::ConfigError => "CONFIG_ERROR",
44            ErrorCode::GeneralError => "GENERAL_ERROR",
45        }
46    }
47}
48
49/// Main error type for Lit operations
50#[derive(Debug)]
51pub enum LitError {
52    /// Encryption-related errors (passphrase, key derivation, etc.)
53    Encryption(String),
54    /// I/O errors (file read/write)
55    IO(String),
56    /// Configuration errors
57    Config(String),
58    /// Network-related errors
59    Network(String),
60    /// Repository structure errors
61    Repository(String),
62    /// Git object errors
63    Object(String),
64    /// Index errors
65    Index(String),
66    /// General errors
67    General(String),
68}
69
70impl LitError {
71    /// Create an encryption error with detailed internal message
72    pub fn encryption(internal_msg: impl Into<String>) -> Self {
73        LitError::Encryption(internal_msg.into())
74    }
75
76    /// Create an I/O error with detailed internal message
77    pub fn io(internal_msg: impl Into<String>) -> Self {
78        LitError::IO(internal_msg.into())
79    }
80
81    /// Create a config error with detailed internal message
82    pub fn config(internal_msg: impl Into<String>) -> Self {
83        LitError::Config(internal_msg.into())
84    }
85
86    /// Create a network error with detailed internal message
87    pub fn network(internal_msg: impl Into<String>) -> Self {
88        LitError::Network(internal_msg.into())
89    }
90
91    /// Create a repository error with detailed internal message
92    pub fn repository(internal_msg: impl Into<String>) -> Self {
93        LitError::Repository(internal_msg.into())
94    }
95
96    /// Create an object error with detailed internal message
97    pub fn object(internal_msg: impl Into<String>) -> Self {
98        LitError::Object(internal_msg.into())
99    }
100
101    /// Create an index error with detailed internal message
102    pub fn index(internal_msg: impl Into<String>) -> Self {
103        LitError::Index(internal_msg.into())
104    }
105
106    /// Create a general error with detailed internal message
107    pub fn general(internal_msg: impl Into<String>) -> Self {
108        LitError::General(internal_msg.into())
109    }
110
111    /// Get the internal detailed error message (for logging only)
112    pub fn internal_message(&self) -> &str {
113        match self {
114            LitError::Encryption(msg) => msg,
115            LitError::IO(msg) => msg,
116            LitError::Config(msg) => msg,
117            LitError::Network(msg) => msg,
118            LitError::Repository(msg) => msg,
119            LitError::Object(msg) => msg,
120            LitError::Index(msg) => msg,
121            LitError::General(msg) => msg,
122        }
123    }
124
125    /// Machine-readable error code for structured output
126    pub fn error_code(&self) -> &'static str {
127        match self {
128            LitError::Encryption(_) => ErrorCode::CryptoError.as_str(),
129            LitError::IO(_) => ErrorCode::IoError.as_str(),
130            LitError::Config(_) => ErrorCode::ConfigError.as_str(),
131            LitError::Network(_) => ErrorCode::TransportDenied.as_str(),
132            LitError::Repository(msg) => {
133                if msg.contains("not found")
134                    || msg.contains("No .lit directory")
135                    || msg.contains("find_repo_root")
136                {
137                    ErrorCode::RepoNotFound.as_str()
138                } else {
139                    ErrorCode::RepoCorrupt.as_str()
140                }
141            }
142            LitError::Object(msg) => {
143                if msg.contains("not found") || msg.contains("No such") {
144                    ErrorCode::ObjectNotFound.as_str()
145                } else {
146                    ErrorCode::GeneralError.as_str()
147                }
148            }
149            LitError::Index(_) => ErrorCode::GeneralError.as_str(),
150            LitError::General(msg) => {
151                if msg.contains("not yet implemented") || msg.contains("not yet fully implemented")
152                {
153                    ErrorCode::NotImplemented.as_str()
154                } else if msg.contains("not found") {
155                    ErrorCode::RefNotFound.as_str()
156                } else {
157                    ErrorCode::GeneralError.as_str()
158                }
159            }
160        }
161    }
162
163    /// User-facing error message (safe to display — strips internal details)
164    /// SECURITY: Returns category-based messages to prevent information disclosure (FINDING-003)
165    pub fn user_message(&self) -> &str {
166        match self {
167            LitError::Encryption(_) => "Encryption operation failed",
168            LitError::IO(_) => "I/O operation failed",
169            LitError::Config(_) => "Configuration error",
170            LitError::Network(_) => "Network operation failed",
171            LitError::Repository(msg) => {
172                if msg.contains("not found") || msg.contains("No .lit directory") {
173                    "Not in a Lit repository"
174                } else {
175                    "Repository error"
176                }
177            }
178            LitError::Object(msg) => {
179                if msg.contains("not found") || msg.contains("No such") {
180                    "Object not found"
181                } else {
182                    "Object error"
183                }
184            }
185            LitError::Index(_) => "Index error",
186            LitError::General(msg) => {
187                if msg.contains("not yet implemented") || msg.contains("not yet fully implemented")
188                {
189                    "Feature not yet implemented"
190                } else if msg.contains("not found") {
191                    "Resource not found"
192                } else {
193                    "Operation failed"
194                }
195            }
196        }
197    }
198
199    /// Actionable suggestions for agents to resolve the error
200    pub fn suggestions(&self) -> Vec<&'static str> {
201        match self {
202            // The rendered message is deliberately sanitized, so these two
203            // encryption cases would otherwise reach the user as a bare
204            // "Operation failed" with nothing to act on.
205            LitError::General(msg) | LitError::IO(msg)
206                if msg.contains("no Lit encryption header") =>
207            {
208                vec![
209                    "Encryption cannot be enabled for a repository that already has commits",
210                    "Create a new repository with encryption enabled and import into it",
211                ]
212            }
213            LitError::General(msg) | LitError::IO(msg)
214                if msg.contains("Encryption not initialized") =>
215            {
216                vec![
217                    "Set LIT_PASSPHRASE or LIT_PASSPHRASE_FILE to unlock the repository",
218                    "Check encryption settings in .lit/encryption.toml",
219                ]
220            }
221            LitError::Repository(msg)
222                if msg.contains("not found") || msg.contains("No .lit directory") =>
223            {
224                vec![
225                    "Run 'lit init' to create a repository",
226                    "Check that you are in the correct directory",
227                ]
228            }
229            LitError::Object(_) => {
230                vec![
231                    "Verify the object hash is correct",
232                    "Run 'lit verify' to check repository integrity",
233                ]
234            }
235            LitError::Network(_) => {
236                vec![
237                    "Check remote URL configuration with 'lit remote list'",
238                    "Verify network/airgap settings with 'lit config show'",
239                ]
240            }
241            LitError::Encryption(_) => {
242                vec![
243                    "Verify passphrase is correct",
244                    "Check encryption configuration",
245                ]
246            }
247            LitError::General(msg) if msg.contains("not yet implemented") => {
248                vec!["This feature is planned for a future release"]
249            }
250            _ => vec![],
251        }
252    }
253
254    /// Log detailed error to secure log file (not stdout/stderr)
255    pub fn log_detailed(&self) {
256        // Only log if debug logging is enabled
257        if std::env::var("LIT_DEBUG").is_ok() {
258            let log_path = get_secure_log_path();
259            if let Ok(path) = log_path {
260                use std::fs::OpenOptions;
261                use std::io::Write;
262
263                let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
264                let log_entry =
265                    format!("[{}] {:?}: {}\n", timestamp, self, self.internal_message());
266
267                if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
268                    let _ = file.write_all(log_entry.as_bytes());
269                }
270            }
271        }
272    }
273}
274
275/// Display implementation shows sanitized error messages
276/// SECURITY: Does not expose file paths, internal state, or detailed errors
277impl fmt::Display for LitError {
278    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
279        match self {
280            LitError::Encryption(_) => write!(f, "Encryption operation failed"),
281            LitError::IO(_) => write!(f, "I/O operation failed"),
282            LitError::Config(_) => write!(f, "Configuration error"),
283            LitError::Network(_) => write!(f, "Network operation failed"),
284            LitError::Repository(_) => write!(f, "Repository operation failed"),
285            LitError::Object(_) => write!(f, "Object operation failed"),
286            LitError::Index(_) => write!(f, "Index operation failed"),
287            LitError::General(_) => write!(f, "Operation failed"),
288        }
289    }
290}
291
292impl std::error::Error for LitError {}
293
294/// Convert from io::Error
295impl From<io::Error> for LitError {
296    fn from(err: io::Error) -> Self {
297        LitError::IO(err.to_string())
298    }
299}
300
301/// Convert from String for backward compatibility
302impl From<String> for LitError {
303    fn from(msg: String) -> Self {
304        LitError::General(msg)
305    }
306}
307
308/// Convert from &str for convenience
309impl From<&str> for LitError {
310    fn from(msg: &str) -> Self {
311        LitError::General(msg.to_string())
312    }
313}
314
315/// Get secure log file path
316fn get_secure_log_path() -> Result<std::path::PathBuf, String> {
317    let home = dirs::home_dir().ok_or("Could not determine home directory")?;
318    let log_dir = home.join(".lit").join("logs");
319
320    // Create log directory if it doesn't exist
321    std::fs::create_dir_all(&log_dir)
322        .map_err(|e| format!("Failed to create log directory: {}", e))?;
323
324    let log_file = log_dir.join("debug.log");
325
326    // Ensure restrictive permissions on log file
327    #[cfg(unix)]
328    {
329        use std::fs::OpenOptions;
330        use std::os::unix::fs::PermissionsExt;
331
332        // `create_new` rather than `create`: the only goal is to bring the file
333        // into existence so the mode can be tightened below. Should it appear
334        // between the check and the open, this fails harmlessly instead of
335        // truncating a log that is already being written.
336        if !log_file.exists() {
337            OpenOptions::new()
338                .create_new(true)
339                .write(true)
340                .open(&log_file)
341                .ok();
342        }
343
344        if let Ok(metadata) = std::fs::metadata(&log_file) {
345            let mut perms = metadata.permissions();
346            perms.set_mode(0o600); // Owner read/write only
347            std::fs::set_permissions(&log_file, perms).ok();
348        }
349    }
350
351    Ok(log_file)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_error_display_sanitization() {
360        let err = LitError::encryption("Detailed: failed to decrypt /home/user/secret/file.txt");
361        assert_eq!(err.to_string(), "Encryption operation failed");
362        assert!(!err.to_string().contains("/home"));
363        assert!(!err.to_string().contains("secret"));
364    }
365
366    #[test]
367    fn test_internal_message_access() {
368        let err = LitError::io("Failed to read /etc/shadow");
369        assert_eq!(err.internal_message(), "Failed to read /etc/shadow");
370    }
371
372    #[test]
373    fn test_error_conversion() {
374        let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
375        let lit_err: LitError = io_err.into();
376        assert_eq!(lit_err.to_string(), "I/O operation failed");
377    }
378}