siphon_secrets/
uri.rs

1use std::path::PathBuf;
2use std::str::FromStr;
3
4use serde::{Deserialize, Deserializer};
5
6use crate::error::SecretError;
7
8/// Represents a secret reference that can be resolved from various backends.
9///
10/// Supports the following URI schemes:
11/// - `keychain://service/key` - OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
12/// - `op://vault/item/field` - 1Password CLI
13/// - `env://VAR_NAME` - Environment variable
14/// - `file:///path/to/file` - File content
15/// - Plain string - Literal value (backwards compatible)
16#[derive(Debug, Clone, PartialEq)]
17pub enum SecretUri {
18    /// Plain text value (no URI scheme, backwards compatible)
19    Plain(String),
20
21    /// OS Keychain: `keychain://service/key`
22    Keychain { service: String, key: String },
23
24    /// 1Password CLI: `op://vault/item/field`
25    OnePassword {
26        vault: String,
27        item: String,
28        field: String,
29    },
30
31    /// Environment variable: `env://VAR_NAME`
32    Env { var_name: String },
33
34    /// File path: `file:///path/to/file` or just a path
35    File { path: PathBuf },
36
37    /// Base64 encoded value: `base64://...`
38    Base64 { data: String },
39}
40
41impl SecretUri {
42    /// Check if this is a plain value (not a URI reference)
43    pub fn is_plain(&self) -> bool {
44        matches!(self, SecretUri::Plain(_))
45    }
46
47    /// Get the backend name for logging/errors
48    pub fn backend_name(&self) -> &'static str {
49        match self {
50            SecretUri::Plain(_) => "plain",
51            SecretUri::Keychain { .. } => "keychain",
52            SecretUri::OnePassword { .. } => "1password",
53            SecretUri::Env { .. } => "env",
54            SecretUri::File { .. } => "file",
55            SecretUri::Base64 { .. } => "base64",
56        }
57    }
58}
59
60impl FromStr for SecretUri {
61    type Err = SecretError;
62
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        if s.starts_with("keychain://") {
65            parse_keychain_uri(s)
66        } else if s.starts_with("op://") {
67            parse_onepassword_uri(s)
68        } else if s.starts_with("env://") {
69            parse_env_uri(s)
70        } else if s.starts_with("file://") {
71            parse_file_uri(s)
72        } else if s.starts_with("base64://") {
73            parse_base64_uri(s)
74        } else if looks_like_file_path(s) {
75            // Treat bare paths as file URIs for convenience
76            Ok(SecretUri::File {
77                path: PathBuf::from(s),
78            })
79        } else {
80            // Plain value (no URI scheme)
81            Ok(SecretUri::Plain(s.to_string()))
82        }
83    }
84}
85
86/// Parse `keychain://service/key`
87fn parse_keychain_uri(s: &str) -> Result<SecretUri, SecretError> {
88    let rest = s.strip_prefix("keychain://").unwrap();
89    let parts: Vec<&str> = rest.splitn(2, '/').collect();
90
91    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
92        return Err(SecretError::invalid_uri(
93            s,
94            "keychain URI must be keychain://service/key",
95        ));
96    }
97
98    Ok(SecretUri::Keychain {
99        service: parts[0].to_string(),
100        key: parts[1].to_string(),
101    })
102}
103
104/// Parse `op://vault/item/field`
105fn parse_onepassword_uri(s: &str) -> Result<SecretUri, SecretError> {
106    let rest = s.strip_prefix("op://").unwrap();
107    let parts: Vec<&str> = rest.splitn(3, '/').collect();
108
109    if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) {
110        return Err(SecretError::invalid_uri(
111            s,
112            "1Password URI must be op://vault/item/field",
113        ));
114    }
115
116    Ok(SecretUri::OnePassword {
117        vault: parts[0].to_string(),
118        item: parts[1].to_string(),
119        field: parts[2].to_string(),
120    })
121}
122
123/// Parse `env://VAR_NAME`
124fn parse_env_uri(s: &str) -> Result<SecretUri, SecretError> {
125    let var_name = s.strip_prefix("env://").unwrap();
126
127    if var_name.is_empty() {
128        return Err(SecretError::invalid_uri(
129            s,
130            "env URI must specify a variable name",
131        ));
132    }
133
134    Ok(SecretUri::Env {
135        var_name: var_name.to_string(),
136    })
137}
138
139/// Parse `file:///path/to/file`
140fn parse_file_uri(s: &str) -> Result<SecretUri, SecretError> {
141    let path = s.strip_prefix("file://").unwrap();
142
143    if path.is_empty() {
144        return Err(SecretError::invalid_uri(s, "file URI must specify a path"));
145    }
146
147    Ok(SecretUri::File {
148        path: PathBuf::from(path),
149    })
150}
151
152/// Parse `base64://...`
153fn parse_base64_uri(s: &str) -> Result<SecretUri, SecretError> {
154    let data = s.strip_prefix("base64://").unwrap();
155
156    if data.is_empty() {
157        return Err(SecretError::invalid_uri(
158            s,
159            "base64 URI must contain encoded data",
160        ));
161    }
162
163    Ok(SecretUri::Base64 {
164        data: data.to_string(),
165    })
166}
167
168/// Check if a string looks like a file path
169fn looks_like_file_path(s: &str) -> bool {
170    // Unix absolute path or Windows path or relative path with extension
171    s.starts_with('/')
172        || s.starts_with("./")
173        || s.starts_with("../")
174        || (s.len() > 2 && s.chars().nth(1) == Some(':')) // Windows C:\...
175        || s.contains(".pem")
176        || s.contains(".crt")
177        || s.contains(".key")
178}
179
180/// Custom serde deserializer for SecretUri
181impl<'de> Deserialize<'de> for SecretUri {
182    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183    where
184        D: Deserializer<'de>,
185    {
186        let s = String::deserialize(deserializer)?;
187        SecretUri::from_str(&s).map_err(serde::de::Error::custom)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_parse_keychain_uri() {
197        let uri: SecretUri = "keychain://myservice/mykey".parse().unwrap();
198        assert_eq!(
199            uri,
200            SecretUri::Keychain {
201                service: "myservice".to_string(),
202                key: "mykey".to_string(),
203            }
204        );
205    }
206
207    #[test]
208    fn test_parse_onepassword_uri() {
209        let uri: SecretUri = "op://Private/Server/api-token".parse().unwrap();
210        assert_eq!(
211            uri,
212            SecretUri::OnePassword {
213                vault: "Private".to_string(),
214                item: "Server".to_string(),
215                field: "api-token".to_string(),
216            }
217        );
218    }
219
220    #[test]
221    fn test_parse_env_uri() {
222        let uri: SecretUri = "env://MY_SECRET".parse().unwrap();
223        assert_eq!(
224            uri,
225            SecretUri::Env {
226                var_name: "MY_SECRET".to_string(),
227            }
228        );
229    }
230
231    #[test]
232    fn test_parse_file_uri() {
233        let uri: SecretUri = "file:///etc/tunnel/secret.key".parse().unwrap();
234        assert_eq!(
235            uri,
236            SecretUri::File {
237                path: PathBuf::from("/etc/tunnel/secret.key"),
238            }
239        );
240    }
241
242    #[test]
243    fn test_parse_bare_path() {
244        let uri: SecretUri = "/etc/tunnel/server.crt".parse().unwrap();
245        assert_eq!(
246            uri,
247            SecretUri::File {
248                path: PathBuf::from("/etc/tunnel/server.crt"),
249            }
250        );
251    }
252
253    #[test]
254    fn test_parse_plain_value() {
255        let uri: SecretUri = "my-secret-token".parse().unwrap();
256        assert_eq!(uri, SecretUri::Plain("my-secret-token".to_string()));
257    }
258
259    #[test]
260    fn test_invalid_keychain_uri() {
261        let result: Result<SecretUri, _> = "keychain://onlyservice".parse();
262        assert!(result.is_err());
263    }
264
265    #[test]
266    fn test_invalid_onepassword_uri() {
267        let result: Result<SecretUri, _> = "op://vault/item".parse();
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn test_invalid_env_uri() {
273        let result: Result<SecretUri, _> = "env://".parse();
274        assert!(result.is_err());
275    }
276
277    #[test]
278    fn test_parse_base64_uri() {
279        let uri: SecretUri = "base64://SGVsbG8gV29ybGQ=".parse().unwrap();
280        assert_eq!(
281            uri,
282            SecretUri::Base64 {
283                data: "SGVsbG8gV29ybGQ=".to_string(),
284            }
285        );
286    }
287
288    #[test]
289    fn test_invalid_base64_uri() {
290        let result: Result<SecretUri, _> = "base64://".parse();
291        assert!(result.is_err());
292    }
293}