corteq-onepassword 0.1.5

Secure 1Password SDK wrapper with FFI bindings for Rust applications
Documentation
//! Safe Rust bindings to the 1Password SDK FFI.
//!
//! This module provides a safe interface over the raw FFI calls,
//! handling JSON serialization and error mapping.

use crate::error::{Error, Result};
use crate::ffi::loader::NativeLibrary;
use crate::ffi::protocol::*;
use secrecy::{ExposeSecret, SecretString};
use std::sync::Arc;
use zeroize::Zeroize;

/// SDK client wrapper providing safe access to 1Password operations.
///
/// This struct holds a client ID obtained from the SDK and provides
/// methods for secret resolution.
pub(crate) struct SdkClient {
    /// The client ID returned by the SDK's init_client.
    /// The SDK returns it as a string but it's used as a number in invocations.
    client_id: u64,

    /// Reference to the loaded native library.
    library: Arc<NativeLibrary>,
}

impl SdkClient {
    /// Initialize a new SDK client session.
    ///
    /// # Arguments
    ///
    /// * `library` - The loaded native library
    /// * `token` - The service account token
    /// * `integration_name` - Name of the integration (for audit logs)
    /// * `integration_version` - Version of the integration
    ///
    /// # Returns
    ///
    /// A new `SdkClient` on success, or an error if initialization fails.
    pub fn init(
        library: Arc<NativeLibrary>,
        token: &SecretString,
        integration_name: &str,
        integration_version: &str,
    ) -> Result<Self> {
        let params = InitClientParams {
            service_account_token: SecretString::from(token.expose_secret().to_string()),
            programming_language: "Rust".to_string(),
            // SDK version must match the native library's build number format
            // "0030201" = version 0.3.2, build 01 (matching Python SDK's libop_uniffi_core.so)
            sdk_version: "0030201".to_string(),
            integration_name: integration_name.to_string(),
            integration_version: integration_version.to_string(),
            request_library_name: "reqwest".to_string(),
            request_library_version: "0.11".to_string(),
            os: std::env::consts::OS.to_string(),
            os_version: "0.0.0".to_string(),
            architecture: std::env::consts::ARCH.to_string(),
        };

        let mut config_json = serde_json::to_string(&params)?;

        // init_client returns a String, but the SDK uses it as a number in invocations
        let result = library.init_client(&config_json);

        // Zeroize the JSON string containing the service account token
        // to prevent exposure in memory dumps
        config_json.zeroize();

        let client_id_str = result?;
        let client_id = client_id_str.parse::<u64>().map_err(|e| Error::SdkError {
            message: format!("invalid client_id '{client_id_str}': {e}"),
        })?;

        Ok(Self { client_id, library })
    }

    /// Resolve a single secret reference.
    ///
    /// # Arguments
    ///
    /// * `reference` - The secret reference in `op://vault/item/field` format
    ///
    /// # Returns
    ///
    /// The secret value wrapped in `SecretString`.
    pub fn resolve_secret(&self, reference: &str) -> Result<SecretString> {
        let params = ResolveSecretParams {
            secret_reference: reference.to_string(),
        };

        // Create SDK invocation with proper format (client_id is in the JSON payload)
        let invocation = SdkInvocation::new(self.client_id, methods::SECRETS_RESOLVE, params);
        let request_json = invocation.to_json()?;

        let mut response_json = self.library.invoke_sync(&request_json)?;

        // Parse the secret from JSON response
        let parse_result: std::result::Result<String, _> = serde_json::from_str(&response_json);

        // Zeroize response_json immediately after parsing (contains secret in plaintext)
        response_json.zeroize();

        let mut secret = parse_result.map_err(|e| Error::SdkError {
            message: format!("failed to parse secret response: {e}"),
        })?;

        // Convert to SecretString and zeroize the intermediate string
        let result = SecretString::from(secret.clone());
        secret.zeroize();

        Ok(result)
    }

    /// Resolve multiple secrets in a batch.
    ///
    /// # Arguments
    ///
    /// * `references` - The secret references to resolve
    ///
    /// # Returns
    ///
    /// A vector of secret values in the same order as the input references.
    pub fn resolve_secrets_batch(&self, references: &[&str]) -> Result<Vec<SecretString>> {
        if references.is_empty() {
            return Ok(Vec::new());
        }

        // For now, resolve sequentially since batch API may vary by SDK version
        // TODO: Use batch API when available
        let mut secrets = Vec::with_capacity(references.len());
        for reference in references {
            secrets.push(self.resolve_secret(reference)?);
        }
        Ok(secrets)
    }
}

