corteq-onepassword 0.1.5

Secure 1Password SDK wrapper with FFI bindings for Rust applications
Documentation
//! Protocol types for communicating with the 1Password SDK.
//!
//! The 1Password SDK uses a custom "invocation" format for all operations.
//! This module defines the request and response types for serialization.

use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;

// ============================================================================
// SDK Invocation Request Types
// ============================================================================

/// SDK invocation request wrapper.
///
/// All SDK method calls are wrapped in this structure.
#[derive(Debug, Serialize)]
pub(crate) struct SdkInvocation<P: Serialize> {
    pub invocation: InvocationPayload<P>,
}

impl<P: Serialize> SdkInvocation<P> {
    /// Create a new SDK invocation.
    pub fn new(client_id: u64, method: &'static str, parameters: P) -> Self {
        Self {
            invocation: InvocationPayload {
                client_id,
                parameters: MethodCall {
                    name: method,
                    parameters,
                },
            },
        }
    }

    /// Serialize the invocation to a JSON string.
    pub fn to_json(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string(self)
    }
}

/// The payload of an SDK invocation.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct InvocationPayload<P: Serialize> {
    pub client_id: u64,
    pub parameters: MethodCall<P>,
}

/// A method call with name and parameters.
#[derive(Debug, Serialize)]
pub(crate) struct MethodCall<P: Serialize> {
    pub name: &'static str,
    pub parameters: P,
}

// ============================================================================
// SDK-specific request parameters
// ============================================================================

/// Helper to serialize SecretString by exposing its value.
fn serialize_secret<S>(secret: &SecretString, serializer: S) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    serializer.serialize_str(secret.expose_secret())
}

/// Parameters for initializing the 1Password client.
///
/// Note: `init_client` uses a different format, just the raw config JSON,
/// not wrapped in an invocation.
///
/// All fields are required by the SDK (based on Python SDK's `defaults.py`).
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct InitClientParams {
    /// The service account token for authentication.
    /// Wrapped in SecretString to prevent exposure in Debug output (CWE-532).
    #[serde(serialize_with = "serialize_secret")]
    pub service_account_token: SecretString,
    /// Programming language identifier (e.g., "Rust").
    pub programming_language: String,
    /// SDK version string.
    pub sdk_version: String,
    /// Integration name (for audit logs).
    pub integration_name: String,
    /// Integration version.
    pub integration_version: String,
    /// HTTP request library name (e.g., "reqwest").
    pub request_library_name: String,
    /// HTTP request library version.
    pub request_library_version: String,
    /// Operating system (e.g., "linux", "macos").
    pub os: String,
    /// Operating system version.
    pub os_version: String,
    /// CPU architecture (e.g., "aarch64", "x86_64").
    pub architecture: String,
}

/// Parameters for resolving a single secret.
#[derive(Debug, Serialize)]
pub(crate) struct ResolveSecretParams {
    pub secret_reference: String,
}

// ============================================================================
// SDK method names
// ============================================================================

/// SDK method names as constants.
pub(crate) mod methods {
    /// Resolve a single secret reference.
    pub const SECRETS_RESOLVE: &str = "SecretsResolve";
}

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

    #[test]
    fn test_invocation_serialization() {
        let params = ResolveSecretParams {
            secret_reference: "op://vault/item/field".to_string(),
        };
        let invocation = SdkInvocation::new(123, methods::SECRETS_RESOLVE, params);

        let json = invocation.to_json().unwrap();

        // Check the structure matches expected format
        assert!(json.contains("\"invocation\""));
        assert!(json.contains("\"clientId\":123")); // Number, not string!
        assert!(json.contains("\"parameters\""));
        assert!(json.contains("\"name\":\"SecretsResolve\""));
        assert!(json.contains("\"secret_reference\":\"op://vault/item/field\""));
    }

    #[test]
    fn test_invocation_structure() {
        let params = ResolveSecretParams {
            secret_reference: "op://test/test/test".to_string(),
        };
        let invocation = SdkInvocation::new(42, methods::SECRETS_RESOLVE, params);

        // Verify the JSON parses to expected structure
        let json = invocation.to_json().unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();

        assert!(parsed.get("invocation").is_some());
        let inv = &parsed["invocation"];
        assert_eq!(inv["clientId"], 42); // Number, not string!
        assert_eq!(inv["parameters"]["name"], "SecretsResolve");
        assert_eq!(
            inv["parameters"]["parameters"]["secret_reference"],
            "op://test/test/test"
        );
    }

    #[test]
    fn test_init_client_params_serialization() {
        let params = InitClientParams {
            service_account_token: SecretString::from("ops_test".to_string()),
            programming_language: "Rust".to_string(),
            sdk_version: "0.1.0".to_string(),
            integration_name: "test-app".to_string(),
            integration_version: "1.0.0".to_string(),
            request_library_name: "reqwest".to_string(),
            request_library_version: "0.11".to_string(),
            os: "linux".to_string(),
            os_version: "0.0.0".to_string(),
            architecture: "aarch64".to_string(),
        };

        let json = serde_json::to_string(&params).unwrap();

        // Check camelCase field names
        assert!(json.contains("\"serviceAccountToken\":\"ops_test\""));
        assert!(json.contains("\"programmingLanguage\":\"Rust\""));
        assert!(json.contains("\"sdkVersion\":\"0.1.0\""));
        assert!(json.contains("\"integrationName\":\"test-app\""));
        assert!(json.contains("\"integrationVersion\":\"1.0.0\""));
        assert!(json.contains("\"requestLibraryName\":\"reqwest\""));
        assert!(json.contains("\"os\":\"linux\""));
        assert!(json.contains("\"architecture\":\"aarch64\""));
    }

    #[test]
    fn test_init_client_params_debug_redacts_token() {
        let params = InitClientParams {
            service_account_token: SecretString::from("ops_secret_token_12345".to_string()),
            programming_language: "Rust".to_string(),
            sdk_version: "0.1.0".to_string(),
            integration_name: "test-app".to_string(),
            integration_version: "1.0.0".to_string(),
            request_library_name: "reqwest".to_string(),
            request_library_version: "0.11".to_string(),
            os: "linux".to_string(),
            os_version: "0.0.0".to_string(),
            architecture: "aarch64".to_string(),
        };

        let debug_output = format!("{params:?}");

        // Token should NOT appear in debug output
        assert!(!debug_output.contains("ops_secret_token_12345"));
        assert!(!debug_output.contains("secret_token"));

        // SecretString shows [REDACTED] instead
        assert!(debug_output.contains("[REDACTED]") || debug_output.contains("Secret"));

        // Other fields should still be visible
        assert!(debug_output.contains("Rust"));
        assert!(debug_output.contains("test-app"));
    }
}