Skip to main content

rusty_commit/config/
secure_storage.rs

1//! Secure storage for sensitive configuration values.
2//!
3//! This module provides optional secure storage for API keys and other
4//! sensitive data using the system keychain when available.
5//!
6//! Platform support:
7//! - **macOS**: Uses the macOS Keychain via Security Framework
8//! - **Linux**: Uses Secret Service API (GNOME Keyring, KWallet, etc.)
9//! - **Windows**: Uses Windows Credential Manager (wincred)
10//! - **iOS**: Uses iOS Keychain Services
11//! - **FreeBSD/OpenBSD**: Uses Secret Service if available
12
13#[cfg(feature = "secure-storage")]
14use anyhow::anyhow;
15use anyhow::Result;
16
17#[cfg(feature = "secure-storage")]
18use keyring::Entry;
19
20#[allow(dead_code)]
21const SERVICE_NAME: &str = "rustycommit";
22
23/// Store a secret securely in the system keyring.
24///
25/// Platform behavior:
26/// - macOS: Stores in login keychain
27/// - Linux: Stores in Secret Service (GNOME Keyring/KWallet)
28/// - Windows: Stores in Windows Credential Manager
29///
30/// If the secure-storage feature is not enabled or the system doesn't
31/// support keychain, this will return Ok(()) without storing anything.
32pub fn store_secret(_key: &str, _value: &str) -> Result<()> {
33    // Respect explicit opt-out for tests/CI and deterministic behavior
34    if is_disabled_via_env() {
35        return Ok(());
36    }
37    #[cfg(feature = "secure-storage")]
38    {
39        match Entry::new(SERVICE_NAME, _key) {
40            Ok(entry) => {
41                // Propagate failure so callers can fall back to file storage
42                entry
43                    .set_password(_value)
44                    .map_err(|e| anyhow!("Failed to store secret in secure storage: {e}"))?;
45            }
46            Err(e) => {
47                // Signal to callers that secure storage isn't usable
48                return Err(anyhow!(
49                    "Secure storage not available on this platform: {e}"
50                ));
51            }
52        }
53    }
54    Ok(())
55}
56
57/// Retrieve a secret from the system keyring.
58///
59/// Returns None if secure-storage is not enabled or the key doesn't exist.
60pub fn get_secret(_key: &str) -> Result<Option<String>> {
61    // Respect explicit opt-out for tests/CI and deterministic behavior
62    if is_disabled_via_env() {
63        return Ok(None);
64    }
65    #[cfg(feature = "secure-storage")]
66    {
67        match Entry::new(SERVICE_NAME, _key) {
68            Ok(entry) => match entry.get_password() {
69                Ok(password) => Ok(Some(password)),
70                Err(keyring::Error::NoEntry) => Ok(None),
71                // Ignore other errors (e.g., no keychain available)
72                Err(_) => Ok(None),
73            },
74            Err(_) => {
75                // Platform doesn't support keyring
76                Ok(None)
77            }
78        }
79    }
80
81    #[cfg(not(feature = "secure-storage"))]
82    {
83        Ok(None)
84    }
85}
86
87/// Delete a secret from the system keyring.
88///
89/// If secure-storage is not enabled, this is a no-op.
90pub fn delete_secret(_key: &str) -> Result<()> {
91    // Respect explicit opt-out for tests/CI and deterministic behavior
92    if is_disabled_via_env() {
93        return Ok(());
94    }
95    #[cfg(feature = "secure-storage")]
96    {
97        match Entry::new(SERVICE_NAME, _key) {
98            Ok(entry) => {
99                // Try to delete, but don't fail if keyring is not available
100                // In keyring v3, we use delete_credential() instead of delete_password()
101                let _ = entry.delete_credential();
102            }
103            Err(_) => {
104                // Platform doesn't support keyring - that's ok
105            }
106        }
107    }
108
109    Ok(())
110}
111
112/// Check if secure storage is available on this system.
113///
114/// Returns true only if the secure-storage feature is enabled AND
115/// the system has a working keychain.
116pub fn is_available() -> bool {
117    // Allow tests/CI to force-disable secure storage to ensure deterministic behavior
118    if is_disabled_via_env() {
119        return false;
120    }
121
122    #[cfg(feature = "secure-storage")]
123    {
124        // Try to create a test entry to see if keyring is available
125        match Entry::new(SERVICE_NAME, "test") {
126            Ok(entry) => {
127                // Try to get a non-existent key - this should work if keyring is available
128                matches!(entry.get_password(), Err(keyring::Error::NoEntry) | Ok(_))
129            }
130            Err(_) => false,
131        }
132    }
133
134    #[cfg(not(feature = "secure-storage"))]
135    {
136        false
137    }
138}
139
140/// Returns true if secure storage is explicitly disabled via environment.
141fn is_disabled_via_env() -> bool {
142    std::env::var("RCO_DISABLE_SECURE_STORAGE")
143        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
144        .unwrap_or(false)
145}
146
147/// Get detailed platform information for secure storage
148pub fn get_platform_info() -> String {
149    #[cfg(all(feature = "secure-storage", target_os = "macos"))]
150    return "macOS Keychain".to_string();
151
152    #[cfg(all(feature = "secure-storage", target_os = "linux"))]
153    {
154        // Try to detect which secret service is available
155        if std::env::var("GNOME_KEYRING_CONTROL").is_ok() {
156            "GNOME Keyring".to_string()
157        } else if std::env::var("KDE_FULL_SESSION").is_ok() {
158            "KWallet".to_string()
159        } else {
160            "Linux Secret Service".to_string()
161        }
162    }
163
164    #[cfg(all(feature = "secure-storage", target_os = "windows"))]
165    return "Windows Credential Manager".to_string();
166
167    #[cfg(all(feature = "secure-storage", target_os = "ios"))]
168    return "iOS Keychain".to_string();
169
170    #[cfg(all(feature = "secure-storage", target_os = "freebsd"))]
171    return "FreeBSD Secret Service".to_string();
172
173    #[cfg(all(feature = "secure-storage", target_os = "openbsd"))]
174    return "OpenBSD Secret Service".to_string();
175
176    #[cfg(not(feature = "secure-storage"))]
177    return "Not compiled with secure storage support".to_string();
178
179    // Fallback for unknown platforms with secure-storage enabled
180    #[cfg(all(
181        feature = "secure-storage",
182        not(any(
183            target_os = "macos",
184            target_os = "linux",
185            target_os = "windows",
186            target_os = "ios",
187            target_os = "freebsd",
188            target_os = "openbsd"
189        ))
190    ))]
191    return "Unknown platform".to_string();
192}
193
194/// Returns a user-friendly message about the secure storage status.
195pub fn status_message() -> String {
196    #[cfg(feature = "secure-storage")]
197    {
198        if is_available() {
199            format!("Secure storage is available via {}", get_platform_info())
200        } else {
201            format!(
202                "Secure storage feature is enabled but {} is not available",
203                get_platform_info()
204            )
205        }
206    }
207
208    #[cfg(not(feature = "secure-storage"))]
209    {
210        "Secure storage is not enabled (compile with --features secure-storage to enable)"
211            .to_string()
212    }
213}