corteq-onepassword 0.1.5

Secure 1Password SDK wrapper with FFI bindings for Rust applications
Documentation
//! Secret types for the corteq-onepassword crate.
//!
//! This module provides types for working with 1Password secret references
//! and collections of resolved secrets.

use crate::error::{Error, Result};
use secrecy::SecretString;
use std::collections::HashMap;
use std::fmt;

/// Maximum length for a secret reference component (vault, item, section, field).
/// Prevents memory exhaustion from extremely long references.
const MAX_COMPONENT_LEN: usize = 256;

/// A parsed 1Password secret reference.
///
/// Secret references follow the format `op://vault/item/field` or
/// `op://vault/item/section/field` for section-scoped fields.
///
/// # Examples
///
/// ```
/// use corteq_onepassword::SecretReference;
///
/// // Parse a simple reference
/// let reference = SecretReference::parse("op://prod/database/password").unwrap();
/// assert_eq!(reference.vault(), "prod");
/// assert_eq!(reference.item(), "database");
/// assert_eq!(reference.field(), "password");
/// assert!(reference.section().is_none());
///
/// // Parse a section-scoped reference
/// let reference = SecretReference::parse("op://prod/database/admin/password").unwrap();
/// assert_eq!(reference.section(), Some("admin"));
/// ```
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SecretReference {
    vault: String,
    item: String,
    section: Option<String>,
    field: String,
    raw: String,
}

impl SecretReference {
    /// Parse a secret reference string.
    ///
    /// # Format
    ///
    /// - `op://vault/item/field` - Simple reference
    /// - `op://vault/item/section/field` - Section-scoped reference
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidReference`] if the reference format is invalid.
    ///
    /// # Examples
    ///
    /// ```
    /// use corteq_onepassword::SecretReference;
    ///
    /// // Valid references
    /// assert!(SecretReference::parse("op://vault/item/field").is_ok());
    /// assert!(SecretReference::parse("op://vault/item/section/field").is_ok());
    ///
    /// // Invalid references
    /// assert!(SecretReference::parse("vault/item/field").is_err()); // Missing op://
    /// assert!(SecretReference::parse("op://vault/item").is_err());   // Missing field
    /// ```
    pub fn parse(reference: &str) -> Result<Self> {
        // Must start with "op://"
        if !reference.starts_with("op://") {
            return Err(Error::InvalidReference {
                reference: reference.to_string(),
                reason: "secret reference must start with 'op://'".to_string(),
            });
        }

        let path = &reference[5..]; // Strip "op://"

        // Check for empty path
        if path.is_empty() {
            return Err(Error::InvalidReference {
                reference: reference.to_string(),
                reason: "secret reference path is empty".to_string(),
            });
        }

        let parts: Vec<&str> = path.split('/').collect();

        // Validate each part is non-empty and within length limits
        for (i, part) in parts.iter().enumerate() {
            if part.is_empty() {
                return Err(Error::InvalidReference {
                    reference: reference.to_string(),
                    reason: format!("empty component at position {}", i + 1),
                });
            }
            if part.len() > MAX_COMPONENT_LEN {
                return Err(Error::InvalidReference {
                    reference: reference.to_string(),
                    reason: format!(
                        "component at position {} exceeds maximum length of {} bytes",
                        i + 1,
                        MAX_COMPONENT_LEN
                    ),
                });
            }
        }

        match parts.len() {
            3 => Ok(Self {
                vault: parts[0].to_string(),
                item: parts[1].to_string(),
                section: None,
                field: parts[2].to_string(),
                raw: reference.to_string(),
            }),
            4 => Ok(Self {
                vault: parts[0].to_string(),
                item: parts[1].to_string(),
                section: Some(parts[2].to_string()),
                field: parts[3].to_string(),
                raw: reference.to_string(),
            }),
            n if n < 3 => Err(Error::InvalidReference {
                reference: reference.to_string(),
                reason: format!(
                    "expected at least 3 path components (vault/item/field), found {n}"
                ),
            }),
            n => Err(Error::InvalidReference {
                reference: reference.to_string(),
                reason: format!(
                    "expected 3 or 4 path components (vault/item/[section/]field), found {n}"
                ),
            }),
        }
    }

    /// Returns the vault name.
    pub fn vault(&self) -> &str {
        &self.vault
    }

    /// Returns the item name.
    pub fn item(&self) -> &str {
        &self.item
    }

    /// Returns the section name, if present.
    pub fn section(&self) -> Option<&str> {
        self.section.as_deref()
    }

    /// Returns the field name.
    pub fn field(&self) -> &str {
        &self.field
    }

    /// Returns the original reference string.
    pub fn as_str(&self) -> &str {
        &self.raw
    }
}

impl fmt::Debug for SecretReference {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SecretReference")
            .field("vault", &self.vault)
            .field("item", &self.item)
            .field("section", &self.section)
            .field("field", &self.field)
            .finish()
    }
}

impl fmt::Display for SecretReference {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.raw)
    }
}

impl AsRef<str> for SecretReference {
    fn as_ref(&self) -> &str {
        &self.raw
    }
}

