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            // A key file that has gone missing is not a missing passphrase, and
214            // the generic advice sends the user to set LIT_PASSPHRASE, which
215            // will not help and risks them "fixing" it by letting a new key be
216            // created over data the old one holds.
217            LitError::General(msg) | LitError::IO(msg) | LitError::Encryption(msg)
218                if msg.contains("its key file is missing") =>
219            {
220                vec![
221                    "The repository is encrypted and its key file is gone; a new key cannot open it",
222                    "Restore the key file from backup, or point key_file in .lit/encryption.toml at it",
223                ]
224            }
225            // A key file shared between repositories holds one passphrase, so
226            // the rejection is about which repository the key belongs to.
227            LitError::General(msg) | LitError::IO(msg) | LitError::Encryption(msg)
228                if msg.contains("shared key file") =>
229            {
230                vec![
231                    "This key file is shared between repositories and holds a single passphrase",
232                    "Remove key_file from .lit/encryption.toml to give this repository its own key",
233                ]
234            }
235            LitError::General(msg) | LitError::IO(msg)
236                if msg.contains("Encryption not initialized") =>
237            {
238                vec![
239                    "Set LIT_PASSPHRASE or LIT_PASSPHRASE_FILE to unlock the repository",
240                    "Check encryption settings in .lit/encryption.toml",
241                ]
242            }
243            LitError::Repository(msg)
244                if msg.contains("not found") || msg.contains("No .lit directory") =>
245            {
246                vec![
247                    "Run 'lit init' to create a repository",
248                    "Check that you are in the correct directory",
249                ]
250            }
251            LitError::Object(_) => {
252                vec![
253                    "Verify the object hash is correct",
254                    "Run 'lit verify' to check repository integrity",
255                ]
256            }
257            LitError::Network(_) => {
258                vec![
259                    "Check remote URL configuration with 'lit remote list'",
260                    "Verify network/airgap settings with 'lit config show'",
261                ]
262            }
263            // A refused agent handshake is not a wrong passphrase, and saying
264            // so sends the user to check the one thing that is not the problem.
265            // What actually happened is that something other than the agent is
266            // on the recorded port, usually because the agent died without
267            // clearing its endpoint file.
268            LitError::Encryption(msg) if msg.contains("could not prove it is the agent") => {
269                vec![
270                    "Stop the stale agent with 'lit agent stop', then start a new one",
271                    "Nothing was sent to the process on that port",
272                ]
273            }
274            LitError::Encryption(_) => {
275                vec![
276                    "Verify passphrase is correct",
277                    "Check encryption configuration",
278                ]
279            }
280            LitError::General(msg) if msg.contains("not yet implemented") => {
281                vec!["This feature is planned for a future release"]
282            }
283            _ => vec![],
284        }
285    }
286
287    /// Log detailed error to secure log file (not stdout/stderr)
288    pub fn log_detailed(&self) {
289        // Only log if debug logging is enabled
290        if std::env::var("LIT_DEBUG").is_ok() {
291            let log_path = get_secure_log_path();
292            if let Ok(path) = log_path {
293                use std::fs::OpenOptions;
294                use std::io::Write;
295
296                let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
297                let log_entry =
298                    format!("[{}] {:?}: {}\n", timestamp, self, self.internal_message());
299
300                if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
301                    let _ = file.write_all(log_entry.as_bytes());
302                }
303            }
304        }
305    }
306}
307
308/// Display implementation shows sanitized error messages
309/// SECURITY: Does not expose file paths, internal state, or detailed errors
310impl fmt::Display for LitError {
311    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
312        match self {
313            LitError::Encryption(_) => write!(f, "Encryption operation failed"),
314            LitError::IO(_) => write!(f, "I/O operation failed"),
315            LitError::Config(_) => write!(f, "Configuration error"),
316            LitError::Network(_) => write!(f, "Network operation failed"),
317            LitError::Repository(_) => write!(f, "Repository operation failed"),
318            LitError::Object(_) => write!(f, "Object operation failed"),
319            LitError::Index(_) => write!(f, "Index operation failed"),
320            LitError::General(_) => write!(f, "Operation failed"),
321        }
322    }
323}
324
325impl std::error::Error for LitError {}
326
327/// Convert from io::Error
328impl From<io::Error> for LitError {
329    fn from(err: io::Error) -> Self {
330        LitError::IO(err.to_string())
331    }
332}
333
334/// Convert from String for backward compatibility
335impl From<String> for LitError {
336    fn from(msg: String) -> Self {
337        LitError::General(msg)
338    }
339}
340
341/// Convert from &str for convenience
342impl From<&str> for LitError {
343    fn from(msg: &str) -> Self {
344        LitError::General(msg.to_string())
345    }
346}
347
348/// Get secure log file path
349fn get_secure_log_path() -> Result<std::path::PathBuf, String> {
350    let home = dirs::home_dir().ok_or("Could not determine home directory")?;
351    let log_dir = home.join(".lit").join("logs");
352
353    // Create log directory if it doesn't exist
354    std::fs::create_dir_all(&log_dir)
355        .map_err(|e| format!("Failed to create log directory: {}", e))?;
356
357    let log_file = log_dir.join("debug.log");
358
359    // Ensure restrictive permissions on log file
360    #[cfg(unix)]
361    {
362        use std::fs::OpenOptions;
363        use std::os::unix::fs::PermissionsExt;
364
365        // `create_new` rather than `create`: the only goal is to bring the file
366        // into existence so the mode can be tightened below. Should it appear
367        // between the check and the open, this fails harmlessly instead of
368        // truncating a log that is already being written.
369        if !log_file.exists() {
370            OpenOptions::new()
371                .create_new(true)
372                .write(true)
373                .open(&log_file)
374                .ok();
375        }
376
377        if let Ok(metadata) = std::fs::metadata(&log_file) {
378            let mut perms = metadata.permissions();
379            perms.set_mode(0o600); // Owner read/write only
380            std::fs::set_permissions(&log_file, perms).ok();
381        }
382    }
383
384    Ok(log_file)
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_error_display_sanitization() {
393        let err = LitError::encryption("Detailed: failed to decrypt /home/user/secret/file.txt");
394        assert_eq!(err.to_string(), "Encryption operation failed");
395        assert!(!err.to_string().contains("/home"));
396        assert!(!err.to_string().contains("secret"));
397    }
398
399    #[test]
400    fn test_internal_message_access() {
401        let err = LitError::io("Failed to read /etc/shadow");
402        assert_eq!(err.internal_message(), "Failed to read /etc/shadow");
403    }
404
405    #[test]
406    fn test_error_conversion() {
407        let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
408        let lit_err: LitError = io_err.into();
409        assert_eq!(lit_err.to_string(), "I/O operation failed");
410    }
411}