impl Drop for SdkClient {
    fn drop(&mut self) {
        #[cfg(feature = "tracing")]
        tracing::debug!(client_id = self.client_id, "releasing SDK client");

        // release_client expects the client_id as a string (RustBuffer)
        self.library.release_client(&self.client_id.to_string());
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ffi::loader::load_library;

    fn get_test_token() -> Option<String> {
        std::env::var("OP_SERVICE_ACCOUNT_TOKEN").ok()
    }

    fn get_test_secret_ref() -> Option<String> {
        std::env::var("TEST_SECRET_REF").ok()
    }

    // ==========================================================================
    // Integration tests (require OP_SERVICE_ACCOUNT_TOKEN)
    // ==========================================================================

    #[test]
    #[ignore = "requires OP_SERVICE_ACCOUNT_TOKEN"]
    fn test_sdk_client_init_success() {
        let token = get_test_token().expect("OP_SERVICE_ACCOUNT_TOKEN required");
        let library = load_library().expect("should load library");

        let client = SdkClient::init(
            library,
            &SecretString::from(token),
            "test-bindings",
            "0.1.0",
        )
        .expect("should init client");

        // Client ID should be non-zero
        assert!(client.client_id > 0, "client_id should be positive");
    }

    #[test]
    #[ignore = "requires OP_SERVICE_ACCOUNT_TOKEN"]
    fn test_sdk_client_init_with_invalid_token() {
        let library = load_library().expect("should load library");

        let result = SdkClient::init(
            library,
            &SecretString::from("invalid-token-12345".to_string()),
            "test-bindings",
            "0.1.0",
        );

        assert!(result.is_err(), "should fail with invalid token");
    }

    #[test]
    #[ignore = "requires OP_SERVICE_ACCOUNT_TOKEN and TEST_SECRET_REF"]
    fn test_sdk_client_resolve_secret() {
        let token = get_test_token().expect("OP_SERVICE_ACCOUNT_TOKEN required");
        let secret_ref = get_test_secret_ref().expect("TEST_SECRET_REF required");
        let library = load_library().expect("should load library");

        let client = SdkClient::init(
            library,
            &SecretString::from(token),
            "test-bindings",
            "0.1.0",
        )
        .expect("should init client");

        let secret = client
            .resolve_secret(&secret_ref)
            .expect("should resolve secret");

        // Secret should not be empty
        assert!(
            !secret.expose_secret().is_empty(),
            "secret should not be empty"
        );
    }

    #[test]
    #[ignore = "requires OP_SERVICE_ACCOUNT_TOKEN"]
    fn test_sdk_client_resolve_invalid_reference() {
        let token = get_test_token().expect("OP_SERVICE_ACCOUNT_TOKEN required");
        let library = load_library().expect("should load library");

        let client = SdkClient::init(
            library,
            &SecretString::from(token),
            "test-bindings",
            "0.1.0",
        )
        .expect("should init client");

        let result = client.resolve_secret("op://nonexistent/vault/field");

        assert!(result.is_err(), "should fail with invalid reference");
    }

    #[test]
    #[ignore = "requires OP_SERVICE_ACCOUNT_TOKEN and TEST_SECRET_REF"]
    fn test_sdk_client_resolve_secrets_batch() {
        let token = get_test_token().expect("OP_SERVICE_ACCOUNT_TOKEN required");
        let secret_ref = get_test_secret_ref().expect("TEST_SECRET_REF required");
        let library = load_library().expect("should load library");

        let client = SdkClient::init(
            library,
            &SecretString::from(token),
            "test-bindings",
            "0.1.0",
        )
        .expect("should init client");

        // Resolve the same secret twice
        let secrets = client
            .resolve_secrets_batch(&[&secret_ref, &secret_ref])
            .expect("should resolve batch");

        assert_eq!(secrets.len(), 2);
        assert_eq!(secrets[0].expose_secret(), secrets[1].expose_secret());
    }

    #[test]
    #[ignore = "requires OP_SERVICE_ACCOUNT_TOKEN"]
    fn test_sdk_client_resolve_secrets_batch_empty() {
        let token = get_test_token().expect("OP_SERVICE_ACCOUNT_TOKEN required");
        let library = load_library().expect("should load library");

        let client = SdkClient::init(
            library,
            &SecretString::from(token),
            "test-bindings",
            "0.1.0",
        )
        .expect("should init client");

        let secrets = client
            .resolve_secrets_batch(&[])
            .expect("should handle empty batch");

        assert!(secrets.is_empty());
    }

    #[test]
    #[ignore = "requires OP_SERVICE_ACCOUNT_TOKEN"]
    fn test_sdk_client_drop_releases_client() {
        let token = get_test_token().expect("OP_SERVICE_ACCOUNT_TOKEN required");
        let library = load_library().expect("should load library");

        {
            let _client = SdkClient::init(
                library.clone(),
                &SecretString::from(token.clone()),
                "test-bindings",
                "0.1.0",
            )
            .expect("should init client");
            // Client will be dropped here
        }

        // Create another client to verify the first was properly released
        let _client2 = SdkClient::init(
            library,
            &SecretString::from(token),
            "test-bindings-2",
            "0.1.0",
        )
        .expect("should init another client after first was released");
    }
}