1use std::path::PathBuf;
2use std::str::FromStr;
3
4use serde::{Deserialize, Deserializer};
5
6use crate::error::SecretError;
7
8#[derive(Debug, Clone, PartialEq)]
17pub enum SecretUri {
18 Plain(String),
20
21 Keychain { service: String, key: String },
23
24 OnePassword {
26 vault: String,
27 item: String,
28 field: String,
29 },
30
31 Env { var_name: String },
33
34 File { path: PathBuf },
36
37 Base64 { data: String },
39}
40
41impl SecretUri {
42 pub fn is_plain(&self) -> bool {
44 matches!(self, SecretUri::Plain(_))
45 }
46
47 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 Ok(SecretUri::File {
77 path: PathBuf::from(s),
78 })
79 } else {
80 Ok(SecretUri::Plain(s.to_string()))
82 }
83 }
84}
85
86fn 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
104fn 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
123fn 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
139fn 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
152fn 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
168fn looks_like_file_path(s: &str) -> bool {
170 s.starts_with('/')
172 || s.starts_with("./")
173 || s.starts_with("../")
174 || (s.len() > 2 && s.chars().nth(1) == Some(':')) || s.contains(".pem")
176 || s.contains(".crt")
177 || s.contains(".key")
178}
179
180impl<'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}