Skip to main content

crates_docs/cli/
revoke_api_key_cmd.rs

1//! Revoke API key command implementation
2
3use std::fs;
4use std::path::Path;
5
6/// Revoke an API key from configuration file.
7///
8/// Removes the specified key hash or key ID from the configuration file.
9/// The configuration file format and comments are preserved.
10///
11/// # Errors
12///
13/// Returns an error if:
14/// - The configuration file cannot be read or written
15/// - The specified key is not found
16/// - The configuration file format is invalid
17pub fn run_revoke_api_key_command(
18    config_path: &Path,
19    key_to_revoke: &str,
20) -> Result<(), Box<dyn std::error::Error>> {
21    // Check if config file exists
22    if !config_path.exists() {
23        return Err(format!("Configuration file not found: {}", config_path.display()).into());
24    }
25
26    // Read the configuration file content
27    let content = fs::read_to_string(config_path)
28        .map_err(|e| format!("Failed to read configuration file: {e}"))?;
29
30    // Parse as TOML document (preserves comments and formatting)
31    let mut doc = content
32        .parse::<toml_edit::DocumentMut>()
33        .map_err(|e| format!("Failed to parse configuration file: {e}"))?;
34
35    // Locate the keys array. The canonical location is [auth.api_key], but we
36    // also fall back to a legacy top-level [api_key] table for compatibility.
37    let keys_array = find_keys_array_mut(&mut doc).ok_or(
38        "API key configuration section not found in config file (expected [auth.api_key])",
39    )?;
40
41    // Find and remove the key
42    let mut found = false;
43    let mut indices_to_remove = Vec::new();
44
45    for (index, item) in keys_array.iter().enumerate() {
46        if let Some(key_value) = item.as_str() {
47            // Match deterministically to avoid accidentally revoking the wrong
48            // key. We accept either:
49            //   1. An exact match of the stored value (full hash or plaintext).
50            //   2. An exact match of the Argon2 PHC salt segment.
51            // Loose substring matching is intentionally NOT supported because it
52            // can revoke unintended keys.
53            if key_value == key_to_revoke {
54                indices_to_remove.push(index);
55                found = true;
56                break;
57            }
58
59            // Argon2 PHC format: $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>
60            // Splitting on '$' yields: ["", "argon2id", "v=19", "params",
61            // "<salt>", "<hash>"], so the salt is at index 4 and the hash at 5.
62            if key_value.starts_with("$argon2") {
63                let parts: Vec<&str> = key_value.split('$').collect();
64                if parts.len() >= 6 {
65                    let salt = parts[4];
66                    let hash = parts[5];
67                    if salt == key_to_revoke || hash == key_to_revoke {
68                        indices_to_remove.push(index);
69                        found = true;
70                        break;
71                    }
72                }
73            }
74        }
75    }
76
77    if !found {
78        println!("Key not found in configuration: {key_to_revoke}");
79        println!();
80        println!("Tip: Use 'crates-docs list-api-keys' to see all configured keys.");
81        return Err("Key not found".into());
82    }
83
84    // Remove the key(s) - remove from highest index first to maintain validity
85    indices_to_remove.sort_unstable();
86    for index in indices_to_remove.iter().rev() {
87        keys_array.remove(*index);
88    }
89
90    // Write back to file
91    let new_content = doc.to_string();
92    fs::write(config_path, new_content)
93        .map_err(|e| format!("Failed to write configuration file: {e}"))?;
94
95    println!("API key revoked successfully!");
96    println!();
97    println!(
98        "Removed {} key(s) from: {}",
99        indices_to_remove.len(),
100        config_path.display()
101    );
102    println!();
103    println!(
104        "Note: If the server is running, you may need to restart it for changes to take effect."
105    );
106    println!("      Or use hot-reload feature if available.");
107
108    Ok(())
109}
110
111/// Locate the mutable `keys` array in the configuration document.
112///
113/// Prefers the canonical `[auth.api_key]` table and falls back to a legacy
114/// top-level `[api_key]` table for backward compatibility.
115fn find_keys_array_mut(doc: &mut toml_edit::DocumentMut) -> Option<&mut toml_edit::Array> {
116    // Prefer [auth.api_key].keys
117    let in_auth = doc
118        .get("auth")
119        .and_then(toml_edit::Item::as_table)
120        .and_then(|t| t.get("api_key"))
121        .and_then(toml_edit::Item::as_table)
122        .and_then(|t| t.get("keys"))
123        .and_then(toml_edit::Item::as_array)
124        .is_some();
125
126    if in_auth {
127        return doc
128            .get_mut("auth")
129            .and_then(toml_edit::Item::as_table_mut)
130            .and_then(|t| t.get_mut("api_key"))
131            .and_then(toml_edit::Item::as_table_mut)
132            .and_then(|t| t.get_mut("keys"))
133            .and_then(toml_edit::Item::as_array_mut);
134    }
135
136    // Fall back to legacy top-level [api_key].keys
137    doc.get_mut("api_key")
138        .and_then(toml_edit::Item::as_table_mut)
139        .and_then(|t| t.get_mut("keys"))
140        .and_then(toml_edit::Item::as_array_mut)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::io::Write;
147    use tempfile::NamedTempFile;
148
149    #[test]
150    fn test_revoke_api_key_removes_key() {
151        let mut temp_file = NamedTempFile::new().unwrap();
152        let content = r#"
153[server]
154host = "127.0.0.1"
155port = 8080
156
157[auth.api_key]
158enabled = true
159keys = ["$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1", "$argon2id$v=19$m=47104,t=1,p=1$c2FsdB$hash2"]
160header_name = "X-API-Key"
161"#;
162        temp_file.write_all(content.as_bytes()).unwrap();
163
164        let path = temp_file.path();
165        let result =
166            run_revoke_api_key_command(path, "$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1");
167        assert!(result.is_ok());
168
169        // Verify the key was removed
170        let new_content = std::fs::read_to_string(path).unwrap();
171        assert!(new_content.contains("hash2"));
172        assert!(!new_content.contains("hash1"));
173    }
174
175    #[test]
176    fn test_revoke_api_key_preserves_comments() {
177        let mut temp_file = NamedTempFile::new().unwrap();
178        let content = r#"
179[server]
180# Server configuration
181host = "127.0.0.1"
182port = 8080
183
184[auth.api_key]
185# API key configuration
186enabled = true
187# List of API key hashes
188keys = ["$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1", "$argon2id$v=19$m=47104,t=1,p=1$c2FsdB$hash2"]
189header_name = "X-API-Key"
190"#;
191        temp_file.write_all(content.as_bytes()).unwrap();
192
193        let path = temp_file.path();
194        let result =
195            run_revoke_api_key_command(path, "$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1");
196        assert!(result.is_ok());
197
198        // Verify comments are preserved
199        let new_content = std::fs::read_to_string(path).unwrap();
200        assert!(new_content.contains("# Server configuration"));
201        assert!(new_content.contains("# API key configuration"));
202        assert!(new_content.contains("# List of API key hashes"));
203    }
204
205    #[test]
206    fn test_revoke_api_key_not_found() {
207        let mut temp_file = NamedTempFile::new().unwrap();
208        let content = r#"
209[auth.api_key]
210enabled = true
211keys = ["$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1"]
212"#;
213        temp_file.write_all(content.as_bytes()).unwrap();
214
215        let path = temp_file.path();
216        let result = run_revoke_api_key_command(path, "nonexistent_key");
217        assert!(result.is_err());
218    }
219
220    #[test]
221    fn test_revoke_api_key_file_not_found() {
222        let result = run_revoke_api_key_command(Path::new("/nonexistent/config.toml"), "key");
223        assert!(result.is_err());
224    }
225
226    #[test]
227    fn test_revoke_api_key_legacy_top_level_section() {
228        let mut temp_file = NamedTempFile::new().unwrap();
229        let content = r#"
230[api_key]
231enabled = true
232keys = ["$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1", "$argon2id$v=19$m=47104,t=1,p=1$c2FsdB$hash2"]
233"#;
234        temp_file.write_all(content.as_bytes()).unwrap();
235        let path = temp_file.path();
236        let result =
237            run_revoke_api_key_command(path, "$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1");
238        assert!(result.is_ok());
239        let new_content = std::fs::read_to_string(path).unwrap();
240        assert!(new_content.contains("hash2"));
241        assert!(!new_content.contains("hash1"));
242    }
243
244    #[test]
245    fn test_revoke_api_key_substring_does_not_match() {
246        // A loose substring of a stored hash must NOT revoke any key.
247        let mut temp_file = NamedTempFile::new().unwrap();
248        let content = r#"
249[auth.api_key]
250enabled = true
251keys = ["$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1"]
252"#;
253        temp_file.write_all(content.as_bytes()).unwrap();
254        let path = temp_file.path();
255        // "hash1" is a substring but not an exact value/salt/hash segment match.
256        let result = run_revoke_api_key_command(path, "argon2id");
257        assert!(result.is_err());
258        let new_content = std::fs::read_to_string(path).unwrap();
259        assert!(new_content.contains("hash1"));
260    }
261
262    #[test]
263    fn test_revoke_api_key_by_salt_segment() {
264        let mut temp_file = NamedTempFile::new().unwrap();
265        let content = r#"
266[auth.api_key]
267enabled = true
268keys = ["$argon2id$v=19$m=47104,t=1,p=1$c2FsdA$hash1", "$argon2id$v=19$m=47104,t=1,p=1$c2FsdB$hash2"]
269"#;
270        temp_file.write_all(content.as_bytes()).unwrap();
271        let path = temp_file.path();
272        // Revoke by the unique salt segment of the second key.
273        let result = run_revoke_api_key_command(path, "c2FsdB");
274        assert!(result.is_ok());
275        let new_content = std::fs::read_to_string(path).unwrap();
276        assert!(new_content.contains("hash1"));
277        assert!(!new_content.contains("hash2"));
278    }
279}