corteq_onepassword/
secret.rs

1//! Secret types for the corteq-onepassword crate.
2//!
3//! This module provides types for working with 1Password secret references
4//! and collections of resolved secrets.
5
6use crate::error::{Error, Result};
7use secrecy::SecretString;
8use std::collections::HashMap;
9use std::fmt;
10
11/// Maximum length for a secret reference component (vault, item, section, field).
12/// Prevents memory exhaustion from extremely long references.
13const MAX_COMPONENT_LEN: usize = 256;
14
15/// A parsed 1Password secret reference.
16///
17/// Secret references follow the format `op://vault/item/field` or
18/// `op://vault/item/section/field` for section-scoped fields.
19///
20/// # Examples
21///
22/// ```
23/// use corteq_onepassword::SecretReference;
24///
25/// // Parse a simple reference
26/// let reference = SecretReference::parse("op://prod/database/password").unwrap();
27/// assert_eq!(reference.vault(), "prod");
28/// assert_eq!(reference.item(), "database");
29/// assert_eq!(reference.field(), "password");
30/// assert!(reference.section().is_none());
31///
32/// // Parse a section-scoped reference
33/// let reference = SecretReference::parse("op://prod/database/admin/password").unwrap();
34/// assert_eq!(reference.section(), Some("admin"));
35/// ```
36#[derive(Clone, PartialEq, Eq, Hash)]
37pub struct SecretReference {
38    vault: String,
39    item: String,
40    section: Option<String>,
41    field: String,
42    raw: String,
43}
44
45impl SecretReference {
46    /// Parse a secret reference string.
47    ///
48    /// # Format
49    ///
50    /// - `op://vault/item/field` - Simple reference
51    /// - `op://vault/item/section/field` - Section-scoped reference
52    ///
53    /// # Errors
54    ///
55    /// Returns [`Error::InvalidReference`] if the reference format is invalid.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use corteq_onepassword::SecretReference;
61    ///
62    /// // Valid references
63    /// assert!(SecretReference::parse("op://vault/item/field").is_ok());
64    /// assert!(SecretReference::parse("op://vault/item/section/field").is_ok());
65    ///
66    /// // Invalid references
67    /// assert!(SecretReference::parse("vault/item/field").is_err()); // Missing op://
68    /// assert!(SecretReference::parse("op://vault/item").is_err());   // Missing field
69    /// ```
70    pub fn parse(reference: &str) -> Result<Self> {
71        // Must start with "op://"
72        if !reference.starts_with("op://") {
73            return Err(Error::InvalidReference {
74                reference: reference.to_string(),
75                reason: "secret reference must start with 'op://'".to_string(),
76            });
77        }
78
79        let path = &reference[5..]; // Strip "op://"
80
81        // Check for empty path
82        if path.is_empty() {
83            return Err(Error::InvalidReference {
84                reference: reference.to_string(),
85                reason: "secret reference path is empty".to_string(),
86            });
87        }
88
89        let parts: Vec<&str> = path.split('/').collect();
90
91        // Validate each part is non-empty and within length limits
92        for (i, part) in parts.iter().enumerate() {
93            if part.is_empty() {
94                return Err(Error::InvalidReference {
95                    reference: reference.to_string(),
96                    reason: format!("empty component at position {}", i + 1),
97                });
98            }
99            if part.len() > MAX_COMPONENT_LEN {
100                return Err(Error::InvalidReference {
101                    reference: reference.to_string(),
102                    reason: format!(
103                        "component at position {} exceeds maximum length of {} bytes",
104                        i + 1,
105                        MAX_COMPONENT_LEN
106                    ),
107                });
108            }
109        }
110
111        match parts.len() {
112            3 => Ok(Self {
113                vault: parts[0].to_string(),
114                item: parts[1].to_string(),
115                section: None,
116                field: parts[2].to_string(),
117                raw: reference.to_string(),
118            }),
119            4 => Ok(Self {
120                vault: parts[0].to_string(),
121                item: parts[1].to_string(),
122                section: Some(parts[2].to_string()),
123                field: parts[3].to_string(),
124                raw: reference.to_string(),
125            }),
126            n if n < 3 => Err(Error::InvalidReference {
127                reference: reference.to_string(),
128                reason: format!(
129                    "expected at least 3 path components (vault/item/field), found {n}"
130                ),
131            }),
132            n => Err(Error::InvalidReference {
133                reference: reference.to_string(),
134                reason: format!(
135                    "expected 3 or 4 path components (vault/item/[section/]field), found {n}"
136                ),
137            }),
138        }
139    }
140
141    /// Returns the vault name.
142    pub fn vault(&self) -> &str {
143        &self.vault
144    }
145
146    /// Returns the item name.
147    pub fn item(&self) -> &str {
148        &self.item
149    }
150
151    /// Returns the section name, if present.
152    pub fn section(&self) -> Option<&str> {
153        self.section.as_deref()
154    }
155
156    /// Returns the field name.
157    pub fn field(&self) -> &str {
158        &self.field
159    }
160
161    /// Returns the original reference string.
162    pub fn as_str(&self) -> &str {
163        &self.raw
164    }
165}
166
167impl fmt::Debug for SecretReference {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.debug_struct("SecretReference")
170            .field("vault", &self.vault)
171            .field("item", &self.item)
172            .field("section", &self.section)
173            .field("field", &self.field)
174            .finish()
175    }
176}
177
178impl fmt::Display for SecretReference {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        write!(f, "{}", self.raw)
181    }
182}
183
184impl AsRef<str> for SecretReference {
185    fn as_ref(&self) -> &str {
186        &self.raw
187    }
188}
189
190/// A named collection of secrets.
191///
192/// `SecretMap` provides a way to access resolved secrets by user-defined names
193/// rather than by their 1Password references. This makes code more readable
194/// and decouples the application from specific vault/item/field paths.
195///
196/// # Security
197///
198/// `SecretMap` does NOT implement `Debug` in a way that would expose secret values.
199/// The `Debug` implementation only shows the keys (names), not the values.
200///
201/// # Examples
202///
203/// ```ignore
204/// use corteq_onepassword::{OnePassword, SecretMap, ExposeSecret};
205///
206/// async fn example(op: &OnePassword) -> Result<(), Box<dyn std::error::Error>> {
207///     let secrets = op.secrets_named(&[
208///         ("db_host", "op://prod/database/host"),
209///         ("db_pass", "op://prod/database/password"),
210///     ]).await?;
211///
212///     let host = secrets.get("db_host").unwrap().expose_secret();
213///     let pass = secrets.get("db_pass").unwrap().expose_secret();
214///
215///     Ok(())
216/// }
217/// ```
218pub struct SecretMap {
219    inner: HashMap<String, SecretString>,
220}
221
222impl SecretMap {
223    /// Create a new `SecretMap` from an iterator of name-secret pairs.
224    pub(crate) fn from_pairs<I, S>(pairs: I) -> Self
225    where
226        I: Iterator<Item = (S, SecretString)>,
227        S: Into<String>,
228    {
229        Self {
230            inner: pairs.map(|(k, v)| (k.into(), v)).collect(),
231        }
232    }
233
234    /// Get a secret by its name.
235    ///
236    /// Returns `None` if no secret with the given name exists.
237    pub fn get(&self, name: &str) -> Option<&SecretString> {
238        self.inner.get(name)
239    }
240
241    /// Check if a secret with the given name exists.
242    pub fn contains(&self, name: &str) -> bool {
243        self.inner.contains_key(name)
244    }
245
246    /// Returns an iterator over the secret names.
247    pub fn names(&self) -> impl Iterator<Item = &str> {
248        self.inner.keys().map(|s| s.as_str())
249    }
250
251    /// Returns the number of secrets in the map.
252    pub fn len(&self) -> usize {
253        self.inner.len()
254    }
255
256    /// Returns `true` if the map contains no secrets.
257    pub fn is_empty(&self) -> bool {
258        self.inner.is_empty()
259    }
260}
261
262// Implement Debug with redacted values
263impl fmt::Debug for SecretMap {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        f.debug_struct("SecretMap")
266            .field("keys", &self.inner.keys().collect::<Vec<_>>())
267            .field("count", &self.inner.len())
268            .finish()
269    }
270}
271
272// SecretMap is Send + Sync because HashMap<String, SecretString> is
273// and SecretString is Send + Sync
274unsafe impl Send for SecretMap {}
275unsafe impl Sync for SecretMap {}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use secrecy::ExposeSecret;
281
282    #[test]
283    fn test_parse_simple_reference() {
284        let reference = SecretReference::parse("op://vault/item/field").unwrap();
285        assert_eq!(reference.vault(), "vault");
286        assert_eq!(reference.item(), "item");
287        assert_eq!(reference.field(), "field");
288        assert!(reference.section().is_none());
289    }
290
291    #[test]
292    fn test_parse_section_reference() {
293        let reference = SecretReference::parse("op://vault/item/section/field").unwrap();
294        assert_eq!(reference.vault(), "vault");
295        assert_eq!(reference.item(), "item");
296        assert_eq!(reference.section(), Some("section"));
297        assert_eq!(reference.field(), "field");
298    }
299
300    #[test]
301    fn test_parse_invalid_prefix() {
302        let result = SecretReference::parse("ops://vault/item/field");
303        assert!(matches!(result, Err(Error::InvalidReference { .. })));
304    }
305
306    #[test]
307    fn test_parse_too_few_parts() {
308        let result = SecretReference::parse("op://vault/item");
309        assert!(matches!(result, Err(Error::InvalidReference { .. })));
310    }
311
312    #[test]
313    fn test_parse_too_many_parts() {
314        let result = SecretReference::parse("op://a/b/c/d/e");
315        assert!(matches!(result, Err(Error::InvalidReference { .. })));
316    }
317
318    #[test]
319    fn test_parse_empty_component() {
320        let result = SecretReference::parse("op://vault//field");
321        assert!(matches!(result, Err(Error::InvalidReference { .. })));
322    }
323
324    #[test]
325    fn test_parse_component_too_long() {
326        let long_vault = "x".repeat(257);
327        let reference = format!("op://{long_vault}/item/field");
328        let result = SecretReference::parse(&reference);
329        assert!(matches!(result, Err(Error::InvalidReference { .. })));
330
331        // Verify error message mentions length
332        if let Err(Error::InvalidReference { reason, .. }) = result {
333            assert!(reason.contains("exceeds maximum length"));
334        }
335    }
336
337    #[test]
338    fn test_reference_display() {
339        let reference = SecretReference::parse("op://vault/item/field").unwrap();
340        assert_eq!(reference.to_string(), "op://vault/item/field");
341    }
342
343    #[test]
344    fn test_secret_map_get() {
345        let map = SecretMap::from_pairs(
346            vec![
347                ("key1", SecretString::from("value1")),
348                ("key2", SecretString::from("value2")),
349            ]
350            .into_iter(),
351        );
352
353        assert_eq!(map.get("key1").unwrap().expose_secret(), "value1");
354        assert_eq!(map.get("key2").unwrap().expose_secret(), "value2");
355        assert!(map.get("key3").is_none());
356    }
357
358    #[test]
359    fn test_secret_map_debug_redacted() {
360        let map = SecretMap::from_pairs(
361            vec![("password", SecretString::from("super-secret"))].into_iter(),
362        );
363
364        let debug_output = format!("{map:?}");
365        assert!(!debug_output.contains("super-secret"));
366        assert!(debug_output.contains("password"));
367    }
368
369    #[test]
370    fn test_secret_map_is_send_sync() {
371        fn assert_send_sync<T: Send + Sync>() {}
372        assert_send_sync::<SecretMap>();
373    }
374
375    #[test]
376    fn test_secret_reference_is_send_sync() {
377        fn assert_send_sync<T: Send + Sync>() {}
378        assert_send_sync::<SecretReference>();
379    }
380}