/// A named collection of secrets.
///
/// `SecretMap` provides a way to access resolved secrets by user-defined names
/// rather than by their 1Password references. This makes code more readable
/// and decouples the application from specific vault/item/field paths.
///
/// # Security
///
/// `SecretMap` does NOT implement `Debug` in a way that would expose secret values.
/// The `Debug` implementation only shows the keys (names), not the values.
///
/// # Examples
///
/// ```ignore
/// use corteq_onepassword::{OnePassword, SecretMap, ExposeSecret};
///
/// async fn example(op: &OnePassword) -> Result<(), Box<dyn std::error::Error>> {
///     let secrets = op.secrets_named(&[
///         ("db_host", "op://prod/database/host"),
///         ("db_pass", "op://prod/database/password"),
///     ]).await?;
///
///     let host = secrets.get("db_host").unwrap().expose_secret();
///     let pass = secrets.get("db_pass").unwrap().expose_secret();
///
///     Ok(())
/// }
/// ```
pub struct SecretMap {
    inner: HashMap<String, SecretString>,
}

impl SecretMap {
    /// Create a new `SecretMap` from an iterator of name-secret pairs.
    pub(crate) fn from_pairs<I, S>(pairs: I) -> Self
    where
        I: Iterator<Item = (S, SecretString)>,
        S: Into<String>,
    {
        Self {
            inner: pairs.map(|(k, v)| (k.into(), v)).collect(),
        }
    }

    /// Get a secret by its name.
    ///
    /// Returns `None` if no secret with the given name exists.
    pub fn get(&self, name: &str) -> Option<&SecretString> {
        self.inner.get(name)
    }

    /// Check if a secret with the given name exists.
    pub fn contains(&self, name: &str) -> bool {
        self.inner.contains_key(name)
    }

    /// Returns an iterator over the secret names.
    pub fn names(&self) -> impl Iterator<Item = &str> {
        self.inner.keys().map(|s| s.as_str())
    }

    /// Returns the number of secrets in the map.
    pub fn len(&self) -> usize {
        self.inner.len()
    }

    /// Returns `true` if the map contains no secrets.
    pub fn is_empty(&self) -> bool {
        self.inner.is_empty()
    }
}

// Implement Debug with redacted values
impl fmt::Debug for SecretMap {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SecretMap")
            .field("keys", &self.inner.keys().collect::<Vec<_>>())
            .field("count", &self.inner.len())
            .finish()
    }
}

// SecretMap is Send + Sync because HashMap<String, SecretString> is
// and SecretString is Send + Sync
unsafe impl Send for SecretMap {}
unsafe impl Sync for SecretMap {}

#[cfg(test)]
mod tests {
    use super::*;
    use secrecy::ExposeSecret;

    #[test]
    fn test_parse_simple_reference() {
        let reference = SecretReference::parse("op://vault/item/field").unwrap();
        assert_eq!(reference.vault(), "vault");
        assert_eq!(reference.item(), "item");
        assert_eq!(reference.field(), "field");
        assert!(reference.section().is_none());
    }

    #[test]
    fn test_parse_section_reference() {
        let reference = SecretReference::parse("op://vault/item/section/field").unwrap();
        assert_eq!(reference.vault(), "vault");
        assert_eq!(reference.item(), "item");
        assert_eq!(reference.section(), Some("section"));
        assert_eq!(reference.field(), "field");
    }

    #[test]
    fn test_parse_invalid_prefix() {
        let result = SecretReference::parse("ops://vault/item/field");
        assert!(matches!(result, Err(Error::InvalidReference { .. })));
    }

    #[test]
    fn test_parse_too_few_parts() {
        let result = SecretReference::parse("op://vault/item");
        assert!(matches!(result, Err(Error::InvalidReference { .. })));
    }

    #[test]
    fn test_parse_too_many_parts() {
        let result = SecretReference::parse("op://a/b/c/d/e");
        assert!(matches!(result, Err(Error::InvalidReference { .. })));
    }

    #[test]
    fn test_parse_empty_component() {
        let result = SecretReference::parse("op://vault//field");
        assert!(matches!(result, Err(Error::InvalidReference { .. })));
    }

    #[test]
    fn test_parse_component_too_long() {
        let long_vault = "x".repeat(257);
        let reference = format!("op://{long_vault}/item/field");
        let result = SecretReference::parse(&reference);
        assert!(matches!(result, Err(Error::InvalidReference { .. })));

        // Verify error message mentions length
        if let Err(Error::InvalidReference { reason, .. }) = result {
            assert!(reason.contains("exceeds maximum length"));
        }
    }

    #[test]
    fn test_reference_display() {
        let reference = SecretReference::parse("op://vault/item/field").unwrap();
        assert_eq!(reference.to_string(), "op://vault/item/field");
    }

    #[test]
    fn test_secret_map_get() {
        let map = SecretMap::from_pairs(
            vec![
                ("key1", SecretString::from("value1")),
                ("key2", SecretString::from("value2")),
            ]
            .into_iter(),
        );

        assert_eq!(map.get("key1").unwrap().expose_secret(), "value1");
        assert_eq!(map.get("key2").unwrap().expose_secret(), "value2");
        assert!(map.get("key3").is_none());
    }

    #[test]
    fn test_secret_map_debug_redacted() {
        let map = SecretMap::from_pairs(
            vec![("password", SecretString::from("super-secret"))].into_iter(),
        );

        let debug_output = format!("{map:?}");
        assert!(!debug_output.contains("super-secret"));
        assert!(debug_output.contains("password"));
    }

    #[test]
    fn test_secret_map_is_send_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<SecretMap>();
    }

    #[test]
    fn test_secret_reference_is_send_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<SecretReference>();
    }
}